Skip to content

History

The session machine forgets which signed-in screen you were on. Logout lands on :unauthenticated. The next success always opens :dashboard, even if you left from :settings.

A history state lets a compound remember where it was when you left it.

History is only for compound states. If the remembered thing is a flat value, store it in :data.

Example: last signed-in screen

(rf/reg-machine :auth.login/flow
  {:initial :unauthenticated

   :states
   {:unauthenticated
    {:initial :idle
     :states
     {:idle
      {:on {:auth.login/submit :submitting}}
      :submitting
      {:on {:auth.login/success [:authenticated :hist]
            :auth.login/failure :error-shown}}
      :error-shown
      {:on {:auth.login/dismiss :idle}}}}

    :authenticated
    {:initial :dashboard
     :on      {:auth/logout [:unauthenticated]}

     :states
     {:hist
      {:type :history
       :deep? true
       :default-target :dashboard}

      :dashboard
      {:on {:open-settings :settings}}

      :settings
      {:on {:close :dashboard}}}}}})

Drive it with dispatch-sync so each line has settled before the next:

(rf/dispatch-sync [:auth.login/flow [:auth.login/submit]])
(rf/dispatch-sync [:auth.login/flow [:auth.login/success]])
;; :state => [:authenticated :dashboard]
(rf/dispatch-sync [:auth.login/flow [:open-settings]])
;; :state => [:authenticated :settings]
(rf/dispatch-sync [:auth.login/flow [:auth/logout]])
;; :state => [:unauthenticated :idle], recording :authenticated
(rf/dispatch-sync [:auth.login/flow [:auth.login/submit]])
(rf/dispatch-sync [:auth.login/flow [:auth.login/success]])
;; :state => restores [:authenticated :settings]

@(rf/subscribe [:rf/machine :auth.login/flow])
;; => {:state      [:authenticated :settings]
;;     :data       {}
;;     :rf/history {[:authenticated] [:authenticated :settings]}}

The target [:authenticated :hist] means "enter :authenticated through its history pseudo-state." First login has no recording, so :default-target opens :dashboard.

A history state is a target, not a place you sit

A history state is a pseudo-state declared under a compound's :states.

The machine never occupies it. A transition targets it, and the runtime resolves that target to a real child. The snapshot's :state records the resolved leaf — never the pseudo-state.

Shallow vs deep

Suppose you leave :authenticated from :settings.

Deep history records the full leaf path:

:rf/history {[:authenticated] [:authenticated :settings]}

Restoring returns to [:authenticated :settings].

Shallow history records only the direct child of the owning compound:

:rf/history {[:authenticated] :settings}

If :settings were itself a compound, restoring would enter :settings and then follow its :initial. For a leaf, deep and shallow land in the same place.

Use deep when the precise nested position matters. Use shallow when only the top-level branch matters.

Recording happens on exit

History records when the owning compound is actually exited.

In the example, :auth/logout leaves :authenticated for :unauthenticated, so the runtime records :authenticated's last configuration.

A transition between :dashboard and :settings does not record history, because :authenticated was never exited. It remained the least common compound ancestor.

If history is not sticking, check that the transition leaves the compound that owns the history node.

Troubleshooting

Symptom Cause Fix
History never restores; always hits default Owning compound never left (sibling moves keep it as LCA) Give the compound an off-state outside it (:unauthenticated next to :authenticated)
View cases on :hist Pseudo-state is never in :state Transition to :hist resolves to a real leaf — case on that
Registration error at root / bare parallel History needs an enclosing compound Nest under a compound with :states + :initial. Error: :rf.error/machine-history-misplaced
Two history children under one compound At most one history node per compound Use one node; deep vs shallow is :deep?. Error: :rf.error/machine-history-duplicate
Unresolvable :default-target Keyword form must name a direct child Use a direct-child keyword, or a vector for an absolute path. Error: :rf.error/machine-history-bad-default-target
Extra keys on the history node The pseudo-state is never occupied Only :type, :deep?, :default-target. Error: :rf.error/machine-history-extra-keys
## Advanced

The keys

Key Meaning
:type :history Marks the node as a history pseudo-state. Required.
:deep? true Restore the full nested path. Absent or false means shallow.
:default-target Where to go before anything has been recorded. Absent ⇒ the owning compound's :initial.

A history pseudo-state cannot declare :on, :entry, :exit, :always, :after, :spawn, :spawn-all, :states, :initial, :tags, or :final?. It is not a real state. Any extra key is :rf.error/machine-history-extra-keys at reg-machine time.

A keyword :default-target names a direct child of the owning compound. Use a vector for an absolute path.

Restore order

When a transition targets a history pseudo-state, the runtime resolves it in this order:

  1. use a valid recording, if one exists
  2. otherwise use :default-target, if present
  3. otherwise use the owning compound's :initial

If a hot reload removes the recorded target, the runtime discards the stale recording and falls back to the default. That is not an error.

Once resolved, the normal exit/entry cascade runs. History is target resolution, not a separate transition mechanism.

The :rf/history snapshot slot

History recordings live in a runtime-owned snapshot slot:

{:state      [:authenticated :dashboard]
 :data       {...}
 :rf/history {[:authenticated] [:authenticated :settings]}}

The key is the compound's declaration path. The value is either a full path for deep history or a direct child keyword for shallow history.

You do not write this slot. It is part of the snapshot, so it participates in undo, time-travel, persistence, and SSR hydration.

Parallel regions

Inside a parallel machine, history is scoped to the region that owns it. The :rf/history key is region-qualified so recordings do not collide.

{:rf/history {[:left  :group :on] [:group :on :bright]
              [:right :group :on] [:group :on :dim]}}

The region name heads the key; the value is the within-region path. Restoring history in one region leaves the others alone.