The table¶
Use guards to choose a transition and actions to update private data or describe effects. This page explains how those parts of the login table work together.
The idea¶
A table has five everyday parts:
:initial— where the machine starts.:data— private working memory.:guards— yes/no predicates.:actions— return{:data … :fx …}; they never perform side effects.:states— the nodes and their outgoing transitions.
Register and drive¶
The first machine defines login-flow. Register that value
under an event id:
(ns app.login
(:require [re-frame.core :as rf]
[re-frame.machines])) ;; forget this → :rf.error/machines-artefact-missing
(rf/reg-machine :auth.login/flow login-flow)
reg-machine registers an event handler that reads the snapshot, takes a
transition, writes the next snapshot and returns the action effects.
Drive it with dispatch, as the first machine
does: the event id is the machine id, and the second element is the trigger
the table matches. At the REPL, read the framework subscription using the
demo frame:
(rf/subscribe-once [:rf/machine :auth.login/flow] {:frame login-frame})
;; => {:state :submitting :data {:attempts 0 :error nil} :tags #{:auth/busy}}
The snapshot lives in runtime-db, so undo, time-travel, and SSR hydration work without extra wiring.
Transition forms¶
An :on entry can be written in three forms.
A bare keyword is sugar for {:target :submitting}, and a vector of keywords
is the same sugar for a path target.
A map gives the transition a guard, an action, and other options. Its keys
are :target, :guard, :action, :reenter?
(self-transitions) and :meta.
:on {:auth.login/failure [{:target :error-shown
:guard :under-retry-limit
:action :record-error}
{:target :locked-out
:action :record-error}]}
A vector of maps is a first-match-wins candidate vector. The runtime tries each candidate in order and takes the first whose guard passes. Put an unguarded default last when the event must be handled.
Guards and actions¶
Every callback receives one context map:
:state is the state the machine was in before this transition, in every
slot, :entry included. :meta is the snapshot's :meta, which starts as
the machine root's :meta.
There is no :db. A machine cannot see app-db. That
is strict encapsulation.
Guards¶
Return truthy or falsey. There is no combinator DSL — compound logic is ordinary Clojure:
:guards
{:under-retry-limit (fn [{data :data}] (< (:attempts data) 2))
:form-valid? (fn [{[_ creds] :event}]
(and (seq (:email creds)) (seq (:password creds))))}
A guard sees the snapshot before the transition's action runs, so
:under-retry-limit counts only the failures already recorded: < 2
allows three attempts.
Reference by id (:guard :form-valid?) or inline a one-liner. Prefer named
ids — traces and Xray can address them.
Actions¶
Return descriptions, the same idea as reg-event:
:actions
{:clear-error (fn [_] {:data {:error nil}})
:issue-request
(fn [{[_ creds] :event}]
{:fx [[:rf.http/managed
{:request {:method :post :url "/api/login" :body creds
:request-content-type :json}
:request-id :auth.login/request
:decode :json
:on-success [:auth.login/flow [:auth.login/success]]
:on-failure [:auth.login/flow [:auth.login/failure]]}]]})}
The first machine explains the artefact this effect needs, the reply envelope, and the one-element-short target shape.
The effect map {:data :fx}¶
| Key | Meaning |
|---|---|
:data |
Merged into the snapshot's current :data, top-level keys only: a nested map you return replaces the one there. Explicit nil sets a key to nil; it does not remove keys. |
:fx |
Ordinary effects vector (:dispatch, :rf.http/managed, :rf.machine/spawn, …), plus the machine-only :raise. |
Both keys are optional; nil / {} means no effects. A returned :db is
dropped with :rf.error/machine-action-wrote-db, and the rest of the
transition commits.
:fx cannot read this action's own :data write
Both keys are returned together. Bind fresh values in a let and use the
local in both places, or write in the transition action and read in the
target's :entry.
Entry, exit, and transition actions¶
A transition can run up to three action slots, in this order:
- source state's
:exit - transition's
:action - target state's
:entry
Their :data updates accumulate in order; their :fx vectors concatenate in
order.
:submitting
{:tags #{:auth/busy}
:entry :issue-request
:on {:auth.login/success {:target :authed :action :store-session}
:auth.login/failure […]}}
Use :entry for work that should happen whenever the state is entered —
issuing the request, so every path into :submitting fires it. Use :exit
for cleanup.
Each slot takes one fn or one action id. A vector is refused
(:rf.error/machine-bad-action-form); to do two things, call both from one
action and merge what they return:
(defn clear-error [_] {:data {:error nil}})
(defn count-attempt [{data :data}] {:data {:attempts (inc (:attempts data))}})
:actions
{:clear-and-count
(fn [ctx]
(let [a (clear-error ctx)
b (count-attempt ctx)]
{:data (merge (:data a) (:data b))
:fx (into (:fx a []) (:fx b []))}))}
Strict encapsulation¶
A guard or action sees only its context map, plus :rf.cofx when it declares
a coeffect. To reach anything else:
| Need | How |
|---|---|
| Fact from outside | Put it on the event when you dispatch |
| Write outside the machine | Return :fx [[:dispatch […]]] — a real, named event |
| Clock, random value or subscription | Declare a coeffect on a named guard or action; see Advanced |
Actions describe effects; only the transition's :target chooses the next
state.
The snapshot¶
{:state :submitting
:data {:attempts 1 :error nil}
:tags #{:auth/busy}} ;; omitted when no active state declares tags
| Slot | Role |
|---|---|
:state |
Discrete state — keyword (flat), path vector (hierarchy), or region map (parallel) |
:data |
Machine-private memory |
:tags |
Runtime-projected union of active states' tags |
A live snapshot also carries framework-owned :rf/* keys, at its root and
inside :data. The snapshots printed in this guide show only those a page is
about, so compare the slots you care about rather than a whole :data map,
and never write one of those keys yourself.
[:rf/machine id] is nil until the first event. A view that renders earlier
should fall back to the definition's :initial and :data. To boot a
singleton eagerly instead, dispatch the reserved start marker at startup:
(rf/dispatch [:auth.login/flow [:rf.machine/start]] {:frame login-frame}). It runs the initial
entry — :entry actions fire, :after timers arm — and stops; it never
matches an :on transition. The first ordinary event runs the same initial
entry before it is handled, and either way those :entry actions see :event
as [:rf.machine/start], never the trigger that started the machine.
Do not build views that switch on detailed :state shapes unless the exact
state is the product decision. For "busy", "read-only", "connected", use
state tags.
:data must survive pr-str and read-string: no functions, atoms or host
objects. That is what lets a snapshot persist. Save the machines from
frame-state-value and hand them back at boot with :rf/install-frame-state,
which restores spawned children and re-arms :after timers without re-running
:entry (Persist and restore).
Unhandled events are no-ops¶
If the current state has no transition for an event, the machine ignores it.
The snapshot does not move. A benign :rf.machine.event/unhandled-no-op
trace records the drop.
Broken definitions, such as a target or guard that does not exist, throw at
registration. Read the :rf.error/id in the exception's ex-data and fix the
named part. The API reference
lists the grammar and registration errors.
Self-transitions and wildcards¶
A leaf self-transition runs its action without re-entering the state by
default. In the optional turnstile exercise, pushing a
locked turnstile counts a push without leaving :locked.
| Shape | Effect |
|---|---|
No :target (targetless) |
Action only — no exit/entry; timers and spawns undisturbed |
:target the same state, no :reenter? |
Same on a leaf (action only). A compound re-resolves descendants to :initial |
:reenter? true |
Full exit → action → entry (timers reset, spawns restart) |
A self-rescheduling poll uses the external form so :entry re-fires:
:polling
{:entry :start-fetch
:after {30000 {:target :polling :reenter? true}}
:on {:got-data {:action :merge}
:stop :idle}}
A self-target without :reenter? true does not re-run :entry. If you
meant to re-arm a timer, say so.
Wildcards on :on keys, most-specific first: exact id → :ns/* → :*.
:tracking
{:on {:mouse/down {:action :begin-drag}
:mouse/* {:action :note-move}
:* {:action :log-unknown}}}
A forbidden handler — {:on {:E {}}} or {:on {:E nil}} — consumes
the event and stops the search (how a child opts out of a parent
transition). A missing key is a silent no-op. A bare id like :go has
no :ns/* tier — only exact or :*. A guard-blocked exact match can fall
through to a wildcard.
Testing¶
machine-transition calculates the next snapshot and effect descriptions
without executing them. Inspecting and testing
shows how to test an accepted submit and a guard-blocked one.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Dev warning :rf.warning/machine-source-unstamped |
(def m {…}) then reg-machine |
Use defmachine, or pass a literal map |
Registration throws :rf.error/machine-unresolved-guard (or -action, -target) |
Named ref missing from the table | Add the name, or fix the typo |
Registration throws :rf.error/machine-unknown-node-key |
A misspelt or XState key (:invoke, :cond), or :on-done on a leaf |
Use a key the message lists; namespace your own |
Registration throws :rf.error/machine-bad-action-form |
:entry, :exit or :action is a vector |
One fn or action id; call several from one fn |
Action reports :rf.error/machine-action-wrote-db |
Returned :db, which is dropped |
Update the snapshot via :data; write app-db through a named event in :fx |
| Dispatch does nothing | Current state has no matching :on |
Expected no-op (:rf.machine.event/unhandled-no-op). Bad names fail at registration |
| External dispatch of a private event is refused | Id is in :internal-events |
Raise it from an action, or drop it from the set |
Macrostep fails :rf.error/machine-always-depth-exceeded or -raise-depth-exceeded |
Eventless / :raise loop did not settle |
Break the cycle; a targetless :always whose action makes its guard false is the safe loop. The default bound is 16 |
A missing artefact (:rf.error/machines-artefact-missing, or
:rf.error/no-such-fx on :rf.http/managed) is covered in
First machine → Troubleshooting.
Advanced¶
Registration and hot reload¶
Two registration shapes:
| Shape | Use when |
|---|---|
defmachine + reg-machine |
Named, reusable specs (Xray click-to-source on guards and actions) |
Inline reg-machine with a literal map |
Small local machines |
Avoid (def m {…}) then (reg-machine :id m). The macro never sees the
literal, so source stamps are empty and dev warns
:rf.warning/machine-source-unstamped.
In every frame that has an :id, a hot reload keeps the live snapshot and
applies the new table from the next event; a frame made without an :id keeps
the table it was made with. If the reload removed the current state, the
machine restarts from :initial before handling that event, and reports
:rf.error/machine-state-not-in-definition.
Declared coeffects¶
A declared coeffect arrives under :rf.cofx on the callback map. Read it
there, (:rf/time-ms (:rf.cofx ctx)) — it is not a top-level :rf/time-ms
key. Inline callbacks cannot declare requirements
(:rf.error/machine-cofx-requires-inline). Declare every key a callback reads:
an undeclared key is not ensured, so it can read nil, and reg-machine warns
:rf.warning/machine-cofx-consume-undeclared in development.
:guards
{:within-retry-window?
{:rf.cofx/requires [:rf/time-ms]
:fn (fn [{:keys [data] {:keys [rf/time-ms]} :rf.cofx}]
(< (- time-ms (:first-attempt-at data)) 60000))}}
See one run¶
This optional turnstile exercise isolates self-transitions. It has no HTTP
or form setup. Click into the cell and press Ctrl-Enter
(Cmd-Enter on macOS):
(require '[re-frame.core :as rf])
(rf/reg-machine :turnstile/flow
{:initial :locked
:data {:coins 0 :pushes 0}
:actions {:take-coin (fn [{data :data}] {:data (update data :coins inc)})
:count-push (fn [{data :data}] {:data (update data :pushes inc)})}
:states
{:locked {:on {:coin {:target :unlocked :action :take-coin}
:push {:target :locked :action :count-push}}}
:unlocked {:on {:push {:target :locked}
:coin {:target :unlocked :action :take-coin}}}}})
(rf/reg-view turnstile-view []
(let [{:keys [state data]} (or @(subscribe [:rf/machine :turnstile/flow])
{:state :locked :data {:coins 0 :pushes 0}})
open? (= state :unlocked)]
[:div {:style {:font-family "sans-serif"}}
[:p "state: " [:strong {:style {:color (if open? "green" "crimson")}} (str state)]]
[:p "coins: " (:coins data) " · pushes: " (:pushes data)]
[:button {:on-click #(dispatch [:turnstile/flow [:coin]])} "insert coin"]
[:button {:on-click #(dispatch [:turnstile/flow [:push]])} "push"]]))
[rf/frame-root {:id :demo}
[turnstile-view]]
Try it
Push while locked — the door stays locked, but the push counter climbs (a
self-transition with an action). Dispatch an unknown event
[:turnstile/flow [:wat]] — silent no-op (benign
:rf.machine.event/unhandled-no-op trace). Almost every other mistake
(bad target, missing guard name) fails loud at registration.
Final states¶
- Ordinary leaf with no outgoing transitions — the machine persists
(login's
:authed). Do not set:final?. Optional{:meta {:terminal? true}}is documentation for you and for tools; it does not destroy anything. - Root-level
:final? trueleaf — the machine finishes and is destroyed. Use it for spawned protocols that finish. A final leaf inside a compound finishes only that sub-flow; the surrounding machine keeps running.
A :final? state is a leaf with no way out: it may run :entry and :exit,
but :on, :always, :after, :spawn and :spawn-all there are refused
(:rf.error/machine-final-state-has-transitions), and so are child :states
(:rf.error/machine-final-state-compound). :output-key and :error? belong
only beside :final?.
Nested finals and parent :on-done live in
Hierarchical states and Actors.
Schemas¶
A machine can validate its private :data in development. Require
[re-frame.schemas] once at boot; without it, :schemas checks nothing and
says nothing:
;; Add to the root of login-flow.
:schemas {:data [:map
[:attempts :int]
[:error [:maybe :string]]]}
A failed data validation rolls the transition back before the bad snapshot
reaches runtime-db (:where :machine-data).
:schemas {:output …} validates the value a :final? leaf reports through
:output-key. The machine has already finished, so a failure is reported
(:where :machine-output) and the value is delivered anyway.
A schema does not hide a value from traces. To redact a secret in :data,
name its path on the machine, starting from the snapshot:
{:sensitive [[:data :token]]}. Machine traces redact that slot for every
instance, spawned ones included — see Keep secrets out of traces.
A [:rf/machine id] subscription carries the same classification: its
:rf.sub/run trace, and an off-box read of it by query vector, redact those
slots (and size-mark any declared :large paths) exactly as machine traces do,
while reading the sub in-process returns the real values.
State node keys¶
Besides :on, :entry and :exit, a state takes keys that later pages
teach: child states, eventless and delayed transitions, deadlines, history,
parallel regions, actors, tags and final states. The root is a state too, and
also holds the machine's own blocks, such as :data, :guards and :actions.
Machine spec in the API reference
lists every key and the page that teaches it.
Raise and internal events¶
An action can return :fx [[:raise [:auth/check-session]]] to handle another
trigger inside the same macrostep. The final snapshot commits once. Declare
:internal-events #{:auth/check-session} when external dispatches should not
send that trigger. Automatic transitions
shows a complete example and explains ordering and depth limits.