LiveView Integration

View Source

< AsyncCoroutine | Index | Batch Loading > | Umbrella →

LiveView pages are state machines — model, view, and update all living in the same module. But these concerns are tangled: business logic, effect calls, and socket manipulation mingle in handle_event and handle_info callbacks. It's the equivalent of putting reducer logic, API calls, and DOM updates in a single function.

PageMachine separates these concerns, Elixir-style. Like Elm, Redux, or re-frame, it enforces a Model-View-Update architecture. But instead of pure reducers that must return immediately, PageMachine uses coroutines: each state machine is a sequential computation that can suspend mid-execution, surface updates to the view, and resume where it left off.

A PageMachine runs one or more concurrent spindles — named coroutine fibers, each an independent state machine with its own event stream and its own yields to the LiveView. A single-spindle page is just a page machine with one spindle. A multi-spindle page runs a product browser and a checkout form, a chat panel and a document editor — each region its own computation, its own testable module. The LiveView routes events to the right spindle and forwards yields from each to the right UI region.

Why extract page state machines

LiveView tests are slow. Even with DoubleDown replacing the database sandbox (often a 250× speedup for tests whose main bottleneck was Ecto sandbox DB I/O), the process mechanics dominate — mount, render, socket assigns, DOM diffs. The test time floor is set by the LiveView process itself.

But a LiveView page is a state machine. The Moore model maps directly:

Moore conceptLiveView
Statesocket.assigns
Transitionhandle_event(event, _, socket)
Output (UI)render(socket.assigns)

If you extract the state machine into a pure module — one that receives events and returns new state, with no LiveView dependency — you can test the page logic with plain assert. No process. No LiveViewTest. No DOM.

Pattern

  1. Define a typed protocol with use Skuld.PageMachine.Contract and defspindle blocks
  2. Write each page region as an effectful computation using the protocol's yield functions
  3. Test each in isolation with Coroutine — deterministic, no processes
  4. Wrap the page in a thin LiveView module via use Skuld.PageMachine, protocol: ... with /3 callbacks
  5. Computations fork sub-computations with Spindle.fork; yields update the UI

Example: single-spindle product browser

A product search page — one spindle, one event loop. The user types a query, the spindle fetches results, yields them to the LiveView, then waits for the next event.

Protocol

The protocol is the single source of truth for the spindle ↔ LiveView contract. defspindle opens a spindle block; inside, defevent declares events the LiveView can send to this spindle, defyield declares blocking yields, and defnotify declares fire-and-forget notifications.

defevent takes an event name, an explicit struct name, and typed params. It generates a struct module under the spindle (e.g. Search.SearchEvent) and tells PageMachine to wrap incoming params into that struct before resuming the spindle.

defyield uses function-head syntax — defyield browsing generates a 0-arity function (Search.browsing()) that yields an empty struct. defnotify results(products: [...], total: integer()) generates a fire-and-forget notification — the spindle surfaces results and continues without pausing:

defmodule MyApp.SearchProtocol do
  use Skuld.PageMachine.Contract

  defspindle Search do
    defevent "search", SearchEvent, params: [query: String.t()]
    defevent "filter", FilterEvent, params: [filters: map()]

    defyield browsing
    defnotify results(products: [Product.t()], total: integer())
  end
end

Spindle

The spindle is the computation — a linear flow that fetches data, surfaces results via defnotify, and suspends on defyield to wait for the next event. It uses the protocol's generated functions (Search.results(...), Search.browsing()) and pattern-matches on the protocol's generated structs (%Search.SearchEvent{}, %Search.FilterEvent{}):

defmodule MyApp.SearchSpindle do
  use Skuld.Syntax

  alias MyApp.SearchProtocol.Search

  defcomp run(initial_filters) do
    search_loop(initial_filters)
  end

  defcomp search_loop(filters) do
    {:ok, products, total} <- MyApp.ProductCatalog.search(filters)
    _ <- Search.results(products: products, total: total)
    event <- Search.browsing()

    case event do
      %Search.SearchEvent{query: query} ->
        search_loop(%{query: query})

      %Search.FilterEvent{filters: filters} ->
        search_loop(filters)
    end
  end
end

LiveView

