← Back to home

Actions & effects — action, match await, error handling

A page or component action is a named event handler: a block of statements wired to a control (onClick: submit). Actions come in two flavours — a sync action that only touches local state (Proposal A, Stage 1), and an async action that invokes a remote command and discriminates its result with match await (Stage 2 of docs/old/proposals/async-actions-and-effects.md).

This page is the user-facing reference; the design rationale lives in the proposals. As always in Loom, every feature carries two examples — the .ddd source and the generated target output.

Named actions (sync)

page Counter {
  route: "/"
  state { count: int = 0 }
  action inc() { count += 1 }
  body: Stack {
    Heading { "Counter" },
    Button { "+", onClick: inc }
  }
}

A named action hoists to one handler per framework (React const inc = …, LiveView handle_event("inc", …)), referenced by name from the control. A named action is the only home for an effect: an inline effect handler (onClick: e => { count += 1 }) is rejected by loom.effect-in-lambda, so a render-tree lambda stays pure (a value projection like a Table column accessor). This keeps one effect-handler form across the language and, for the MVU/Elmish study, keeps the Model → Html view pure so Msg/update project straight off the action list.

What belongs in a lambda vs an action

The split is read vs write, not "no state." A render-tree lambda — a Table/Column accessor, a For/.map/.filter callback, a data: renderer — is a pure projection: given a bound item, it returns a value or markup. It may read anything and compute freely; it may not perform an effect. An effect — mutating state/store, navigate/toast/emit — lives in a named action, referenced by name from the control (onClick: bump). Effects inside an action body are unrestricted; the rule only governs lambdas in the view.

In a render-tree lambda Allowed?
o => Text { o.code } — render a component ✅ value/markup
o => Text { o.active ? "Y" : "N" } — ternary / match value ✅ computed value
o => Text { o.code + label } — reads state, concatenates ✅ reads are fine
o => Text { initials(o.code) } — value-returning function call ✅ pure call
orders.filter(o => o.active) / .map(...) — collection op ✅ pure callback
onClick: bump — reference a named action ✅ the effect goes here
e => { count := count + 1 } — writes state ❌ loom.effect-in-lambda
e => navigate("/x") — navigate / toast / emit ❌ loom.effect-in-lambda

Rule of thumb: a lambda is any expression that computes a value; anything that does something is an action. Reading state/store/props/derived values inside a lambda is always fine — only writing (or a view effect) is rejected.

// ❌ effect inline in the view
Button { "+", onClick: e => { count += 1 } }

// ✅ effect named; the view just dispatches
action inc() { count += 1 }
Button { "+", onClick: inc }

(One deliberate exception: a direct api-hook call such as onClick: e => { Orders.Order.create.mutate(v) } is not flagged — it is the mutation-hook mechanism, not a free effect statement.)

match await — awaiting a remote command

When an action needs to run a remote, Result-returning aggregate operation (one declared operation foo(): Agg or SomeError), it awaits the call as the subject of a match and discriminates the returned union:

error Rejected { reason: string }

aggregate Order {
  code: string
  operation confirm(): Order or Rejected { … }
}

page OrderDetail {
  route: "/orders/:id"
  state { message: string = "" }
  action confirm() {
    match await Orders.Order.confirm() {
      Order o    => { message := o.code }
      Rejected r => { message := r.reason }
    }
  }
  body: Stack { Button { "Confirm", onClick: confirm } }
}

Generated output — client-side (React/Vue/Svelte/Angular)

The JS frontends run the async boundary in the browser: await the mutation, reify a thrown ApiError back into the error variant, then switch on the tag.

The Feliz frontend reaches the same result through Elmish MVU rather than await/switch: the action dispatches a Msg, the update fn fires Cmd.OfAsync.perform Api.<fn> … <ResultMsg> (src/generator/feliz/update-emit.ts), and a result-Msg arm branches on the variant tag — the F#/Elmish equivalent of the JS try/catch/switch below.

