Skip to content

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.