jonnifyelixir
F6 / F6.06 / events

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

phx-click phx-click="toggle" — a click sends the "toggle" event on click phx-change phx-change="search" — each keystroke sends "search" on input phx-submit phx-submit="create" — form submit sends "create" on submit
A click on a bound element sends the named "toggle" event to handle_event/3, which updates assigns and re-renders — no onclick handler in JavaScript.

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

BROWSER phx-change event handle_event/3 call a context, build assigns {:noreply, socket} assign render/1 recompute the markup diff to browser the process persists across the loop — only its assigns change

{: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.

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/1 filters the catalog; the clause only re-assigns @courses and @query.
create
Portal.create_course/1 from F6.04; {:ok, _} reloads the list, {:error, changeset} assigns to_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

Related in this course