F5.09 · part 2 of 3
A LiveView mount sketch
The facade was built to be called by a UI, and a LiveView is that UI. This is a sketch — F6
builds the real thing — but it shows the discipline the boundary buys: the LiveView touches only
Portal and %Portal.Error{}, never the engine or the store.
mount/3 loads the starting state with a facade query and puts it in
assigns. handle_event/3 turns a click into a facade command and
branches on the closed error contract from F5.08.3. render/1 draws from
assigns and knows nothing about how progress is computed. The loop — event,
command, re-assign, re-render — is the whole of how a LiveView and the engine talk.
Three callbacks, one boundary. Select a callback to see what it does and which facade function it calls.
Three callbacks at the edge
A LiveView is three callbacks. mount/3 runs once to set the initial
assigns; handle_event/3 runs on each user action;
render/1 turns the current assigns into markup. Each one that
reaches the engine does so through the facade and nowhere else. Select a callback.
LiveView callbacks · select one
callback: mount/3
it does: loads state via a query
A LiveView that only ever names facade functions can be read without knowing the engine exists. That is the boundary doing its job: the UI author thinks in enrollments and progress, not in processes and logs.
Mount and handle_event
mount/3 asks the facade for the current progress and stores it in
assigns; if the query fails, it assigns the error message instead.
handle_event/3 issues a command, and on success re-queries and re-assigns; on a typed
error it assigns the message. The same closed error contract handles both reads and writes.
defmodule PortalWeb.EnrollmentLive do use PortalWeb, :live_view alias Portal.Error # mount loads initial state through a facade query — no engine, no store @impl true def mount(%{"id" => enrollment_id}, _session, socket) do case Portal.progress_of(enrollment_id) do {:ok, percent} -> {:ok, assign(socket, enrollment_id: enrollment_id, progress: percent, error: nil)} {:error, %Error{message: msg}} -> {:ok, assign(socket, progress: nil, error: msg)} end end # an action becomes a command, branched on the closed error contract (F5.08.3) @impl true def handle_event("deliver_lesson", %{"lesson" => lesson_id}, socket) do case Portal.deliver_lesson(socket.assigns.enrollment_id, lesson_id) do :ok -> {:ok, percent} = Portal.progress_of(socket.assigns.enrollment_id) {:noreply, assign(socket, progress: percent, error: nil)} {:error, %Error{message: msg}} -> {:noreply, assign(socket, error: msg)} end end end
Render from assigns
The template reads only from assigns. It shows the error if one is set and the
progress if one is loaded, and a click sends the deliver_lesson event back to the
callback above. There is no mention of the engine, the log, or how progress is derived — the render is a
function of state the facade handed over.
@impl true def render(assigns) do ~H""" <section class="enrollment"> <h1>Your progress</h1> <p :if={@error} class="flash">{@error}</p> <progress :if={@progress} value={@progress} max="100">{@progress}%</progress> <button phx-click="deliver_lesson" phx-value-lesson={@next_lesson}> Mark lesson done </button> </section> """ end
Render is total too: @error is either a string or nil, and the
:if guards make both cases draw something sensible. The closed error contract reaches
all the way to the markup.
The event loop
Put together, the LiveView and the engine talk in a loop. A click sends an event; the callback calls a facade command; the facade runs the engine; the result re-assigns; the changed assigns re-render. The cycle never reaches past the facade, so the engine can change underneath without the template noticing.
event → command → re-render
the UI thinks in domain terms
mount, event, render all speak enroll and progress, not process and log.
only the facade, ever
Every callback that reaches the engine does so through Portal and the error contract.
Next: what ships in F6 — what F6 inherits from this lab unchanged, and what it replaces.
A function component for engine state
The render/1 above inlines its markup. A second technique factors that markup out into a
named function component: a plain function from assigns to HEEx, declared
with Phoenix.Component (Phoenix — Phoenix.Component). Each input the component reads
is announced with an attr, so a missing or misspelled assign is a compile-time error
rather than a blank in the page. The function returns markup through the ~H sigil and is
invoked from a template as <.progress_card .../>.
This is where the boundary pays off in the view layer. The engine’s state — a progress percent from the
facade query Portal.progress_of/1 and the next lesson from a second facade query,
Portal.next_lesson_for/1 — becomes a small
set of assigns, and the function component turns those assigns into server-rendered
markup. The component reads @progress, @error, and
@next_lesson only; it never names Portal, the engine, or
%Portal.Error{}. Mapping engine state to assigns, then assigns to markup, keeps each step
a pure function of its input.
engine state → assigns → markup
defmodule PortalWeb.EnrollmentComponents do use Phoenix.Component # each assign the component reads is declared — a missing one fails at compile time attr :progress, :integer, default: nil attr :error, :string, default: nil attr :next_lesson, :string, required: true # a pure function from assigns to markup — no Portal, no engine, no %Error{} def progress_card(assigns) do ~H""" <section class="enrollment"> <h1>Your progress</h1> <p :if={@error} class="flash">{@error}</p> <progress :if={@progress} value={@progress} max="100">{@progress}%</progress> <button phx-click="deliver_lesson" phx-value-lesson={@next_lesson}> Mark lesson done </button> </section> """ end end
The render/1 of PortalWeb.EnrollmentLive then calls the component
and passes the assigns the facade queries produced. The engine state arrived as @progress
from progress_of/1 and @next_lesson from
next_lesson_for/1; the call wires them straight through.
import PortalWeb.EnrollmentComponents @impl true def render(assigns) do ~H""" <.progress_card progress={@progress} error={@error} next_lesson={@next_lesson} /> """ end # with @progress = 60, @error = nil, @next_lesson = "m3-evolve" the component renders: # <section class="enrollment"> # <h1>Your progress</h1> # <progress value="60" max="100">60%</progress> # <button phx-click="deliver_lesson" phx-value-lesson="m3-evolve">Mark lesson done</button> # </section> # => server-rendered markup, never naming Portal or %Portal.Error{}
Engine state maps to assigns, and a function component maps assigns to markup; with each input declared
by an attr, the boundary that began at the facade reaches all the way into the rendered
page, checked at compile time.
References
Primary sources for this lesson, and where it connects in the course.
Sources
- Phoenix — Phoenix.LiveView — server-rendered, stateful UI over the engine.
- Phoenix — Phoenix.Component (HEEx) — function components and the HEEx template.
- Elixir — Supervisor — the engine runs under a supervision tree.