History states¶
Some compound states have a memory. A media player you stop and restart should resume mid-track, not jump back to the first second. A wizard you step away from and return to should land on the step you left, not step one. A tabbed settings panel should remember which tab — and how far down each tab was scrolled — across a close/reopen. That "resume where I last was" behaviour is what a history state gives you, declaratively, for any compound state.
Without it you reach for the obvious workaround — stash the last substate in :data on the way out, read it back on the way in, and write the wiring to restore it. History states make that pattern a single node in the transition table — a first-class :type :history node — and (because the record/restore lives inside the snapshot) they ride undo, time-travel, persistence, and SSR for free.
History only applies to a compound state — a state with its own
:statesand:initial. If "which tab" or "which step" is a single flat value, just keep it in:datalike any other state; that is ordinary modelling, not a feature you need to name. History earns its keep when a non-trivial nested configuration is worth remembering across a leave/return.
A history state is a transition target, not a state you occupy¶
The mental model to get right first: a history state is a pseudo-state. You declare it under a compound's :states map, right alongside the compound's real substates, but the machine never sits in it. Its only job is to be the target of a transition — and when a transition resolves to it, it stands in for "the substate this compound was in when control last left it." The runtime resolves it to a real leaf, the entry cascade enters that leaf, and the snapshot's :state records the resolved leaf — never the pseudo-state.
So you write [:player :hist] as a transition target the same way you'd write any state path, and it resolves to a recorded (or default) configuration instead of to a fixed state.
A worked example — a media player that resumes¶
Here is a player with a :tray (no disc) and a :player compound (disc inserted). The :player compound owns a :type :history pseudo-state. :eject leaves :player for :tray; :insert comes back in through history, restoring whatever the player was doing when it was ejected:
(rf/reg-machine :media-player
{:initial :tray
:states
{;; No disc. :insert re-enters :player THROUGH the history pseudo-state.
:tray
{:on {:insert [:player :hist]}}
;; Disc inserted — the compound whose configuration we remember.
;; :eject leaves :player entirely, which is what makes it record.
:player
{:initial :stopped
:on {:eject :tray}
:states
{:hist {:type :history
:deep? true ;; omit ⇒ SHALLOW (see below)
:default-target :stopped} ;; where the FIRST :insert lands
:stopped {:on {:play [:player :playing]}}
:playing {:initial :at-start
:on {:stop [:player :stopped]
:pause [:player :paused]}
:states {:at-start {:on {:seek :mid-track}}
:mid-track {}}}
:paused {:on {:resume [:player :playing]}}}}}})
A machine is an event handler — there is no actor to send to — so you drive it with ordinary dispatched events and read it with an ordinary subscription:
(rf/dispatch [:media-player [:insert]]) ;; :tray → nothing recorded yet → :default-target → [:player :stopped]
(rf/dispatch [:media-player [:play]]) ;; → [:player :playing :at-start]
(rf/dispatch [:media-player [:seek]]) ;; → [:player :playing :mid-track]
(rf/dispatch [:media-player [:eject]]) ;; → :tray AND records :player's last config
(rf/dispatch [:media-player [:insert]]) ;; → restores [:player :playing :mid-track] — resumes mid-track
@(rf/subscribe [:rf/machine :media-player])
;; => {:state [:player :playing :mid-track]
;; :data {}
;; :rf/history {[:player] [:player :playing :mid-track]}}
;; (no :tags key here — this machine declares no tags, and an empty tag
;; union is omitted from the snapshot rather than stored as #{})
You wrote no capture code, no "remember the last tab" action. The [:player :hist] target does the work.
The three keys — and nothing else¶
A :type :history pseudo-state carries exactly three keys, all owned by the history grammar:
| Key | Value | Meaning |
|---|---|---|
:type |
:history |
The discriminator that marks this node as a history pseudo-state rather than a real substate. Required. |
:deep? |
boolean | true ⇒ deep history (restore the full recorded leaf path). false or absent ⇒ shallow (restore the recorded direct child, then cascade through that child's :initial chain). Default is shallow — a missing :deep? reads as false. |
:default-target |
child keyword or absolute vector | Where to land the first time the compound is entered, before anything has been recorded. A direct-child keyword (:stopped) or an absolute path. Absent ⇒ falls back to the owning compound's :initial. |
It MUST NOT declare :on / :entry / :exit / :always / :after / :spawn / :spawn-all / :states / :initial / :tags / :final? — the machine never occupies it, so transition and lifecycle keys are meaningless on it. Any such key is a registration error (caught when you reg-machine, not on the unlucky dispatch that first reaches it).
Shallow vs deep — how much of the path is restored¶
This is the one knob that trips people. Take the same player and eject from deep inside — [:player :playing :mid-track]:
- Deep (
:deep? true) records the full leaf path beneath the compound. On:insertit re-enters every level back down to the exact leaf —[:player :playing :mid-track]. The player resumes the exact track and its mid-track position. - Shallow (omit
:deep?) records only the compound's direct child — here:playing. On:insertit restores that child and then cascades through its:initialchain. So it resumes[:player :playing :at-start]— the right top-level branch (we were playing), but its initial inner position, not:mid-track.
So the recorded slot differs by kind:
;; after :eject, DEEP:
:rf/history {[:player] [:player :playing :mid-track]} ;; full absolute leaf path
;; after :eject, SHALLOW:
:rf/history {[:player] :playing} ;; just the direct child keyword
Reach for deep when the precise nested position matters (resume the exact track and scrub point); shallow when only the top-level branch matters (which tab, not the tab's inner scroll).
Recording happens on exit — the compound must actually be left¶
Recording is automatic and happens on the way out: whenever the exit cascade leaves a compound that owns a history pseudo-state, the runtime writes that compound's last-active configuration into the snapshot. You never write capture code.
The owning compound must be genuinely exited to record. This is the W3C SCXML exit-set rule: a <history> value is written only for states in the exit set. A transition that merely moves between two children of the compound keeps the compound as the least-common ancestor — the compound survives, was never left, and so records nothing (there is no "last config" to remember; it is still in one).
That is exactly why :eject targets :tray (a sibling of :player, outside it) rather than some inner state: only leaving :player puts it in the exit set. A common first mistake is to put the history node on a compound that nothing ever exits — then it silently never records and "restore" always falls to the default. If your history isn't sticking, check that something leaves the owning compound.
Symmetrically, the restore transition — which enters the compound through [:player :hist] — records nothing for that compound; re-entry leaves the recorded slot untouched.
Restoring — recorded, else default-target, else :initial¶
When a transition resolves to the pseudo-state, the runtime resolves the target leaf in this order:
- A valid recording exists for the compound → restore it (deep = full leaf path; shallow = recorded child then its
:initialcascade). - No recording (the compound was never entered/exited) → resolve the pseudo-state's
:default-target; if:default-targetis absent, fall back to the compound's own:initialand cascade from there exactly as a first-ever entry would. - A recording exists but is no longer a valid path in the current definition — a dangling recorded path after a hot reload removed that substate → the runtime discards it and falls back to
:default-target/:initialper (2). It never enters a dead path; this is benign and raises no error.
Once the pseudo-state resolves to a concrete leaf, nothing else about history is special: the standard LCA computation, exit/entry cascade ordering, :always settling, and :after timer scheduling all apply to the resolved leaf exactly as if you'd written that leaf as a literal :target. History is a target-resolution step, not a new cascade mechanism.
The :rf/history snapshot slot¶
The recording lives in a reserved, framework-owned slot at the root of the snapshot — a sibling of the other :rf/* runtime slots:
{:state [:player :stopped]
:data {…}
:rf/history {[:player] [:player :playing :mid-track]}} ;; map: compound decl-path → recorded config
:rf/history is a map, keyed by the compound's declaration path (a vector of keywords) → that compound's recorded configuration (a full leaf path for deep, a direct-child keyword for shallow). It is a map because a machine can own several history-bearing compounds, each recorded independently. The slot is:
- read-only for you — the runtime owns it and writes it during the exit cascade; app code MUST NOT write under it;
- allocated lazily — absent until a history-bearing compound is first exited; a machine with no history nodes never carries the key;
- EDN-clean — vectors and keywords only, so it round-trips through
pr-str/read-stringlike the rest of the snapshot.
Because the recording is part of the snapshot value — not a side-table — recorded history rides every path the snapshot rides, with no extra machinery: undo and time-travel (rewind to an earlier epoch and its :rf/history rewinds too, so "rewind, then re-enter the compound" replays deterministically), persistence, and SSR hydration. The snapshot lives in the frame's runtime-db, the framework's half of state — so, like everything else there, it is just a value that replay can reach.
Per-region history under :type :parallel¶
Under a parallel machine each region runs an independent state-tree, so history is per-region: a history node declared inside a region's compound records and restores that region's configuration on the region's own exit cascade, independently of its siblings. The :rf/history keys are region-qualified — the region name is the head segment of the declaration-path key — so two regions that each declare a history-bearing compound at structurally-identical paths never collide:
;; Two regions, each with a history-bearing :on compound at the same shape.
;; The region name heads the KEY (so recordings stay separate); the recorded
;; VALUE is that region's own within-region path:
{:rf/history {[:left :group :on] [:group :on :bright]
[:right :group :on] [:group :on :dim]}}
Restoring history in one region leaves the others untouched — the same per-region scoping that :spawn, :after, and :always follow under parallel.
Why history is first-class, not a snapshot you stash yourself¶
You could hand-roll the equivalent: an :exit action that copies the current sub-path into :data, an entry that reads it back, plus the bookkeeping to know which compound (and, under parallel, which region). re-frame2 ships the declarative node instead, for four reasons:
- Declarative beats hand-rolled.
{:type :history :deep? true}is one node an AI agent (or a teammate) reads and writes confidently; the stash-and-restore version is bespoke per machine. - Composition you'd otherwise re-derive. Per-region history under parallel, deep nesting, and shallow-vs-deep depth all fall out of the grammar plus the existing cascade machinery — the hand-rolled form has to re-implement each, correctly, every time.
- Tooling legibility. A
:type :historynode is visible to the machine inspector, the diagram exporter, and the SCXML corpus; hand-rolled:datashuffling is invisible to all of them. - Parity. SCXML (
<history>) ships first-class history; matching that shape keeps the conformance corpus aligned.
Revertibility is what makes history cheap (the recording rides the snapshot), but it's the foundation history is built on, not a reason to skip the feature.
Gotchas¶
- The owning compound must actually be exited to record. A within-compound sibling move (the compound survives as the LCA) records nothing — give the compound an off-state to leave for (the
:trayin the example). - The pseudo-state is never in
:state. The machine's:stateis never[… :hist]; a transition to:histresolves to a real leaf. Don'tcaseon the pseudo-state keyword in views. - History only applies to a compound. A
:type :historynode at the machine root (or under a:type :parallelroot with no enclosing compound region) is a registration error — there is no configuration for it to record. - At most one history node per compound. Deep-vs-shallow is the single node's
:deep?, not a reason for two nodes. Two history children under one compound is a registration error. :default-target(keyword form) names a direct child of the owning compound, then cascades any:initialchain — not an arbitrary sibling elsewhere. Use the vector form for an absolute path. An unresolvable:default-targetis a registration error.