LiveView Integration
View Source< Handler Stacks | Up: Recipes | Index | Durable Computation >
AsyncPageMachine 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 AsyncPageMachine + 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
AsyncPageMachine - 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 AsyncPageMachine; the module only needs mount, handle_event, and
render:
defmodule MyApp.CheckoutLive do
use MyAppWeb, :live_view
use Skuld.AsyncCoroutine.AsyncPageMachine,
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} = AsyncPageMachine.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
AsyncPageMachine.run(socket.assigns.runner, {:ok, %{address: addr}})
{:noreply, socket}
end
def handle_event("submit_payment", %{"payment" => payment}, socket) do
AsyncPageMachine.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] && AsyncPageMachine.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} = AsyncPageMachine.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 |
|---|---|
AsyncPageMachine.run/2 | Start flow (async) |
AsyncPageMachine.run/3 | Resume with user input |
AsyncPageMachine.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 AsyncPageMachine:
- 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. AsyncPageMachine 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:. AsyncPageMachine 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. AsyncPageMachine 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.
Sync LiveView with Coroutine.PageMachine
For flows where effects are fast (or absent), Coroutine.PageMachine
runs in-process with no separate BEAM process. The callback functions
are identical to the AsyncPageMachine example above — the only
difference is how the page machine is started:
alias Skuld.Coroutine.PageMachine
defmodule MyApp.CheckoutLive do
use MyAppWeb, :live_view
@impl true
def mount(_params, _session, socket) do
comp =
MyApp.CheckoutFlow.flow(socket.assigns.cart)
|> Port.with_handler(%{
MyApp.Inventory => MyApp.Inventory.Service,
MyApp.Orders => MyApp.Orders.Ecto
})
|> Yield.with_handler()
|> Throw.with_handler()
PageMachine.run(comp, socket,
on_yield: &handle_yield/2,
on_complete: &handle_complete/2,
on_error: &handle_error/2,
on_cancel: &handle_cancel/2
)
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
@impl true
def handle_event("submit_shipping", %{"address" => a}, socket),
do: PageMachine.run(socket.assigns.pm, {:ok, %{address: a}}, socket)
def handle_event("submit_payment", %{"payment" => p}, socket),
do: PageMachine.run(socket.assigns.pm, {:ok, p}, socket)
@impl true
def render(assigns) do
case assigns.step do
:shipping -> ~H|<.shipping_form myself={@myself} />|
:payment -> ~H|<.payment_form myself={@myself} />|
:done -> ~H|<.order_summary order={@order} />|
end
end
endThe handle_* functions are shared between both approaches. Switching
from AsyncPageMachine to PageMachine is just a mechanical change to
mount — the callbacks don't change.
For I/O-bound effects use AsyncPageMachine to keep the LiveView
responsive. For fast effects this is the simplest possible integration.
< Handler Stacks | Up: Recipes | Index | Durable Computation >