jonnifyelixir
F5 / F5.09 / mount

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

MOUNT/3 · once Portal.progress_of(id) → assign(socket, progress: ...) query HANDLE_EVENT/3 · per action Portal.deliver_lesson(...) → case :ok / {:error, %Error{}} command RENDER/1 · on change ~H from @progress, @error — no engine details assigns

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

RENDER assigns → DOM phx-click HANDLE_EVENT Portal.deliver_lesson command FACADE :ok | {:error} re-assign · re-render — the loop never reaches past the facade

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

ENGINE STATE progress_of/1 → {:ok, percent} next_lesson_for/1 → {:ok, id} @progress · @next_lesson ASSIGNS @progress, @error, @next_lesson MARKUP <.progress_card> ~H via attr two queries hand over attr-checked
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

Related in this course