Skip to content

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:

  1. takes the event-driven transition;
  2. applies exit/action/entry effects;
  3. checks :always;
  4. drains any :raised internal events;
  5. repeats until no :always is enabled and no raised event remains;
  6. 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:

:draining
{:always [{:guard :has-more?
           :action :drain-one}]}

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:

:checking
{:type   :choice
 :choice [{:guard :valid? :target :accepted}
          {:target :rejected}]}

It is equivalent in behaviour to an :always decision, but it communicates intent to readers and diagram tools.

Choice rules:

  • :type :choice and :choice must appear together.
  • :choice is 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 :choice fails 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:

30000

A positive integer, in milliseconds. Not an ISO-8601 string — those belong to :timeout below.

[:settings/login-timeout-ms]

A subscription vector. The delay re-resolves while the state is active. If the subscription value changes, the timer restarts from now.

(fn [{:keys [snapshot]}]
  (* 1000 (-> snapshot :data :retry-count)))

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

:loading
{:after {5000  :slow-warning
         30000 :timeout}
 :on    {:loaded :ready}}

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.

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

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:

5000

Positive integer milliseconds.

"PT5S"
"PT1H30M"
"PT0.5S"

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