const confirm = async () => {
  let result: ConfirmOrderResponse;
  try {
    result = await orderConfirm.mutateAsync({});
  } catch (e) {
    if (e instanceof ApiError) {
      result = { ...(e.body as Record<string, unknown>), type: "Rejected" } as ConfirmOrderResponse;
    } else { throw e; }
  }
  switch (result.type) {
    case "Order":    { const o = result; setMessage(o.code); break; }
    case "Rejected": { const r = result; setMessage(r.reason); break; }
  }
};

The backend maps an error variant to an RFC-7807 ProblemDetails whose type is the error URI (the tag is clobbered), but the fields survive — so the caught body is re-stamped with the known tag.

Generated output — server-side (Phoenix / HEEx)

LiveView's async boundary is server-side: the handle_event loads the record, runs the op's context function, and cases on the tagged Result tuple — no HTTP round-trip, no reification.

def handle_event("confirm", _params, socket) do
  socket =
    socket
    |> then(fn socket ->
      case Orders.get_order(socket.assigns.id) do
        {:ok, record} ->
          case apply(Orders, :confirm_order, [record, %{}]) do
            {:ok, o}                 -> socket |> assign(:message, o.code)
            {:error, "Rejected", r}  -> socket |> assign(:message, r.reason)
            _                        -> socket
          end
        {:error, :not_found} ->
          put_flash(socket, :error, "Order not found")
      end
    end)
  {:noreply, socket}
end

Multiple error variants

A union may declare more than one error. Each named arm routes independently: the client maps the caught ProblemDetails type URI back to the matching tag.

operation confirm(): Order or Rejected or Blocked { … }

action confirm() {
  match await Orders.Order.confirm() {
    Order o    => { message := o.code }
    Rejected r => { message := r.reason }
    Blocked b  => { message := "blocked" }
  }
}
} catch (e) {
  if (e instanceof ApiError) {
    const __t = (e.body as Record<string, unknown>)?.type;
    const __tag = __t === "/errors/rejected" ? "Rejected" : "Blocked";
    result = { ...(e.body as Record<string, unknown>), type: __tag } as ConfirmOrderResponse;
  } else { throw e; }
}

On HEEx each variant is its own case clause ({:error, "Rejected", r}, {:error, "Blocked", _b}; an unused binder is _-prefixed for --warnings-as-errors).

Naming a context-local error

