LiveView Integration
View Source< Handler Stacks | Up: Recipes | Index | Durable Computation >
PageMachine bridges effectful computations into Phoenix LiveView,
enabling pausable page flows that can be tested without a LiveView process.
Why extract page state machines
LiveView tests are slow. Even with DoubleDown replacing the database sandbox (often a 250× speedup), 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 concept | LiveView |
|---|---|
| State | socket.assigns |
| Transition | handle_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.
Use PageMachine + Yield to structure the state machine as an
effectful computation. Test it deterministically with Coroutine.
Wrap it in a thin LiveView module that delegates events.
Pattern
- Write the page flow as an effectful computation using
Yield - Test it with
Coroutine— deterministic, no processes - Wrap it in a thin LiveView module via
PageMachine - LiveView sends user events as resume values; yields update the UI
Example: checkout flow
A checkout with two external effects and two user interaction steps.
Inventory reservation and order placement are typed effectful calls;
shipping collection and payment collection are user yields. Branching
is plain if.
First, the boundary contracts and the pure state machine:
defmodule MyApp.Orders do
use Skuld.Effects.Port.EffectfulFacade
defcallback place(cart :: Cart.t(), shipping :: map(), payment :: map()) ::
{:ok, Order.t()} | {:error, term()}
end
defmodule MyApp.Inventory do
use Skuld.Effects.Port.EffectfulFacade
defcallback reserve(cart :: Cart.t()) :: {:ok, term()} | {:error, term()}
end
defmodule MyApp.CheckoutFlow do
use Skuld.Syntax
alias Skuld.Effects.Yield
defcomp flow(cart) do
# Effect: reserve inventory (external API, can fail)
{:ok, _} <- MyApp.Inventory.reserve(cart)
# User interaction: collect shipping
{:ok, shipping} <- Yield.yield(:shipping)
# User interaction: collect payment
{:ok, payment} <- Yield.yield(:payment)
# Effect: place order (external API, can fail)
{:ok, order} <- MyApp.Orders.place(cart, shipping, payment)
{:ok, order}
else
{:error, :sold_out} -> {:error, :sold_out}
{:error, reason} -> {:error, reason}
:cancelled -> {:error, :cancelled}
end
endThe LiveView module — a thin shell. handle_info is generated by
use PageMachine; the module only needs mount, handle_event, and
render:
defmodule MyApp.CheckoutLive do
use MyAppWeb, :live_view
use Skuld.AsyncCoroutine.PageMachine,
tag: :checkout,
on_yield: &handle_yield/2,
on_complete: &handle_complete/2,
on_error: &handle_error/2,
on_cancel: &handle_cancel/2
@impl true
def mount(_params, _session, socket) do
flow =
MyApp.CheckoutFlow.flow(socket.assigns.cart)
|> Port.with_handler(%{
MyApp.Inventory => MyApp.Inventory.Service,
MyApp.Orders => MyApp.Orders.Ecto
})
{:ok, runner} = PageMachine.run(flow, :checkout)
{:ok, assign(socket, runner: runner, step: :loading)}
end
defp handle_yield(step, socket) do
{:noreply, assign(socket, step: step)}
end
defp handle_complete({:ok, order}, socket) do
{:noreply, assign(socket, order: order, step: :done)}
end
defp handle_error(:sold_out, socket) do
{:noreply, put_flash(socket, :error, "Sorry, this item is no longer available")}
end
defp handle_error(reason, socket) do
{:noreply, put_flash(socket, :error, "Checkout failed: #{inspect(reason)}")}
end
defp handle_cancel(reason, socket) do
{:noreply, push_navigate(socket, to: ~p"/cart")}
end
def handle_event("submit_shipping", %{"address" => addr}, socket) do
PageMachine.run(socket.assigns.runner, {:ok, %{address: addr}})
{:noreply, socket}
end
def handle_event("submit_payment", %{"payment" => payment}, socket) do
PageMachine.run(socket.assigns.runner, {:ok, payment})
{:noreply, socket}
end
@impl true
def render(assigns) do
case assigns.step do
:loading -> ~H|<.spinner />|
:shipping -> ~H|<.shipping_form myself={@myself} />|
:payment -> ~H|<.payment_form myself={@myself} />|
:done -> ~H|<.order_summary order={@order} />|
end
end
endTesting without LiveViewTest
The state machine module has no LiveView dependency. Test it directly
with Coroutine — deterministic, no processes, no stubs:
defmodule MyApp.CheckoutFlowTest do
use ExUnit.Case, async: true
alias Skuld.Comp.Env
alias Skuld.Coroutine
alias Skuld.Effects.Port
alias Skuld.Effects.Throw
alias Skuld.Effects.Yield
setup do
comp =
MyApp.CheckoutFlow.flow(%Cart{items: [...]})
|> Port.with_handler(%{
MyApp.Inventory => fn _, :reserve, [_] -> {:ok, :reserved} end,
MyApp.Orders => fn _, :place, _, _ -> {:ok, %Order{}} end
})
|> Yield.with_handler()
|> Throw.with_handler()
{:ok, comp: comp}
end
test "normal checkout flow", %{comp: comp} do
fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
assert %Coroutine.ExternalSuspended{value: :shipping} = fiber
fiber = Coroutine.run(fiber, {:ok, %{address: "123 Main"}})
assert %Coroutine.ExternalSuspended{value: :payment} = fiber
fiber = Coroutine.run(fiber, {:ok, %{card: "4242"}})
assert %Coroutine.Completed{result: {:ok, %Order{}}} = fiber
end
test "inventory sold out returns error", %{comp: comp} do
comp =
MyApp.CheckoutFlow.flow(%Cart{items: [...]})
|> Port.with_handler(%{
MyApp.Inventory => fn _, :reserve, [_] -> {:error, :sold_out} end
})
|> Yield.with_handler()
|> Throw.with_handler()
fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
# Inventory call fails immediately — never reaches shipping
assert %Coroutine.Completed{result: {:error, :sold_out}} = fiber
end
test "cancellation at any step propagates", %{comp: comp} do
fiber = comp |> Coroutine.new(Env.new()) |> Coroutine.run()
assert %Coroutine.ExternalSuspended{value: :shipping} = fiber
# Cancel instead of submitting
%Coroutine.Cancelled{} = Coroutine.cancel(fiber, :cancelled)
end
endThese tests run in microseconds. No browser, no LiveView process, no socket — just pure state transitions.
Why this works
The state machine 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 branches 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.
Cancellation and cleanup
Cancel on mount to prevent duplicate runners:
def mount(_params, _session, socket) do
if connected?(socket) do
socket.assigns[:runner] && PageMachine.cancel(socket.assigns.runner)
end
...
endWith EffectLogger
Persist flow state for resumption after disconnects:
flow = MyApp.CheckoutFlow.flow(cart)
|> EffectLogger.with_logging()
|> Reader.with_handler(%{})
{:ok, runner} = PageMachine.run(flow, :checkout)
# On yield: ExternalSuspend.data carries decorations from scoped
# effects — EffectLogger attaches its log, State can attach current
# value via :suspend, etc.
# On reconnect: cold-resume from serialised logOperation reference
| Operation | Purpose |
|---|---|
PageMachine.run/2 | Start flow (async) |
PageMachine.run/3 | Resume with user input |
PageMachine.cancel/1 | Cancel flow |
Comparison: traditional FSM approach
For contrast, here's the same checkout flow using Crank,
a Moore-style pure state machine library. Effects are declared via wants:
and executed by an external loop; failures are explicit state transitions.
defmodule MyApp.CheckoutCrank do
@states ~w(idle reserving shipping payment placing done failed)a
use Crank, states: @states
# idle → reserving: declare the effect, advance the state
def turn({:start, cart}, :idle, _memory) do
{:next, :reserving, %{cart: cart},
wants: {:reserve_inventory, cart}}
end
# Effect result arrives as an event — match both outcomes
def turn({:reserve_inventory, {:ok, _}}, :reserving, memory) do
{:next, :shipping, memory}
end
def turn({:reserve_inventory, {:error, :sold_out}}, :reserving, memory) do
{:next, :failed, memory, reading: :sold_out}
end
# User interactions — external events (the caller feeds them in)
def turn({:submit_shipping, address}, :shipping, memory) do
{:next, :payment, Map.put(memory, :shipping, address)}
end
def turn({:submit_payment, method}, :payment, memory) do
{:next, :placing, Map.put(memory, :payment, method),
wants: {:place_order, memory.cart, memory.shipping, method}}
end
# Order placement — another effect with success/failure
def turn({:place_order, {:ok, order}}, :placing, memory) do
{:next, :done, %{memory | order: order},
reading: {:ok, order}}
end
def turn({:place_order, {:error, reason}}, :placing, memory) do
{:next, :failed, memory,
reading: {:error, reason}}
end
endThe key differences from PageMachine:
- States are explicit atoms —
idle,reserving,shipping, etc. Each transition declares its source state. A typo in a state name is caught at compile time (using unknown state). - Effects are external —
wants: {:reserve_inventory, cart}is a declaration, not an execution. The caller must run an effect loop: checkwants, execute the effect, feed the result back as an event. PageMachine inlines effects as<-binds. - Failures are states —
{:error, :sold_out}goes to:failed. Each new failure mode requires a new state or at least a new transition clause carrying context viareading:. PageMachine uses theelseclause — all failures flow through the same path. - User interactions are events — the LiveView calls
Crank.turnwith{:submit_shipping, address}. No difference in mechanism from effect results — both are events fed into the machine.
Which approach is better? Crank gives you compile-time state validation and a visibly enumerated state space — ideal when you want a formal state diagram. PageMachine gives you linear flow with inline effects and a single error path — ideal when the flow is complex and branching is natural. Both test fast without processes. Pick the one that reads most naturally for the problem at hand.
< Handler Stacks | Up: Recipes | Index | Durable Computation >