defmodule MyApp.SearchLive do
  use MyAppWeb, :live_view
  use Skuld.PageMachine,
    protocol: MyApp.SearchProtocol,
    on_yield: &handle_yield/3,
    on_complete: &handle_complete/3,
    on_error: &handle_error/3

  alias MyApp.SearchProtocol.Search

  @impl true
  def mount(_params, _session, socket) do
    socket =
      PageMachine.run(socket, Search => MyApp.SearchSpindle.run(%{}))
      |> assign(products: [], total: 0)

    {:ok, socket}
  end

  def handle_yield(_spindle, %Search.Results{products: products, total: total}, socket) do
    {:noreply, assign(socket, products: products, total: total)}
  end

  def handle_yield(_spindle, %Search.Browsing{}, socket), do: {:noreply, socket}

  def handle_complete(_spindle, {:error, reason}, socket) do
    {:noreply, put_flash(socket, :error, "Search failed: #{inspect(reason)}")}
  end

  def handle_error(_spindle, reason, socket) do
    {:noreply, put_flash(socket, :error, "Error: #{inspect(reason)}")}
  end

  @impl true
  def render(assigns) do
    ~H"""
    <div>
      <.search_form myself={@myself} />
      <.product_list products={@products} total={@total} myself={@myself} />
    </div>
    """
  end
end

A few things to note:

  • /3 callbacks from the start — the spindle module atom is the first argument. With a single spindle it's unused (_spindle), but using /3 from the beginning means no refactoring when you add a second spindle — just add another clause.
  • defevent + struct namedefevent "search", SearchEvent, params: [...] generates Search.SearchEvent. The auto-generated handle_event wraps the incoming params into the struct before resuming the spindle. This is why the case event in the spindle pattern-matches on %Search.SearchEvent{}.
  • defyield generates both a struct and a functiondefyield browsing produces %Search.Browsing{} and Search.browsing(). defnotify results(...) produces %Search.Results{} and Search.results(products: ..., total: ...) — same pattern, but defnotify is fire-and-forget: the spindle doesn't pause.

Test

test "search yields results", %{comp: comp} do
  fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
  assert %Coroutine.ExternalSuspended{value: %Search.Results{}} = fiber
end

test "filter event triggers new search", %{comp: comp} do
  fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
  fiber = Coroutine.run(fiber, %Search.FilterEvent{filters: %{category: "books"}})
  assert %Coroutine.ExternalSuspended{value: %Search.Results{}} = fiber
end

Adding a second spindle: checkout

When you need an independent region with its own event loop — say a checkout form alongside the product browser — add a second spindle. The protocol gains a Checkout block, the LiveView switches to /3 callbacks, and the product spindle forks the checkout spindle on demand.

Boundary contracts

defmodule MyApp.ProductCatalog do
  use Skuld.Effects.Port.EffectfulFacade

  defcallback search(filters :: map()) ::
              {:ok, [Product.t()], total :: integer()} | {:error, term()}
end

defmodule MyApp.Orders do
  use Skuld.Effects.Port.EffectfulFacade

  defcallback place(cart :: map(), shipping :: map(), payment :: map()) ::
              {:ok, Order.t()} | {:error, term()}
end

defmodule MyApp.Inventory do
  use Skuld.Effects.Port.EffectfulFacade

  defcallback reserve(cart :: map()) :: {:ok, term()} | {:error, term()}
end

Protocol

The protocol extends to include Checkout alongside Search. The Checkout spindle declares events for form submissions — when the user fills the shipping and payment forms, the LiveView routes those events back to the checkout spindle as typed structs:

defmodule MyApp.StoreProtocol do
  use Skuld.PageMachine.Contract

  defspindle Search do
    defevent "search", SearchEvent, params: [query: String.t()]
    defevent "filter", FilterEvent, params: [filters: map()]
    defevent "buy", BuyEvent, params: [product: Product.t()]

    defyield browsing
    defnotify results(products: [Product.t()], total: integer())
  end

  defspindle Checkout do
    defevent "submit_shipping", ShippingEvent, params: [shipping: map()]
    defevent "submit_payment", PaymentEvent, params: [payment: map()]

    defyield shipping
    defyield payment
  end
end

Spindle computations

The search spindle is extended with a %Search.BuyEvent{} branch that forks the checkout spindle and continues its own loop:

defmodule MyApp.SearchSpindle do
  use Skuld.Syntax

  alias MyApp.StoreProtocol.{Search, Checkout}

  defcomp run(initial_filters) do
    search_loop(initial_filters)
  end

  defcomp search_loop(filters) do
    {:ok, products, total} <- MyApp.ProductCatalog.search(filters)
    _ <- Search.results(products: products, total: total)
    event <- Search.browsing()

    case event do
      %Search.BuyEvent{product: product} ->
        _handle <- Spindle.fork(Checkout, MyApp.CheckoutSpindle.run(product))
        search_loop(filters)

      %Search.FilterEvent{filters: filters} ->
        search_loop(filters)

      %Search.SearchEvent{query: query} ->
        search_loop(%{query: query})
    end
  end
end

The checkout spindle is forked with the selected product. It reserves inventory, then yields %Checkout.Shipping{} and %Checkout.Payment{} to drive a step-by-step form in the LiveView:

defmodule MyApp.CheckoutSpindle do
  use Skuld.Syntax

  alias MyApp.StoreProtocol.Checkout

  defcomp run(product) do
    {:ok, _} <- MyApp.Inventory.reserve(%{product: product})
    %Checkout.ShippingEvent{shipping: shipping} <- Checkout.shipping()
    %Checkout.PaymentEvent{payment: payment} <- Checkout.payment()
    {:ok, order} <- MyApp.Orders.place(%{product: product}, shipping, payment)
    {:ok, order}
  else
    {:error, :sold_out} -> {:error, :sold_out}
    {:error, reason} -> {:error, reason}
  end
end

LiveView

With two spindles, the LiveView switches to /3 callbacks — the first argument is the spindle module atom. The :protocol option auto-generates handle_event/3 from the protocol's defevent declarations:

defmodule MyApp.StoreLive do
  use MyAppWeb, :live_view
  use Skuld.PageMachine,
    protocol: MyApp.StoreProtocol,
    on_yield: &handle_yield/3,
    on_complete: &handle_complete/3,
    on_error: &handle_error/3

  alias MyApp.{SearchSpindle, CheckoutSpindle}
  alias MyApp.StoreProtocol.{Search, Checkout}

  @impl true
  def mount(_params, _session, socket) do
    socket =
      PageMachine.run(socket, Search => SearchSpindle.run(%{}))
      |> assign(products: [], total: 0)

    {:ok, socket}
  end

  def handle_yield(Search, %Search.Results{products: products, total: total}, socket) do
    {:noreply, assign(socket, products: products, total: total)}
  end

  def handle_yield(Search, %Search.Browsing{}, socket), do: {:noreply, socket}

  def handle_yield(Checkout, %Checkout.Shipping{}, socket) do
    socket = clear_spinner(socket)
    {:noreply, assign(socket, step: :shipping)}
  end

  def handle_yield(Checkout, %Checkout.Payment{}, socket) do
    socket = clear_spinner(socket)
    {:noreply, assign(socket, step: :payment)}
  end

  def handle_complete(Search, {:error, reason}, socket) do
    {:noreply, put_flash(socket, :error, "Search failed: #{inspect(reason)}")}
  end

  def handle_complete(Checkout, {:ok, order}, socket) do
    socket = clear_spinner(socket)
    {:noreply, assign(socket, order: order, step: :done)}
  end

  def handle_error(Checkout, :sold_out, socket) do
    socket = clear_spinner(socket)
    {:noreply, put_flash(socket, :error, "Sorry, this item is no longer available")}
  end

  def handle_error(Checkout, reason, socket) do
    socket = clear_spinner(socket)
    {:noreply, put_flash(socket, :error, "Checkout failed: #{inspect(reason)}")}
  end

  defp start_spinner(socket), do: assign(socket, :loading, true)
  defp clear_spinner(socket), do: assign(socket, :loading, false)

  @impl true
  def render(assigns) do
    ~H"""
    <div class="store-layout">
      <div class="product-browser">
        <.search_form myself={@myself} loading={@loading} />
        <.product_list products={@products} total={@total} myself={@myself} />
      </div>
      <div class="checkout-panel">
        <%= case assigns[:step] do %>
          <% :shipping -> %>
            <.shipping_form loading={@loading} myself={@myself} />
          <% :payment -> %>
            <.payment_form loading={@loading} myself={@myself} />
          <% :done -> %>
            <.order_summary order={@order} />
          <% _ -> %>
            <p>Select a product to start checkout</p>
        <% end %>
      </div>
    </div>
    """
  end
end

The key differences from the single-spindle version:

  • Additional clauseshandle_yield(Search, ...) and handle_yield(Checkout, ...) dispatch by spindle module atom. The single-spindle used _spindle; here each clause names its spindle.
  • Spindle.fork — the search spindle forks a checkout spindle rather than handling the buy event itself. The fork returns a Handle (ignored with _handle); the parent continues running. Completion and errors from the child are delivered via the LiveView.
  • mount starts only the primary spindle — the checkout spindle doesn't exist until a BuyEvent arrives.

Testing in isolation

Each spindle is tested independently with Coroutine — deterministic, no processes, no stubs. Here's the search spindle from the single-spindle example:

defmodule MyApp.SearchSpindleTest do
  use ExUnit.Case, async: true

  alias Skuld.Comp.Env
  alias Skuld.Coroutine
  alias Skuld.Effects.Yield
  alias MyApp.SearchProtocol.Search

  setup do
    comp =
      MyApp.SearchSpindle.run(%{category: "electronics"})
      |> Port.with_handler(%{
        MyApp.ProductCatalog => fn _, :search, [%{category: "electronics"}] ->
          {:ok, [%Product{name: "Phone"}], 1}
        end
      })
      |> Yield.with_handler()

    {:ok, comp: comp}
  end

  test "first search yields results to the LiveView", %{comp: comp} do
    fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
    assert %Coroutine.ExternalSuspended{value: %Search.Results{}} = fiber
  end

  test "filter event triggers new search and new results", %{comp: comp} do
    fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
    fiber = Coroutine.run(fiber, %Search.FilterEvent{filters: %{category: "books"}})
    assert %Coroutine.ExternalSuspended{value: %Search.Results{}} = fiber
  end
end

These tests run in microseconds. There's no need for a LiveView process — just pure state transitions.

Why this works

Each spindle knows nothing about LiveView. It doesn't import Phoenix.LiveView. It doesn't touch sockets, assigns, or DOM. It's a pure function: (state, event) -> new_state. Yield marks the points where the machine pauses for external input; if and case branch the flow.

This means:

  • Tests are fast: no LiveView process, no DOM rendering.
  • Tests are deterministic: same input → same path, every time.
  • Tests are property-testable: generate inputs, assert paths.
  • The LiveView module is thin: it only bridges events and renders based on the current step.

Comparison to Elm / Redux / MVU

This architecture is an Elixir-based answer to the Model-View-Update pattern that Elm enforces and Redux patterns towards:

ConceptElm/Redux/re-framePageMachine
ModelStore / app-dbScoped effects + fiber
UpdateReducer / event handlerComputation (defcomp)
ViewPure renderrender(assigns)
EventAction / dispatchhandle_event / protocol defspindle defevent
State update:db effectYield.yield(tag)

In Elm and Redux, the reducer is a pure (state, event) -> state function — it must return the new state immediately, without blocking.

PageMachine lifts this constraint with spindles — named coroutines that can suspend mid-execution, surface state to the view via Yield.yield, and resume where they left off when the next event arrives. Multiple spindles run concurrently in the same server process: a product search doesn't block checkout form submission, and UI regions update independently because each yield carries the spindle key.

In re-frame terms, Yield.yield(tag) is analogous to returning a :db effect: it updates the store (assigns), making new state visible to the view's data subscriptions. The /3 callback signature maps naturally to re-frame's event handler receiving the event name as the first argument.

In standard LiveView, business logic, effect calls, and socket manipulation mingle in handle_event / handle_info callbacks — the equivalent of putting reducer logic, API calls, and DOM updates in a single function. PageMachine separates them: the computation is the update function, the LiveView is the view bridge. Effects are inline (MyApp.ProductCatalog.search(filters, page) is a typed function call, not a dispatched action intercepted by middleware), keeping the types visible and the flow linear.

How the spindles collaborate

When the user clicks "buy," the product spindle receives the event, forks a checkout spindle via Spindle.fork, and continues its search loop. The checkout spindle runs its linear flow independently. If the user searches while the checkout form is still open, those events go to the product spindle — the checkout spindle is unaffected.

Cancellation and cleanup

Cancel on mount to prevent duplicate runners:

def mount(_params, _session, socket) do
  if connected?(socket) do
    socket.assigns[:pm] && PageMachine.cancel(socket.assigns.pm)
  end
  ...
end

Cancellation cascades to all spindles — PageMachine.cancel/1 exits the FiberPool.Server process, which cancels all registered fibers.

Operation reference

OperationPurpose
PageMachine.run/1,2Start page machine with spindle keys
PageMachine.resume/3Resume a spindle with a value
PageMachine.cancel/1Cancel page machine and spindles
Spindle.fork/2Fork a spindle from a computation
use Skuld.PageMachine, protocol: ...Typed protocol with auto-generated events and compile-time validation
use Skuld.PageMachine.ContractDefine a typed event/yield protocol

Comparison to a monolithic LiveView

Without spindles, the product browser and checkout form would share a single handle_event. Filter changes, pagination, shipping collection, and payment collection would all live in the same callback function, tangled with socket-assign manipulation. Adding a third region (say, a recommendations carousel) would require more conditional logic in the same flat handler.

With spindles, each region is a self-contained computation. Adding a recommendations carousel is a new spindle and a /3 callback clause. The existing spindles don't change.


< AsyncCoroutine | Index | Batch Loading > | Umbrella →