Skip to content

The model

This page is the flat machine model — everything you need for a single-level state machine. Nested states, parallel regions, history, and actors grow the same grammar; each has its own page.

To build a login machine step by step (guard → action → HTTP → view → test), use the tutorial. Here the goal is understanding the pieces and their contracts.

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

You already write state machines: a :status keyword in app-db plus informal rules in handlers about what may follow what. A machine writes those rules down as one value — a transition table — so you can read, draw, test, and change the flow in one place.

(rf/defmachine turnstile
  {:initial :locked
   :data    {:coins 0}
   :actions {:take-coin (fn [{data :data}] {:data (update data :coins inc)})}
   :states
   {:locked   {:on {:coin {:target :unlocked :action :take-coin}
                    :push {:target :locked}}}     ;; blocked: stays locked
    :unlocked {:on {:push {:target :locked}
                    :coin {:target :unlocked :action :take-coin}}}}})

Two words do most of the work (glossary):

  • A guard — yes/no gate on a transition.
  • An action — side work that returns effects, never performs them.

Register and drive

A machine is an event handler. reg-machine is sugar over reg-event whose body interprets the table: read the live snapshot, take a transition, write the new snapshot, return action effects.

(:require [re-frame.core :as rf]
          [re-frame.machines])   ;; opt-in: forget this → :rf.error/machines-artefact-missing

(rf/reg-machine :turnstile turnstile)

Two blessed registration shapes:

Shape Use when
defmachine + reg-machine Named, reusable specs (Xray click-to-source on guards/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 (dev warns :rf.warning/machine-source-unstamped).

Dispatch addresses the machine id; the inner vector is the machine event:

(rf/dispatch [:turnstile [:coin]])

Subscribe with the framework sub:

@(rf/subscribe [:rf/machine :turnstile])
;; => {:state :unlocked :data {:coins 1}}   ; nil before the first event

The snapshot lives in runtime-db (framework half of the frame), so undo, time-travel, and SSR hydration work without extra wiring.

Async composes

Point managed HTTP replies at the machine: :on-success [:auth.login/flow [:auth.login/success]] (outer id, inner event). The reply is appended into the inner event — no adapter. Full walk-through in the tutorial.

See one run

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.

Guards and actions

Every callback receives one context map:

{:data  {:attempts 1 :error nil}   ;; machine-private memory
 :event [:auth.login/failure ]    ;; inbound event vector
 :state :submitting
 :meta  {}}

There is no :db. A machine cannot see app-db. That is strict encapsulation — the rule that keeps the whole machine inside one snapshot for time-travel.

Guards

Return truthy/falsey. No combinator DSL — compound logic is ordinary Clojure:

:guards
{:under-retry-limit (fn [{data :data}] (< (:attempts data) 2))   ;; three attempts total
 :form-valid?       (fn [{[_ creds] :event}]
                      (and (seq (:email creds)) (seq (:password creds))))}

A guard sees the snapshot as it stands before the transition's action runs, so :under-retry-limit reads the count from the two failures already recorded and the boundary sits one below the total you want — 2 for the tutorial's three-attempt lockout. XState guards evaluate in the same place, ahead of the assign, so the off-by-one reads the same there.

Reference by id (:guard :form-valid?) or inline a one-liner. Prefer named ids — trace rows and Xray can address them.

A list of transition candidates is tried in order; first guard that passes wins.

Actions

Return descriptions, same idea as reg-event:

:actions
{:clear-error  (fn [_] {:data {:error nil}})
 :issue-request
 (fn [{[_ creds] :event}]
   {:fx [[:rf.http/managed { :on-success [:auth.login/flow [:auth.login/success]]}]]})}

Also: :entry / :exit on a state (run when the state is entered / left).

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 optional; nil / {} means no effects. Returning :db is an error (:rf.error/machine-action-wrote-db) — machines must not scribble on app-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 time (:rf.error/machine-unresolved-guard, etc.), not on first dispatch.

Strict encapsulation

A guard or action gets {:data :event :state :meta} (plus :rf.cofx when it declares a coeffect, below) — never app-db. That is what keeps a machine's whole state inside one snapshot for time-travel and SSR.

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 guard or action — do not call (js/Date.now)

A declared coeffect arrives under :rf.cofx on the callback map — the causal token the router recorded, so the decision replays deterministically. Read it there, (:rf/time-ms (:rf.cofx ctx)); it is not a top-level rf/time-ms key.

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

Returning :db from an action is a hard error (:rf.error/machine-action-wrote-db) — the key is dropped and the failure is loud. Actions also never choose the next state; only the transition's :target moves the machine.

The snapshot

{:state :submitting
 :data  {:attempts 1 :error nil}
 :tags  #{:auth/busy}}   ;; optional — union of active states' tags
Slot Role
:state Discrete state — keyword (flat), path vector (hierarchy), or region map (parallel)
:data Machine-private memory
:tags Runtime-projected set of active tags (omit when empty)

[:rf/machine id] is nil until the first event; views should fall back to :initial / default :data if they render earlier.

Optional :schemas {:data …} (Malli) validates :data at commit in dev and rolls back a bad transition.

Full rules: schemas section of the re-frame.machines API.

Self-transitions and wildcards

Self-moves don't re-enter by default — the turnstile's push-while-locked counts a push without leaving :locked. (XState calls that non-reentering shape "internal"; re-frame2's runtime internal? flag is narrower — it's the targetless no-op alone, never a targeted self/ancestor move.) Three shapes:

Shape Effect
No :target (targetless) The internal? no-op: action only — no exit/entry; timers and spawns undisturbed; descendants preserved
:target same state, no :reenter? Non-reentering: the target survives, but compounds re-resolve descendants to :initial (a leaf self-target has none, so it's action-only)
: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}}   ;; every 30s: re-enter → fetch again
 :on    {:got-data {:action :merge}                  ;; internal: merge without resetting the clock
         :stop     :idle}}

Targeted self ≠ re-enter

A self-target without :reenter? true does not re-run :entry. If you meant to re-arm a timer or re-spawn a child, say so explicitly.

Wildcards on :on keys, most-specific first: exact id → :ns/*:*.

:tracking
{:on {:mouse/down {:action :begin-drag}   ;; exact wins for :mouse/down
      :mouse/*    {:action :note-move}     ;; any other :mouse/…
      :*          {:action :log-unknown}}} ;; anything else

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

Final states

  • Ordinary leaf with no outgoing transitions — machine persists (login's :authed). Do not set :final?.
  • :final? true — 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.

Tags and automatic moves (pointers)

  • Tags — label intent on states (:tags #{:auth/busy}); views ask [:rf.machine/has-tag? id :auth/busy] instead of enumerating state names. → Tags
  • :after / :always / choice / timeout — moves the table takes without a user event. → Automatic transitions

When the table grows

Same model, more keys — each page assumes this one:

Need Page
Nested sub-flows Hierarchical states
Independent axes at once Parallel regions
Resume mid-compound History
Per-request / worker children Actors
Prove the table / watch live Inspecting and testing

:raise in an action's :fx re-enters this machine atomically before commit. :internal-events marks event ids that external dispatch must not send.

Eventless loops and raise storms are depth-bounded (default 16); tripping aborts the macrostep with a loud error — not a silent no-op.

When to reach for a machine

Yes: named mutually exclusive stages; conditional transitions scattered as whens; the flow is worth drawing on a whiteboard.

No: plain data; two-state flags; server cache lifecycles (resources); mere operation sequences (chained events).

Named states are the concept — not named operations.