← docs.speechwave.live

Lesson 4: the LiveView lifecycle

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.

The big picture

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:

Browser Server (LiveView process) ─────── ────────────────────────── GET /dashboard ───→ mount/3 (static HTML render) │ WebSocket connects ───→ mount/3 (called again, now "connected") │ ▼ Template renders using @assigns │ User clicks button ───→ handle_event/3 │ updates assigns ▼ Template re-renders (only the diff) │ PubSub message arrives → handle_info/2 │ updates assigns ▼ Template re-renders (only the diff)

Three callbacks drive everything:

CallbackWhen it firesWhat it receives
mount/3Page load (twice, see below)URL params, session, socket
handle_event/3User action (click, form submit, keypress)Event name, payload, socket
handle_info/2Message from another processMessage, socket

All three return the socket (with updated assigns), and Phoenix automatically re-renders only the parts of the template that changed.

Rails comparison: Imagine a Rails controller action that never returns. Instead of rendering once and dying, it stays alive, listening for user actions and pushing HTML fragments back. No page reloads, no JavaScript framework needed. That's LiveView.

mount/3: setting up initial state

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:

1. Mount is called twice

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.

Key insight: 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.

2. Pattern matching the params

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.

handle_event/3: responding to user actions

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.

How the template triggers events

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:

How the server handles it

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:

  1. Pattern match: only fires for "react" events, extracts the emoji
  2. Rate-limit check using the socket's session ID
  3. If there's an active talk session, save the reaction to the database
  4. Broadcast the reaction to all subscribed clients via PubSub
  5. Return {:noreply, socket}: no assigns changed on this client's socket
Key insight: {: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.

Form events

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.

handle_info/2: messages from other processes

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:

Rails equivalent: This is like ActionCable, but instead of writing separate JavaScript to handle the WebSocket messages and update the DOM, the server handles everything and pushes the HTML diff. The only JavaScript needed is for things that must run client-side (like the emoji animation via push_event + hooks).

The assigns → template connection

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.

Template syntax quick reference

SyntaxMeaning
{@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

Putting it all together: the react flow

Trace the full path of a single emoji tap in Speechwave:

  1. User loads /t/my-talk → Router maps to TalkLive → mount/3 loads the talk, subscribes to PubSub
  2. User taps the 👏 button → phx-click="react" sends event → handle_event("react", ...) saves reaction, broadcasts via PubSub
  3. Every other TalkLive process receives the PubSub broadcast → handle_info(%Broadcast{event: "new_reaction"}, ...) → push_event triggers the JavaScript animation

Pattern 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.


Practice

Question 1

Why does TalkLive wrap its PubSub subscriptions in if connected?(socket)?

Right. The first mount renders static HTML for the initial page load. There's no WebSocket yet, so subscribing to PubSub would be pointless (and the subscription would be lost when the static render process ends). The second mount has a live WebSocket connection, so subscriptions will actually receive messages.
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.

Question 2

In DashboardLive, the "validate" handler returns:

{:noreply, assign(socket, form: to_form(changeset, action: :validate))}

What effect does this have?

Exactly. Updating the :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.
Think about the assigns → template connection. When you update an assign in a callback, what happens to the template? And a changeset includes validation results without going to the database. Try again.

Question 3

What is the difference between handle_event and handle_info?

Right. 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.
Both callbacks run on the server. The difference is the source of the message. One comes from the user's browser; the other comes from other server-side processes. Try again.

Question 4

When you see phx-value-id={talk.id} in the template and %{"id" => id} in the handler, what connects them?

Correct. Any 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.
Look at the naming: 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.

Your next step

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: