Skip to content

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]), which reads nil until the machine is born
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() the start kick in the frame's :initial-events, [[machine-id [:rf.machine/start]]]. XState computes the settled initial snapshot at createActor, so getSnapshot() is never undefined; re-frame2's creation site is the frame, and the kick there births the machine before make-frame returns. Without it, the first event births the machine lazily
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. A finished singleton rests, so [:rf/machine id] reads its final state; a finished spawned actor is gone, so its address reads nil, as a destroyed or never-started one does
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 done singleton ignores an event its definition declares no way out for; a destroyed 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 (a singleton rests there; a spawned actor completes to its parent and is torn down)
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, so Xray loses click-to-source for that machine.

(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 (get-in error [:body :message]) "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.

{:state :submitting
 :data  {:attempts 1 :error nil}}

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 likewise returns the complete next :data, beside effect descriptions:

(fn [{data :data}]
  {:data (update data :attempts inc)
   :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;
  • :choice is 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:

{ type: "SUBMIT", credentials }

re-frame2 writes that object as a trigger vector:

[:auth.login/submit credentials]

You do not send that vector to an actor. You dispatch it inside a normal re-frame2 event whose id is the machine id:

(rf/dispatch [:auth.login/flow [:auth.login/submit credentials]])

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

state.hasTag("busy")

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:

:loading
{:after {5000 :timeout}
 :on    {:loaded :ready}}

XState's delays implementation map is the machine's root :delays map, and a named delay is an :after key that names an entry, as after: { idleWarning: 'warning' } does:

{:delays {:idle-warning (fn [{:keys [snapshot]}] (-> snapshot :data :idle-warning-ms))}
 :states {:active  {:after {:idle-warning :warning}}
          :warning {}}}

A delay function receives {:snapshot …} and reads the machine's :data, where XState's receives { context, event }. Swap a delay in a test with (assoc-in machine [:delays :idle-warning] 50), as XState's .provide({ delays }) does.

A named deadline uses :timeout and :on-timeout:

:waiting
{:timeout    "PT5S"
 :on-timeout {:target :timed-out}}

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:

:done {:final? true
       :output-key :token}

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? rests there instead, so a resting leaf such as :authed or :locked-out is :final? and stays readable.

Schemas

XState types are mostly compile-time. re-frame2 schemas are optional runtime checks in development.

:schemas {:data   [:map [:attempts :int]]
          :output :string}

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.