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 drain 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 drain may process (default 100). When a drain reaches it, the runtime
stops and emits an always-on error record:
{:error :rf.error/drain-depth-exceeded
:frame :app
:depth 100 ; events already settled this drain
:queue-size 7 ; 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 drain: every event the drain
already settled keeps its app-db write and its epoch. The runtime discards the
remaining queued events and leaves the frame at the last settled state. In Xray you'll
see the 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 100 explicitly.
Raise it only when a frame legitimately fans out wide — a drain that needs
hundreds of 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 :app}) ;; at the REPL, name the frame
@(rf/subscribe [:todo/all] {:frame :app}) ;; 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.