F6.06 · part 2 of 3
handle_event & state
Interaction in LiveView is a message, not a request. A binding names the event; the socket carries
it; handle_event/3 returns new state.
A page element is annotated with a binding —
phx-click, phx-change, phx-submit —
that names an event. The client sends that name and a map of params over the socket, and
handle_event/3 receives the three: the name, the params, and the socket. It transforms the
assigns and returns {:noreply, assign(socket, ...)}; the changed state re-renders the view
and the diff travels back, with no DOM code on either side. The discipline is the F5 one exactly — a message
arrives, new state is computed, the new state is returned — so a search box that filters the catalog, a
button that toggles a panel, and a form that creates a record are each one handle_event
clause over the same Portal context from F6.04 and the
same changeset from F6.03. The domain does not change; it is reached over a socket, and the result
is a live UI without a page reload.
Three bindings, one callback
A binding ties a DOM event to a named server event. phx-click fires on a click;
phx-change fires when an input changes, on every keystroke;
phx-submit fires when a form is submitted. All three deliver to the same place —
handle_event/3, matched on the event name — and the markup declares only
that an event happens, never what it does. Select one.
Event bindings · select one
binding: phx-click
fires: a click sends an event
The event is only a name and a map of params. The server decides what it means, which keeps all the behaviour in Elixir — the same place the F6.04 contexts already live.
The event loop
One turn of the loop: the browser sends a named event over the socket;
handle_event/3 matches it, calls a context if needed, and writes new assigns;
returning {:noreply, socket} re-renders; the diff travels back. The process stays put
the whole time — only its assigns change, which is what makes the turn cheap.
event → handle_event → assign → diff
{:noreply, socket} is the usual return: handle the event, keep the
connection. {:reply, map, socket} exists for when a client JavaScript hook needs a direct
answer, but most events only update state and re-render.
A live search box, computed
The worked detail is a search field bound with phx-change="search". Each keystroke
sends the "search" event with the field value; the handler calls the catalog and
re-assigns @courses; the list re-renders with the matches. The filter below runs the
same rule the server uses — a case-insensitive substring match of the query against each course
title — over a fixed seed of Portal.list_courses/0 data, so the count and the
rows it produces are the rows handle_event/3 would assign. Type a query.
phx-change="search" · live filter over the catalog
- Functional FoundationsPublished
- Processes & OTPPublished
- Phoenix in PracticePublished
- Ecto & Persistence
- LiveView Patterns
- Pattern Matching Deep DivePublished
handler: handle_event("search", %{"q" => q}, socket) → assign(socket, courses: Portal.search_courses(q), query: q)
The value comes back in the params map keyed by the input’s name (here
"q"), and value={@query} keeps the box in sync with the assign.
Filtering itself lives in the context; the LiveView only wires the event to it.
The handler in Elixir
The search clause is two lines: pattern-match the event name and the param map, then return
{:noreply, ...} with the filtered list re-assigned. The markup binds the form with
phx-change and renders the course_card from F6.05.2 over
@courses — the same card, now with fewer items as the query narrows.
# one clause per event name; the search clause filters and re-assigns @impl true def handle_event("search", %{"q" => q}, socket) do {:noreply, assign(socket, courses: Portal.search_courses(q), query: q)} end # Portal.search_courses/1 — the one UI-only catalog reader, delegated to the context defdelegate search_courses(query), to: Portal.Catalog, as: :search # Portal.Catalog.search/1 — a case-insensitive substring match on the title def search(query) do q = String.downcase(String.trim(query)) list_courses() |> Enum.filter(fn c -> String.contains?(String.downcase(c.title), q) end) end # Portal.Catalog.search("live") # => [%Course{title: "LiveView Patterns"}] # Portal.Catalog.search("") # => all courses (an empty query matches every title)
The template binds the form once and lets the assign drive the list:
<%!-- the search box sends "search" on every keystroke --%> <form phx-change="search"> <input type="text" name="q" value={@query} placeholder="Search courses" /> </form> <.course_card :for={course <- @courses} course={course} />
It reads like a GenServer with a view: a message in, new state out, the projection re-rendered. The
handle_event/3 clause and the context call are the entire behaviour; nothing reaches the
DOM by hand.
A live create form
A second clause handles a form bound with phx-submit="create". It calls the
same Portal.create_course/1 from F6.04 and branches on its closed
result: on {:ok, _course} it flashes and reloads the list; on
{:error, %Ecto.Changeset{}} it re-assigns to_form(changeset)
so the F6.05 inputs show the errors — now in place, without a full request.
@impl true def handle_event("create", %{"course" => params}, socket) do case Portal.create_course(params) do {:ok, _course} -> {:noreply, socket |> put_flash(:info, "Course created") |> assign(courses: Portal.list_courses(), query: "")} {:error, %Ecto.Changeset{} = changeset} -> {:noreply, assign(socket, form: to_form(changeset))} end end # valid params # => {:noreply, socket} (flash set, @courses reloaded, @query cleared) # invalid params # => {:noreply, socket} (@form carries the changeset, inputs show errors)
Both outcomes return {:noreply, socket}: success and validation failure are
two different new states. The page never reloads, and the error path is the ordinary changeset contract from
F6.03, reached over the socket.
Why it matters downstream
This callback is the seam the rest of the chapter builds on. Because every interaction is a message that returns
new state, the next dive — render & diffs — can
send only the assigns a clause changed, never the whole page. And because a clause is an ordinary process
message, the same handle_* family that F5 used for
handle_call and handle_cast also receives PubSub broadcasts in
F6.07: a handle_info/2 clause updates the very same assigns a
handle_event/3 clause does, so a change made by one learner can re-render every open
page. The discipline established here — message in, new assigns out, {:noreply,
socket} — is what carries forward.
F5 gave the rule
A handle_call/handle_cast takes a message and returns new
state — the GenServer contract.
handle_event is that
A browser event in, new assigns out, {:noreply, socket} — the same shape, now
with a view re-rendered from the result.
The bridge is exact: handle_event/3 is the LiveView analogue of a
GenServer’s handle_call and handle_cast — the same
message-in, state-out actor from F5, with HEEx as the projection of the state.
Recap
The bindings name events; the callback turns them into state; the contexts do the work.
- phx-click
- Sends a named event on click —
phx-click="toggle". - phx-change
- Sends a named event on every input change, with the field values as params — the live-filter binding.
- phx-submit
- Sends a named event on form submit, with all field values — the live-create binding.
- handle_event/3
- Receives
(name, params, socket), transforms the assigns, returns{:noreply, assign(socket, ...)}; the state change re-renders. - search
Portal.search_courses/1filters the catalog; the clause only re-assigns@coursesand@query.- create
Portal.create_course/1from F6.04;{:ok, _}reloads the list,{:error, changeset}assignsto_form/1.
Next: render & diffs — when these clauses re-assign state, only the changed values travel the socket. How that diff is computed is the last piece of the loop.
References
Primary sources for the bindings and the event callback, and where this dive connects in the course.
Sources
- Phoenix LiveView — Bindings —
phx-click,phx-change,phx-submit. handle_event/3— Phoenix.LiveView — the callback contract and return values.- Phoenix LiveView — Form bindings — live validation and submission with
to_form/1.
Related in this course
- F6.06 · Phoenix LiveView fundamentals
- F6.06.1 · mount & assigns — where
@coursesand@queryare first set. - F6.06.3 · render & diffs — how a re-assign becomes a diff.
- F6.04 · Contexts — the catalog the handler calls.