Coming from XState¶
If you know XState, the statechart ideas transfer well:
- states and transitions;
- guards and actions;
- nested states;
- parallel regions;
- tags;
- delayed and eventless transitions;
- final states;
- history;
- run-to-completion.
The biggest change is where the running machine lives.
In XState, you create an actor, start it, and send to it.
In re-frame2, a machine is an event handler. You register it, dispatch to it, and
read its snapshot from the frame. There is no actor object, no send, and no
second event system.
(rf/reg-machine :auth.login/flow login-flow)
(rf/dispatch [:auth.login/flow [:auth.login/submit creds]])
@(rf/subscribe [:rf/machine :auth.login/flow])
Require [re-frame.machines] once at boot. The first reg-machine without it is
:rf.error/machines-artefact-missing.
The comparison uses XState v6 alpha
(as documented in September 2026) — plain guard and action functions, optional
schemas, explicit timeouts, choice states, private events, event-shaped
completion. v5 helper creators such as assign, sendTo, raise, and
enqueueActions map onto the data-first forms below; v6's setup() /
createMachine({ guards, actions }) registries map onto the machine-local
:guards / :actions maps.
Mapping¶
| XState idea | re-frame2 |
|---|---|
createMachine(...) / setup().createMachine() |
(rf/defmachine …) then (rf/reg-machine id …), or an inline reg-machine literal |
context |
:data |
state.value |
snapshot :state |
state.context |
snapshot :data |
actor.getSnapshot() |
@(rf/subscribe [:rf/machine id]) |
actor.getPersistedSnapshot() |
(rf/frame-state-value frame) — the machines, spawned children and spawn registry are its [:rf.db/runtime :rf.runtime/machines] subtree; see Persist and restore |
createActor(machine, { snapshot }) |
(rf/dispatch-sync [:rf/install-frame-state saved] {:frame f}) at boot — no entry replay, children restored, live :after timers re-armed; see Persist and restore |
actor.send(event) |
(rf/dispatch [machine-id [event …]]) |
createActor(machine).start() |
nothing to create: the first event, or (rf/dispatch [machine-id [:rf.machine/start]]), starts a registered machine |
actor.stop() |
:fx [[:rf.machine/destroy machine-id]] — runs the active states' :exit actions before teardown, where stop() runs none, and destroys the children its :spawn / :spawn-all states track, as stop() stops children |
snapshot.status / snapshot.output after completion |
the :rf.machine/done trace carries :output; the snapshot is gone, so [:rf/machine id] reads nil for a finished, destroyed or never-started machine alike |
actor.subscribe({ complete }) |
the :rf.machine/done trace, and :rf.machine/destroyed with its :reason |
| sending to a done or stopped actor (a dead letter) | a singleton is re-born from :initial and handles the event; a destroyed spawned actor answers :rf.error/no-such-handler |
an action throws (the actor's status becomes 'error') |
the macrostep rolls back, :rf.error/machine-action-exception is emitted, and the machine keeps handling events |
states |
:states |
initial |
:initial |
| nested states | compound states with :initial + :states |
root entry / exit / tags / invoke |
root :entry (once at birth) / :exit (once at teardown, including destroy) / :tags; a root invoke is the root :spawn, one child spawned at birth after the root :entry and destroyed at teardown after the root :exit. A root always is refused at registration — route :initial into a state whose :always fires |
type: "parallel" |
:type :parallel + :regions |
type: "history" |
:type :history (:default-target is optional and falls back to the compound's :initial; v6 requires a history target) |
type: "final" |
:final? true (auto-destroys, and a later event restarts it from :initial; omit it on a resting leaf) |
reenter: true |
:reenter? true |
tags |
:tags #{…} |
state.hasTag(tag) |
@(rf/subscribe [:rf.machine/has-tag? id tag]) |
a condition inside the transition function (v5 guard) |
:guard named in :guards |
the transition function's { context } return + enq(...) (v5 actions / assign) |
:action returning {:data … :fx …} |
always |
:always |
after |
:after |
| timeout / onTimeout | :timeout + :on-timeout |
| choice state | :type :choice + a declarative :choice vector |
invoke |
:spawn |
invoke onDone |
:spawn's :on-done transition |
invoke onError |
:spawn's :on-error transition |
| multiple invokes / fan-out | :spawn-all |
enq.raise (v5 raise) |
:fx [[:raise [:tick]]] |
enq.sendTo (v5 sendTo) |
:fx [[:dispatch [other-id [:their/event]]]] — the id you hold is the address |
output |
:output-key on a final state |
schemas.internalEvents (a map; the top-level internalEvents array is deprecated) |
:internal-events #{…} |
TypeScript types / v6 schemas |
:schemas {:data … :output …} |
Machine definition¶
XState commonly separates machine shape from a setup() registry. re-frame2
keeps the table, guards, and actions in one map. Prefer defmachine (or an
inline reg-machine literal). A plain (def m {…}) then reg-machine leaves
source stamps empty (:rf.warning/machine-source-unstamped).
(rf/defmachine login-flow
{:initial :idle
:data {:attempts 0 :error nil}
:guards
{:under-retry-limit (fn [{data :data}]
(< (:attempts data) 2))}
:actions
{:record-error
(fn [{data :data [_ {:keys [error]}] :event}]
{:data (-> data
(update :attempts inc)
(assoc :error (or (:message error) "Login failed.")))})}
:states
{:idle
{:on {:auth.login/submit :submitting}}
:submitting
{:on {:auth.login/failure [{:target :error-shown
:guard :under-retry-limit
:action :record-error}
{:target :locked-out
:action :record-error}]}}
:error-shown {:on {:auth.login/submit :submitting}}
:locked-out {}}})
(rf/reg-machine :auth.login/flow login-flow)
The guard reads the pre-action :attempts, so < 2 is three attempts. The
failure payload sits under :error. Cross-machine reuse is ordinary Clojure
reuse: put a guard or action function in a var and reference it from several
specs.
Context becomes :data¶
The concept is the same: extended state attached to the finite state.
The name differs because "context" already has other meanings in Clojure,
re-frame, and React, and :data makes the action return shape match an event
handler's.
Actions return effects¶
An XState v5 action performs work or uses assign to update context; in v6 an
entry/exit function returns the complete next context and queues effects
through enq (XState data and effects).
A re-frame2 action instead returns a top-level data patch and effect descriptions:
(fn [{data :data}]
{:data {:attempts (inc (:attempts data))}
:fx [[:analytics/track {:event :login-failed}]]})
Returning {:data …} is the assignment. Returning {:fx …} describes effects
for re-frame2's effect machinery to perform. The action itself stays pure, so a
transition is easy to test and replay.
The topology stays data¶
In re-frame2, functions live in guards and actions. The graph stays declarative.
That means:
- targets are data;
- candidate vectors are data;
:choiceis a vector of guarded candidates, not a routing function;- guard composition happens inside named guard functions, not a separate combinator DSL.
A declarative graph can be rendered, diffed, inspected, tested, and edited by tools.
Events and dispatch¶
XState event objects commonly look like:
re-frame2 writes that object as a trigger vector:
You do not send that vector to an actor. You dispatch it inside a
normal re-frame2 event whose id is the machine id:
:auth.login/flow is the handler id — the same id you passed to
reg-machine. That id is a singleton. [:auth.login/submit credentials]
is the trigger the table matches against :on. The first keyword is the
:on key; the rest is payload.
Reading the snapshot¶
@(rf/subscribe [:rf/machine :auth.login/flow])
;; => {:state :submitting
;; :data {:attempts 1 :error nil}
;; :tags #{:auth/busy}}
This is an ordinary subscription, so projections are ordinary subscriptions too:
(rf/reg-sub :auth.login/error {:inputs [[:rf/machine :auth.login/flow]]}
(fn [[snap] _]
(get-in snap [:data :error])))
Because the snapshot is data in the frame, time-travel and SSR do not need a separate actor serialization story.
Persist and restore¶
XState persists with actor.getPersistedSnapshot() and restores with
createActor(machine, { snapshot }). In re-frame2 the machines are part of the
frame-state: save it with frame-state-value and install it at boot with the
:rf/install-frame-state event. As with XState's snapshot option, entry
actions are not re-run and spawned children come back with their state.
Migrating old snapshots forward is the app's job, as it is in XState.
Persist and restore has the code and what the app
owns.
Tags¶
XState:
re-frame2:
:submitting {:tags #{:auth/busy}}
@(rf/subscribe [:rf.machine/has-tag? :auth.login/flow :auth/busy])
Tags are sets of keywords. Use namespaced tags for intent: :auth/busy,
:mode/read-only, :ws/connected.
Delays and timeouts¶
after maps to :after:
A named deadline uses :timeout and :on-timeout:
Durations are integer milliseconds or ISO-8601 strings. Shorthand strings such as
"5s" are :rf.error/machine-bad-timeout-duration at registration.
invoke becomes :spawn¶
State-bound child work is :spawn:
:authenticating
{:spawn {:machine-id :auth/request
:data {:url "/api/login"}
:on-done {:target :authenticated
:action (fn [{data :data ev :event}]
{:data (assoc data :token (:result (nth ev 2)))})}
:on-error {:target :idle}}
:on {:cancel :idle}}
The child is destroyed automatically when the parent leaves :authenticating
or is destroyed.
:on-done and :on-error are transitions, like XState's onDone and
onError: each resolves at the spawning state's level, so :authenticated and
:idle are its siblings. The child's result arrives on the transition's event
as (:result (nth ev 2)). A fn :on-done —
(fn [{:keys [data result]}] (assoc data :token result)) — instead folds the
result into the parent's :data without moving the parent.
spawn is the same lifecycle idea as invoke, renamed because a child actor
exists while the state is active. The parent registered with reg-machine is
the singleton. Each :spawn creates a spawned instance of a type.
Completion is event-shaped¶
A child reports a result by reaching a root-level final state:
The parent receives that value as (:result (nth ev 2)) in an :on-done
transition, or as result in an :on-done fold.
There is no long-lived snapshot.output to read later. Completion happens,
reports, and the child is destroyed. A singleton that reaches :final? is
destroyed too — omit :final? on a resting leaf such as :authed or
:locked-out.
Schemas¶
XState types are mostly compile-time. re-frame2 schemas are optional runtime checks in development.
A :data schema violation rolls back the transition before the bad snapshot
commits. Production builds can elide the checks.
What stays quiet and what fails loud¶
| Situation | What happens |
|---|---|
| An event the current state does not handle | Quiet no-op, matching modern XState. Trace: :rf.machine.event/unhandled-no-op. |
Broken definition — unresolved target, missing guard or action, invalid :choice, "5s" duration, … |
Fail at registration. |
An XState spelling — invoke, cond, entry: [a, b] |
Fail at registration (:rf.error/machine-unknown-node-key, :rf.error/machine-bad-action-form); the message says what to write instead. |