4. Automatic transitions¶
The first machine already uses one automatic form: :after
on :submitting cancels if the server stalls. This page is the rest of the
family.
Most transitions wait for a trigger from dispatch. Four triggers come from
the machine itself:
- an eventless step whose guard passes;
- a decision node resolving on entry;
- a delay expiring;
- a deadline being missed.
re-frame2 has four authoring forms for this, built on two engines.
| Intent | Form | Engine |
|---|---|---|
| "Whenever this condition holds, move." | :always |
guard-driven microstep loop |
| "Enter a decision node and immediately route." | :type :choice + :choice |
desugars to :always |
| "After N ms in this state, move." | :after |
wall-clock timer |
| "This state or child must finish in time." | :timeout + :on-timeout |
desugars to :after |
Eventless :always¶
:always is checked after a state is entered and after transitions that remain in, or land in, that state.
Those points and no others. Nothing watches the guard in between, so a :data change that arrives outside a macrostep — a spawned child's :on-done fold, say — does not move the machine on its own. The next event does.
Login can skip the form when a session token is already in :data:
:guards
{:has-session?
(fn [{data :data}]
(some? (:token data)))}
:idle
{:always [{:guard :has-session?
:target :authed}]
:on {:auth.login/submit {:target :submitting
:guard :form-valid?
:action :clear-error}}}
Birth lands on :idle. If :data already has a token (hydration, a restore
event), :always moves to :authed in the same macrostep. External
observers see the settled result, not the hop through :idle.
The same form works as a counter that trips a threshold. A targetless
:on updates :data; then :always is checked:
:asking
{:always [{:guard :enough? :target :winner}]
:on {:answer-correct {:action :count-correct}
:answer-wrong :loser}}
Run to completion¶
A machine processes one event to a stable configuration before the next event is observed.
Inside that one macrostep, the runtime:
- takes the event-driven transition;
- applies exit/action/entry effects;
- checks
:always; - drains any
:raised internal events; - repeats until no
:alwaysis enabled and no raised event remains; - commits the final snapshot once.
The loop is bounded. The default depth limit is 16. A runaway cycle raises :rf.error/machine-always-depth-exceeded (eventless) or :rf.error/machine-raise-depth-exceeded (:raise) and aborts the macrostep atomically; the previous snapshot remains visible.
:always rules¶
:always takes a candidate vector:
:resolving
{:always [{:guard :empty? :target :empty}
{:guard :too-many? :target :too-many}
{:target :some}]}
The first candidate whose guard passes wins. Include an unguarded default when the state must always resolve.
An :always transition may be targetless:
This is the safe "loop until done" pattern. The action changes :data; once the guard becomes false, the loop settles.
An :always transition may not target its own declaring state. That shape either loops forever or does nothing useful, so reg-machine throws :rf.error/machine-always-self-loop.
An :always step runs with no event, so its guards and actions receive :event as nil. Anything that reads a trigger payload belongs on the event-driven transition; put the result in :data and let the :always guard read that.
Choice states¶
A choice state is a named decision node. The machine enters it and immediately leaves through the first passing candidate.
The first machine's failure candidate list can be written as a choice instead. :record-error stays on the way in, where it can still read the failure message off the event, so the choice reads the incremented :attempts:
:guards
{:retries-left?
(fn [{data :data}]
(< (:attempts data) 3))}
:submitting
{:on {:auth.login/failure {:target :decide-failure
:action :record-error}}}
:decide-failure
{:type :choice
:choice [{:guard :retries-left?
:target :error-shown}
{:target :locked-out}]}
The tutorial's :under-retry-limit guard is (< (:attempts data) 2) because it runs before :record-error. :retries-left? runs after, so it compares against 3. Both lock out on the third failure.
A smaller decision node looks the same:
It is equivalent in behaviour to an :always decision, but it communicates intent to readers and diagram tools.
Choice rules:
:type :choiceand:choicemust appear together.:choiceis a non-empty vector of transition candidates.- The vector must include an unguarded default.
- A choice state only routes; it does not also declare
:on,:entry,:after,:spawn, and so on. - The topology stays data. A function-valued
:choicefails at registration with:rf.error/machine-bad-choice.
Delayed :after¶
:after maps a delay to a transition. Entering the state arms the timer. Leaving the state cancels it.
The first machine uses an 8-second :after as a server deadline. The same key works for any wall-clock wait:
(rf/reg-machine :boot
{:initial :splash
:states
{:splash {:after {3000 :main}
:on {:skip :main}}
:main {}}})
The transition value uses the same grammar as :on:
:loading
{:after {30000 {:target :timeout
:guard :still-loading?
:action :record-timeout}}
:on {:loaded :ready
:failed :error}}
If the guard is false when the timer fires, the timer is discarded and the snapshot does not move.
Delay forms¶
An :after delay can be:
A positive integer, in milliseconds. Not an ISO-8601 string — those belong to :timeout below.
A subscription vector. The delay re-resolves while the state is active. If the subscription value changes, the timer restarts from now.
A function, evaluated once when the state is entered. It does not re-resolve. Delay functions receive {:snapshot …}, not the usual guard/action context ({:data :event :state :meta}).
Timer staleness¶
You do not cancel :after timers yourself.
Every timer carries the state-entry epoch that armed it. When it fires, the runtime checks whether that epoch is still current. If the state has been exited or re-entered, the timer is stale and ignored.
This avoids the usual setTimeout plus cancel-flag bug. A late timer from a previous visit cannot move the current state.
Several timers can race¶
Both timers count from state entry. If :loaded arrives before either, leaving the state cancels both. If the 5 second timer fires, it takes its transition; if that transition exits the state, the 30 second timer is cancelled.
Do not rely on declaration order to break a same-tick tie. Host scheduling decides which timer event arrives first.
Exponential backoff¶
:reconnecting
{:after {(fn [{:keys [snapshot]}]
(let [{:keys [retries base-ms max-backoff-ms]} (:data snapshot)]
(min (* base-ms (Math/pow 2 retries)) max-backoff-ms)))
{:target :connecting}} ;; cf. examples/patterns/websocket
:on {:give-up :failed}}
Each visit to :reconnecting computes a fresh delay from the current snapshot.
For recurring timers, re-enter the state. There is no separate recurring-timer primitive.
SSR¶
On the server, :after does not run wall-clock timers. The server renders the current state. The client re-arms timers after hydration.
Design SSR-visible states so they are meaningful without depending on a timer firing server-side.
:timeout and :on-timeout¶
Use :timeout when the intent is a deadline.
The pair lowers onto the same timer mechanism as :after.
It also works on a spawn spec:
:authenticating
{:spawn {:machine-id :auth/request
:timeout "PT10S"
:on-timeout {:target :auth-failed}}}
:timeout on a spawn spec is valid. The retired :timeout-ms slot is not — reg-machine throws :rf.error/spawn-timeout-ms-removed.
When the timeout fires, the parent state exits, and the spawned child is destroyed as part of the normal exit cascade.
:timeout requires :on-timeout, and :on-timeout requires :timeout.
Timeout durations¶
A timeout duration is one of:
Positive integer milliseconds.
An ISO-8601 duration string.
Readable shorthands such as "5s" or "10ms" are not accepted, nor are subscription vectors or delay functions. A bad duration fails at registration with :rf.error/machine-bad-timeout-duration. Use integer milliseconds or ISO-8601.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
Registration throws :rf.error/machine-always-self-loop |
:always targets its own declaring state |
Use a targetless :always with an action that flips the guard, or target a different state |
Macrostep fails :rf.error/machine-always-depth-exceeded |
Eventless loop did not settle within 16 steps | Break the cycle; a targetless drain-until-false is the safe loop |
Registration throws :rf.error/machine-bad-choice |
:choice is a function, empty, or otherwise not a candidate vector |
Declarative non-empty vector of candidate maps |
Registration throws :rf.error/machine-choice-no-default |
Every :choice candidate is guarded |
End the vector with an unguarded candidate |
A choice or :always candidate read nil where the payload should be |
An eventless step runs with no event | Read the payload on the event-driven transition and store it in :data |
| A retry limit trips one failure early after the count moved into a choice | The entering action already incremented the count the choice's guard reads | Compare against the post-action number |
| Timer fired but the snapshot did not move | Guard was false at expiry, or the state had already been left | Expected. A late timer is stale; a false guard discards that firing |
Registration throws :rf.error/machine-bad-timeout-duration |
"5s" shorthand, or a non-positive / malformed duration |
Integer milliseconds or ISO-8601 ("PT5S") |
Registration throws :rf.error/spawn-timeout-ms-removed |
:timeout-ms on a spawn spec |
Use :timeout + :on-timeout, or :after on the parent state |