2. The table¶
The first machine filled the slots. This page is the rest of
the flat table contract — registration, the snapshot, transition forms,
encapsulation, self-transitions, finals, schemas, and :raise. It does not
rebuild the login walk-through.
Where should this value live?
A machine fits when a value has a lifecycle of named states — not when it is only data to store. See Where should this value live?.
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.
Resting leaves such as :authed keep the snapshot around so a view can still
render them. They are not :final? — that flag destroys the machine
(Final states).
Register and drive¶
A machine is an event handler. reg-machine compiles the table into a
reg-event whose body reads the live snapshot, takes a
transition, writes the new snapshot, and returns the action's effects.
(:require [re-frame.core :as rf]
[re-frame.machines]) ;; forget this → :rf.error/machines-artefact-missing
(rf/reg-machine :auth.login/flow login-flow)
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.
You drive the machine with dispatch, not send. The outer vector is a
re-frame2 event whose id is the machine id. The second element is the
trigger the table matches:
:auth.login/submit is the :on key. credentials is payload, read from
:event in a guard or action. The landing page
introduces this split; the first machine drives it.
Subscribe with the framework sub — there is no function sugar:
@(rf/subscribe [:rf/machine :auth.login/flow])
;; => {:state :submitting :data {:attempts 0 :error nil} :tags #{:auth/busy}}
;; nil before the first event — this is a singleton
The snapshot lives in runtime-db, so undo, time-travel, and SSR hydration work without extra wiring.
A singleton is the registered id. A spawned actor is a second live instance of a type, with an allocated id — Actors.
Async composes
Point managed HTTP replies at the machine:
:on-success [:auth.login/flow [:auth.login/success]] (outer event id,
inner trigger). The reply is appended onto the trigger. Full
walk-through in the first machine.
See one run¶
A tiny clickable machine, not the login flow. Use it to feel a
self-transition. 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.
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 |
[: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]]). It runs the initial
entry — :entry actions fire, :after timers arm — and stops; it never
matches an :on transition.
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.
Transition forms¶
An :on entry can be written in three forms.
A bare keyword is sugar for {:target :submitting}.
A map gives the transition a guard, an action, and other options.
:on {:auth.login/failure [{:target :error-shown
:guard :under-retry-limit
:action :record-error}
{:target :locked-out
:action :record-error}]}
A vector is a first-match-wins candidate list. 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. The lockout candidate also runs
:record-error, so the terminal failure is counted.
Guards and actions¶
Every callback receives one context map:
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. On a
three-attempt lockout, :under-retry-limit therefore reads the count from
failures already recorded, and the boundary sits one below the total you
want — < 2 for three attempts. The first two failures pass and land in
:error-shown; the third fails the guard and the fallback candidate locks
out.
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}
:decode :json
:on-success [:auth.login/flow [:auth.login/success]]
:on-failure [:auth.login/flow [:auth.login/failure]]}]]})}
Require [re-frame.http.managed] wherever :rf.http/managed appears, or the
effect is :rf.error/no-such-fx. The reply envelope and the one-element-short
target shape are in the first machine.
The effect map {:data :fx}¶
| Key | Meaning |
|---|---|
:data |
Merged into the snapshot's current :data (not replaced). Explicit nil sets a key to nil; it does not remove keys. |
:fx |
Ordinary effects vector (:dispatch, :rf.http/managed, …). Machine-only ids: :raise, :rf.machine/spawn, :rf.machine/destroy. |
Both keys are optional; nil / {} means no effects. Returning :db is
:rf.error/machine-action-wrote-db.
: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.
Unresolved :guard / :action / :target names throw at reg-machine
(:rf.error/machine-unresolved-guard,
:rf.error/machine-unresolved-action,
:rf.error/machine-unresolved-target), not on first dispatch.
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.
Strict encapsulation¶
A guard or action gets {:data :event :state :meta} (plus :rf.cofx when it
declares a coeffect) — never app-db. Parallel regions also see :tags /
:all-state; that is a later page.
| 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 / host fact | Declare a coeffect on the named guard or action — do not call (js/Date.now) |
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).
: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))}}
Actions never choose the next state. Only the transition's :target moves
the machine.
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.
That does not hide mistakes. Broken definitions fail at registration: missing
targets, undefined guards or actions, invalid timeout shapes, illegal
:final? combinations. The unhandled event is the one intentionally quiet
case.
Self-transitions and wildcards¶
Self-moves do not re-enter by default. The turnstile's push-while-locked
counts a push without leaving :locked. Three shapes:
| 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.
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. :final? true— the machine terminates and is destroyed. Use for spawned protocols that finish, not for "last screen of a long-lived machine."
Nested finals and parent :on-done live in
Hierarchical states and Actors.
Schemas¶
A machine can validate its private :data in development:
(rf/reg-machine :auth.login/flow
{:initial :idle
:data {:attempts 0 :error nil}
:schemas {:data [:map
[:attempts :int]
[:error [:maybe :string]]]}
:states {…}})
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. Full rules: re-frame.machines API.
Testing¶
The table is a value. (machines/machine-transition definition snapshot trigger)
returns the next snapshot and the effects the action described. The
first machine
has the login cases. Inspecting and testing is the
full surface.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
First reg-machine throws :rf.error/machines-artefact-missing |
[re-frame.machines] not required |
Require it once at boot |
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 |
Action fails :rf.error/machine-action-wrote-db |
Returned :db |
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 |
:rf.error/no-such-fx on :rf.http/managed |
HTTP artefact not loaded | Require [re-frame.http.managed] |
| 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; default bound is 16 |
Raise and internal events¶
:raise in an action's :fx re-enters this machine atomically before
commit. :internal-events is the set of event ids that external
dispatch must not send
(:rf.error/machine-internal-event-external-dispatch). Eventless loops and
raise storms are depth-bounded (default 16);
:rf.error/machine-always-depth-exceeded /
:rf.error/machine-raise-depth-exceeded abort the whole step — not a silent
no-op.
When to reach for a machine¶
Use a machine when named mutually exclusive stages are the load-bearing
concept: legal and illegal triggers, timers, cancellation, retries, or
cleanup; the flow is easier to draw than to describe; tests should assert
(state, trigger) → next state + effects.
The landing page has the when-not table.