How Speechwave's pages come alive
Primary source: Phoenix.LiveView docs, which have a complete lifecycle description. Read the "Life-cycle" section after this lesson.
A LiveView is a long-lived server process that holds state and pushes HTML updates to the browser over a WebSocket. There's no REST request/response cycle. The process stays alive as long as the user is on the page.
Every LiveView has the same shape:
Three callbacks drive everything:
| Callback | When it fires | What it receives |
|---|---|---|
mount/3 | Page load (twice, see below) | URL params, session, socket |
handle_event/3 | User action (click, form submit, keypress) | Event name, payload, socket |
handle_info/2 | Message from another process | Message, socket |
All three return the socket (with updated assigns), and Phoenix automatically re-renders only the parts of the template that changed.
Let's look at both of Speechwave's main LiveViews.
lib/speechwave_web/live/dashboard_live.ex:8-28
def mount(_params, _session, socket) do
scope = socket.assigns.current_scope
user = scope.user
{:ok,
assign(socket,
talks: Talks.list_talks(scope),
form: to_form(Talk.changeset(%Talk{}, %{})),
created_talk: nil,
selected_talk: nil,
selected_talk_url: nil,
selected_qr_data_uri: nil,
sessions: [],
renaming_session_id: nil,
rename_form: nil,
full_session_count: Talks.count_full_sessions_this_month(scope),
session_limit: Plans.limit(:full_sessions_per_month, user.plan),
participant_limit: Plans.limit(:max_participants, user.plan),
qr_cache: %{}
)}
end
Every key in that assign call becomes an @variable in the template. When you see {@talks} in the HEEx template, it's reading the :talks assign set here.
Now look at TalkLive, the audience-facing page:
lib/speechwave_web/live/talk_live.ex:8-22
def mount(%{"slug" => slug}, _session, socket) do
case Talks.get_talk_by_slug(slug) do
nil ->
{:ok, redirect(socket, to: "/")}
talk ->
if connected?(socket) do
Phoenix.PubSub.subscribe(Speechwave.PubSub, "reactions:#{slug}")
Phoenix.PubSub.subscribe(Speechwave.PubSub, "slides:#{slug}")
end
{:ok,
assign(socket, talk: talk, emojis: @emojis, session_id: socket.id, current_slide: 0)}
end
end
Two important things here:
On the first call, the page is being rendered as static HTML (for fast initial load and SEO). On the second call, the WebSocket has connected and the LiveView is "live." The connected?(socket) check distinguishes the two. You only subscribe to PubSub on the second mount, because PubSub needs a live connection.
connected?(socket) is the standard way to defer work that only makes sense when the WebSocket is alive: subscriptions, timers, expensive API calls. If you see it in a mount, it's guarding against running that code during the static HTML render.
DashboardLive uses _params because it doesn't need URL params. TalkLive pattern matches %{"slug" => slug} to extract the slug from /t/:slug. This is the same map destructuring from Lesson 1, now applied to URL parameters defined in the router.
When the user clicks a button or submits a form in the browser, the event arrives at handle_event/3. The template wires events using phx- attributes.
lib/speechwave_web/live/talk_live.html.heex:18-20
<button
phx-click="react"
phx-value-emoji={emoji}
>
When clicked, this sends an event with:
"react" (from phx-click)%{"emoji" => "👏"} (from phx-value-emoji)lib/speechwave_web/live/talk_live.ex:24-39
def handle_event("react", %{"emoji" => emoji}, socket) do
if RateLimiter.allow?(socket.assigns.session_id) do
case Talks.get_active_session(socket.assigns.talk.id) do
nil -> :ok
session -> Reactions.create_reaction(session, emoji, socket.assigns.current_slide)
end
SpeechwaveWeb.Endpoint.broadcast!(
"reactions:#{socket.assigns.talk.slug}",
"new_reaction",
%{emoji: emoji}
)
end
{:noreply, socket}
end
Read this step by step:
"react" events, extracts the emoji{:noreply, socket}: no assigns changed on this client's socket{:noreply, socket} means "I'm done, here's the (possibly updated) socket." If you changed assigns, the template automatically re-renders the affected parts. If you didn't change assigns (as above), nothing re-renders. There is no :reply variant in LiveView events. The "reply" is the re-rendered HTML.
Forms use two events: phx-change (fires on every keystroke) and phx-submit (fires on submit).
lib/speechwave_web/live/dashboard_live.html.heex:108
<.form for={@form} id="talk-form" phx-change="validate" phx-submit="save">
The corresponding handlers:
lib/speechwave_web/live/dashboard_live.ex:32-42 (validate)
def handle_event("validate", %{"talk" => attrs}, socket) do
slug =
if attrs["title"] != "" and attrs["slug"] == "" do
Talks.generate_slug(attrs["title"])
end
changeset = Talk.changeset(%Talk{}, Map.put(attrs, "slug", slug))
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
end
On every keystroke, the form params arrive, a changeset is built (which runs validations), and the form assign is updated. The template re-renders, showing validation errors in real time, with no JavaScript validation code needed.
lib/speechwave_web/live/dashboard_live.ex:44-67 (save)
def handle_event("save", %{"talk" => attrs}, socket) do
scope = socket.assigns.current_scope
case Talks.create_talk(scope, attrs) do
{:ok, talk} ->
# ... update many assigns on success
{:noreply, assign(socket, ...)}
{:error, changeset} ->
{:noreply, assign(socket, form: to_form(changeset))}
end
end
On submit: try to create the talk. On success, update the assigns to show the new talk. On failure, put the error changeset back into the form so validation messages appear. This is the pattern match + pipe + assign pattern from the previous three lessons all working together.
This is what makes LiveView live. Other processes can send messages to a LiveView, and it can update the page in response. This is how Speechwave shows emoji reactions in real time.
lib/speechwave_web/live/talk_live.ex:41-53
def handle_info(
%Phoenix.Socket.Broadcast{event: "new_reaction", payload: %{emoji: emoji}},
socket
) do
{:noreply, push_event(socket, "new_reaction", %{emoji: emoji})}
end
def handle_info(
%Phoenix.Socket.Broadcast{event: "slide_changed", payload: %{slide: slide}},
socket
) do
{:noreply, assign(socket, :current_slide, slide)}
end
When someone taps an emoji on their phone, the handle_event("react", ...) broadcasts to PubSub. Every other TalkLive process subscribed to that topic receives the broadcast as a handle_info message. The pattern match on the struct extracts the event type and payload.
Two different responses:
push_event: sends a message to a JavaScript hook on the client (the emoji animation)assign: updates server-side state, which re-renders the templatepush_event + hooks).
Every assign set in the LiveView is available in the template as @name:
| In the LiveView (.ex) | In the template (.heex) |
|---|---|
assign(socket, talks: [...]) | {@talks} |
assign(socket, selected_talk: talk) | {@selected_talk.title} |
assign(socket, form: to_form(changeset)) | <.form for={@form}> |
When you change an assign in a callback, Phoenix diffs the template and sends only the changed HTML to the browser. This is why every callback returns the socket. Phoenix needs it to compute the diff.
| Syntax | Meaning |
|---|---|
{@variable} | Output an assign value (escaped) |
<%= if ... do %> | Conditional rendering |
<%= for item <- @list do %> | Loop (like .each) |
<.form>, <.input>, <.link> | Function components (defined in CoreComponents) |
<Layouts.app> | A component from the Layouts module |
phx-click="name" | Send click event to handle_event |
phx-value-key={value} | Attach data to the event payload |
phx-change="name" | Send form change event on each keystroke |
phx-submit="name" | Send form submit event |
phx-hook="Name" | Attach a JavaScript hook to this element |
Trace the full path of a single emoji tap in Speechwave:
/t/my-talk → Router maps to TalkLive → mount/3 loads the talk, subscribes to PubSubphx-click="react" sends event → handle_event("react", ...) saves reaction, broadcasts via PubSubhandle_info(%Broadcast{event: "new_reaction"}, ...) → push_event triggers the JavaScript animationPattern matching selects which callback clause runs. Pipes transform data inside the clause. Assigns connect the LiveView state to the template. Context modules handle the database work. Everything from Lessons 1-3 converges here.
Why does TalkLive wrap its PubSub subscriptions in if connected?(socket)?
connected?(socket) checks whether the LiveView has a live WebSocket connection to the browser. Think about when mount runs, and how many times. Try again.In DashboardLive, the "validate" handler returns:
{:noreply, assign(socket, form: to_form(changeset, action: :validate))}
What effect does this have?
:form assign triggers a re-render of the template. Because the changeset carries validation errors and the action: :validate flag tells the form components to show them, the user sees error messages appear in real time as they type. No JavaScript validation code, no separate API call.What is the difference between handle_event and handle_info?
handle_event is for user-initiated actions arriving from the browser via the WebSocket. handle_info is for server-side messages from other Elixir processes: PubSub broadcasts, timer ticks, task completions. Both run on the server; the difference is who sent the message.When you see phx-value-id={talk.id} in the template and %{"id" => id} in the handler, what connects them?
phx-value-xyz attribute on the element is sent as %{"xyz" => value} in the event payload. The handler's pattern match on %{"id" => id} extracts it. Note that the value is always a string, which is why you see String.to_integer(id) in several handlers.phx-value-id in the template, %{"id" => id} in the handler. The part after phx-value- becomes the key in the payload map. Try again.Open talk_live.ex and talk_live.html.heex side by side. Trace the connection between each phx- attribute in the template and its corresponding callback. Then do the same for dashboard_live.ex and its template. It has more events, so it's a richer exercise.
For each handle_event, ask yourself:
phx-value-* attributes?)