Run to completion¶
The runtime processes dispatched events until the queue is empty, and only then
renders, once. Effects introduces the idea with a
live demo. This page covers the operational details: what happens when a dispatch cycle runs
away, how dispatch-sync differs from dispatch, and what destroy-frame! does to
a running drain.
When the drain won't stop¶
If a handler dispatches an event whose handler dispatches the first one again, the
drain would never finish. Each frame has a :drain-depth, the maximum number of
events one family may run (default 1000). A family is one event dispatched from
outside, such as a click, a timer or an HTTP reply, plus every event it dispatches,
transitively. The count restarts with each event from outside, so a long queue of user
input never reaches it. When a family reaches the limit with events still queued, the
runtime halts that family and emits an always-on error record:
{:rf.error/id :rf.error/drain-depth-exceeded
:frame :rf/default
:depth 1000 ; events this family already settled
:queue-size 7 ; the family's queued events, dropped unrun
:last-event-id :todo/sync ; id of the last event that ran
:tail-event-ids [:todo/sync :todo/save …] ; recent ids; the repeating run names the cycle
:dropped-event-ids [:todo/save …]
:rollback? false
…}
The record carries event ids only, never event arguments. In dev builds the trace
stream also gets a :rf.error/drain-depth-exceeded trace with the full last event and
a readable :reason.
The commit is per event, not per family: every event the family
already settled keeps its app-db write and its epoch, and nothing is rolled back. The
runtime discards only the family's remaining queued events, so the frame stays at the
last settled state, and then the drain carries on with the next event from outside
under a fresh budget: a runaway never swallows user input. In Xray you'll see the
family's settled rows followed by a single :halted-depth marker.
The bound is per-frame and tunable
Set :drain-depth in the frame config. The :story frame preset uses 16, so
a runaway demo fails fast; the :test preset sets the default 1000 explicitly.
Raise it only when one event legitimately fans out wide — a family that needs
more than a thousand synchronous events is usually a cycle.
Destroy ends the drain¶
destroy-frame! discards the frame's queued events immediately. It does not
interrupt code already running: a handler on the stack may return, and interceptor
:after functions already entered still run. But nothing that code produced takes
effect — no commit, no flows, no effects, no child dispatches, no render. The frame's
:on-destroy event, if it has one, then runs (Frames).
dispatch-sync¶
Inside a handler you never call dispatch; you return :fx [[:dispatch …]].
Outside a handler you call dispatch directly, as every :on-click does, and it
returns at once. When the caller needs the drain settled before its next line runs
(a test, the REPL), use dispatch-sync:
(rf/dispatch-sync [:todo/add "Buy milk"] {:frame :rf/default}) ;; at the REPL, name the frame
@(rf/subscribe [:todo/all] {:frame :rf/default}) ;; already includes "Buy milk"
dispatch-sync runs the same drain as dispatch, but returns only after it
settles. It does not rethrow a handler's exception: it returns normally, and the
failure becomes an error record (:rf.error/handler-exception, nothing committed).
A test asserts on the resulting state or on that record.
Called from inside a handler, it drops the event, because a drain is already
running; the handler carries on, and a dev build reports
:rf.error/dispatch-sync-in-handler. Return :fx [[:dispatch event]] instead. (A
dispatch-sync aimed at a different frame is allowed, with a warning; see
Frames.)
Gotcha — a dispatch needs a frame in scope
Both dispatch and dispatch-sync get their frame from
scope: a frame-root during render, a running handler, with-frame, or a
capture-frame frame api. From an async callback
with none of these they raise :rf.error/no-frame-context. Capture the frame
while it is in scope, or pass {:frame <id>} in the dispatch options. See
Frames.
From re-frame v1
There is no ^:flush-dom: the drain never pauses mid-run to let a paint
through. For "show this, then run the heavy work", return a
[:dispatch-later {:ms 0 :event [...]}] row. The current drain ends and views
re-render before the next event runs. See From re-frame v1.