Skip to content

State machines

A single-page app is full of finite state machines. Most of them are never written down.

The session is anonymous, submitting, authed, or locked out. An HTTP request is idle, in flight, succeeded, or failed. A dropdown is closed, open, or disabled. Same shape at every scale: a handful of named stages, and a smaller set of events that may leave each one.

What you usually write instead is a pile of booleans and a cond in every handler — :loading?, :error?, :open?, :disabled?. The machine is still there. It is just poorly specified. Each handler re-decides what is legal. A new stage means another if. Two flags combine into a state nobody named (:loading? true and :error? true). An event that should be impossible gets through.

A machine is that process written as one table: the stages, and which triggers may leave them.

{:initial :closed
 :states  {:closed   {:on {:open :open}}
           :open     {:on {:close :closed
                           :pick  :closed}}
           :disabled {}}}

From :closed, only :open moves you. From :disabled, nothing does. You cannot open a disabled dropdown by forgetting a branch — the branch is not there.

The same table shape covers a login flow or a request. Only the names change.

Machines plug into events, app-db, subscriptions, and effects. They do not replace them.

Why nest states

A flat list is enough for one process. Some stages are really a cluster. :connecting, :authenticating, and :connected all share one live socket. Every authenticated screen should honour :logout the same way. Checkout is a sub-flow with its own start and end, inside a larger shopping flow.

A hierarchical machine lets a state contain child states. The parent holds what the children share. The children hold what differs. Common transitions live once, on the parent; a child can override or block them.

:authenticated
{:initial :dashboard
 :on      {:logout :unauthenticated}   ;; every child inherits this
 :states  {:dashboard {}
           :settings  {}
           :cart      {:initial :browsing
                       :states  {:browsing {}
                                 :paying   {}}}}}

Entering :authenticated lands on :dashboard. :logout works from any child. Moving from :browsing to :paying does not leave :authenticated. Hierarchical states is the grammar.

Native to re-frame2

re-frame2 does not add a second runtime. Other chart libraries give you an actor: you start it and send it messages. re-frame2 already has that job — events go on one queue via dispatch. So a machine is not a new object. It is an event handler. The id you register is the event id you dispatch to.

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

(rf/defmachine login-flow
  {:initial :idle
   :states  {:idle        {:on {:auth.login/submit :submitting}}
             :submitting  {:on {:auth.login/success :authed
                                :auth.login/failure :error-shown}}
             :error-shown {:on {:auth.login/dismiss :idle}}
             :authed      {}}})

(rf/reg-machine :auth.login/flow login-flow)

(rf/dispatch [:auth.login/flow [:auth.login/submit]])
@(rf/subscribe [:rf/machine :auth.login/flow])
;; => {:state :submitting :data {}}

Look at the dispatch line. You dispatch. You do not send.

The outer vector is a re-frame2 event. :auth.login/flow is a normal event id — the same slot as :todo/add. You registered that id with reg-machine, so the handler that runs is the table.

The second element is a trigger. In a statechart, a trigger is the thing that can fire a transition. Here it is another vector: [:auth.login/submit]. The table matches the first keyword against the current state's :on map. Anything after that keyword is payload.

Not every trigger comes from dispatch. A timer expiry is a trigger, and so is an eventless :always step — Automatic transitions.

reg-machine is sugar over reg-event: same registry, same dispatch. Read the live value with an ordinary subscribe. That value — the snapshot — lives in runtime-db, the framework half of the frame, so undo, Xray, SSR, and tests see it the way they see any other event's result.

:auth.login/flow is a singleton: one registered id, one live instance per frame. The snapshot sits in that frame's runtime-db and is nil until the first event; a second frame running the same app runs its own login machine. A spawned actor is a second live instance of a type, created at run time with an allocated id. Login is a singleton. An in-flight request protocol is often spawned. The spec heading says "dynamic actors" for the second kind; that is an adjective, not a third kind. This guide says singleton and spawned. Actors is the full treatment.

Already using XState? Coming from XState is the translation.

When not to use a machine

A two-state flag is already a tiny machine. Leave it as a boolean. Write a table when the stages multiply, or when illegal combinations start appearing.

Situation Prefer
A counter, list, or form field app-db + events
Two stages (:loading? boolean) a keyword or flag
Server fetch / cache / invalidate resources
A fixed sequence of operations chained events / effects

Reach for a machine when named stages and legal transitions are the thing you are modelling — not when the thing is a value or a network cache. Where should this value live? has the full decision table.