An error declared inside a context is context-scoped — it is not exported globally (so it can't collide across contexts). It is still nameable in a match-await arm when the page's api X: Y handle binds the context that owns it — the page can only match errors from contexts it actually talks to. An error from an unbound context does not resolve.

The effect marker

A bare (un-awaited) remote mutating call in an action body has an invisible async boundary. The validator flags it — loom.missing-effect-marker (a warning during the Stage-2 ramp) — pointing you at the match await form:

action confirm() {
  Orders.Order.confirm()          // ⚠ loom.missing-effect-marker
}

Reads (byId, finders), sibling-action calls, pure helpers, and view-effects (navigate / toast) are not flagged.

Parameter defaults — param: T = <expr>

Any operation / workflow-start / create parameter may declare a default value, the parameter analogue of a field default (field: T = <expr>). The scaffolded OperationForm / WorkflowForm seeds its input from that default instead of the type-zero placeholder — a suggestion the user can still override (unlike a stamp, which the server owns and hides from the form entirely).

aggregate Shipment {
  eta:    datetime
  status: string
  // constant default → seeds the op form's input
  operation cancel(reason: string = "customer request") { status := "cancelled" }
  // this-relative default → resolves against the target instance
  operation reschedule(to: datetime = this.eta) { eta := to }
}

The default lowers in the operation's env (so this binds the target instance) and rides ParamIR.default. Generated create-form seed (React):

useForm<CancelShipmentRequest>({ defaultValues: { reason: "customer request" } })

Seeding is best-effort over the client-evaluable subset — compile-time constants and enum members. These seed on React (all packs), Vue, Svelte, Angular, and Feliz, in both create forms (field defaults) and operation forms (param defaults). A mistyped default is rejected at the source, exactly like a field default (operation cancel(reason: int = "x") → validation error).

A this.<field> default additionally seeds from the loaded record on an instance-qualified op form (OperationForm { order.<op> }, where the instance is in scope) rendered by a pack that threads the record into its op-form (seedsOpFormRecord) — the React packs (mantine, shadcn, mui, chakra), which pass a record prop into the op-form component, and the Vue packs (vuetify, shadcnVue), which read the in-scope component prop directly (props.<instance>.<field>) since Vue inlines the op-form in <script setup>. The scaffolded Detail page's op modals render inside the record view (the QueryView data lambda), so they are instance-qualified against the loaded record too — a this.<field> op default seeds from it on those packs, the same as the hand-written case. A pack that doesn't thread the record still falls back to type-zero.

// operation note(memo: string = this.customerId) → mantine op-form component
function NoteForm({ mut, record, onClose }: { …; record: OrderResponse; … }) {
  const { … } = useForm<NoteOrderRequest>({ defaultValues: { memo: record.customerId } });

Server-sourced defaults — applied per-request in the create path

An ambient field default the client can't evaluate — now() or currentUser.<claim> — is a stamp the client may override: if the request carries the field, the server uses it; if it's omitted, the server computes the value per-request at create time, exactly like an audit/tenant stamp. So the field is wire-optional and every backend's create path coalesces it, reusing that backend's stamp value renderer + ambient-principal accessor. (This is the authoritative mechanism — the /prepare endpoint below is only a React UX affordance on top.)

aggregate Order with crudish { customerId: string  createdAt: datetime = now() }
// Hono — the create field is optional; the factory coalesces per-request
createdAt: z.coerce.date().optional(),
const created = Order.create({ customerId: body.customerId,
  createdAt: body.createdAt !== undefined ? body.createdAt : new Date() });
# Python — optional model field + coalesce in the handler
createdAt: datetime | None = None
created_at=body.createdAt if body.createdAt is not None else datetime.now(UTC)

A currentUser.<claim> default binds the ambient principal the same way (request.OwnerId is null ? RequestContext.Current!.CurrentUser!.TenantId : … on .NET, current_user.tenant_id off the request scope on Python/Java/Elixir). All five backends do this; the default is authoritative server-side, so a raw client that never touches the form still gets the real, per-request value. A constant default (status: string = "draft") is unchanged — it stays a serializer default (.default("draft") / = "draft"), which has no evaluated-once-at-boot problem; only the non-constant (server-sourced) set switches to optional-wire + coalesce, keyed by the shared isServerSourcedDefault predicate. A sequence or cross-aggregate lookup default is still deferred (not yet server-sourced) and keeps falling back to the type-zero seed.

The React prepare UX layer. So the create form can show the user the value the server will default to (still editable), the backend also emits GET /<plural>/prepare returning just the server-sourced keys, and every React pack's create form fetches it (usePrepare<Agg>) and resets the form over its type-zero seed once it resolves — keepDirtyValues so a slow response never clobbers what the user typed:

const __prep = usePrepareOrder();                                   // GET /orders/prepare
const form = useForm({ resolver, defaultValues: { customerId: "", createdAt: "" } });
useEffect(() => { if (__prep.data) form.reset({ ...{ customerId: "", createdAt: "" }, ...__prep.data }, { keepDirtyValues: true }); }, [__prep.data]);

The endpoint keys and the form's fetched keys both derive from serverSourcedDefaultFields, so they can't drift. The overlay is React-only, gated per pack by manifest.seedsServerDefaults — the hooks are RHF-shaped, so a Vue/Svelte/Angular pack renders the plain create form (no overlay, no dangling import). That's purely cosmetic: with or without the overlay, the server applies the default on submit, so a non-React frontend is fully correct — it just doesn't pre-fill the field.

Everything outside this tier falls back to the type-zero seed: a this.<field> on the by-name op form or a non-threading pack, and the still-deferred sources above. ParamIR.default is carried in the IR for a future backend that applies op-param defaults server-side (today none do — op params are required on the wire and the form always seeds + sends them).

Further reading