Skip to content

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.

:on {:auth.login/submit :submitting}

A bare keyword is sugar for {:target :submitting}, and a vector of keywords is the same sugar for a path target.

:on {:auth.login/submit {:target :submitting
                         :guard  :form-valid?
                         :action :clear-error}}

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:

{:data  {:attempts 1 :error nil}
 :event [:auth.login/failure …]
 :state :submitting
 :meta  {…}}

: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:

  1. source state's :exit
  2. transition's :action
  3. 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? true leaf — 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.