03 — Effects and interceptors¶
The effect map is what an event handler returns. The interceptor chain is what runs before and after the handler. Together they're the load-bearing trick that makes re-frame2 a pattern — handlers stay pure (they return descriptions of effects, not the effects themselves), and the runtime actions those descriptions against the real world at exactly one point. That separation is why the trace bus, time-travel, and effect-overrides all work; if handlers fired effects directly, none of those would compose.
This chapter covers what an :fx map can carry (:db, :fx, the standard fx-ids), what an interceptor is and the surface for building one (->interceptor), the ergonomic interceptors v2 ships (path, unwrap, the pre-built validate-at-boundary-interceptor), and the override surfaces that let tests and tools swap fx behaviour at runtime (with-fx-overrides, the per-call :fx-overrides opt). Coeffects are not delivered by an interceptor in v2 — a handler declares the world facts it needs with :rf.cofx/requires registration metadata and the runtime supplies them (see 01 — Core §reg-cofx and Guide — Effects and coeffects). v1's inject-cofx interceptor is removed; see 15 — Removed.
The effect map: closed shape¶
Closed: :db + :fx only. That's the entire effect-map vocabulary in v2.
| Key | Notes |
|---|---|
:db |
The new app-db value. Replaces the current value in the cascade's commit phase. |
:fx |
A vector of [fx-id args] pairs. Each is run by the runtime's fx walker against the registered reg-fx handler. |
If you remember v1's :dispatch / :dispatch-later / :dispatch-n at the top level of the effect map, those don't exist any more — they're inside :fx. The migration is mechanical; see MIGRATION §M-8. The closed shape is what lets the conformance harness validate handler outputs across implementations.
Full schema: Spec-Schemas §:rf/effect-map.
Standard :fx entries¶
Anything in :fx is a [fx-id args] pair. The runtime looks up fx-id in the :fx registrar and runs the registered handler against args. User code registers its own fx-ids via reg-fx; a small set of fx-ids is framework-reserved.
[fx-id args] |
Args | Status | Spec | Intuition |
|---|---|---|---|---|
[:dispatch event-vec] |
event vector | v1 | 002 | "Schedule this event on the same queue." Async — runs after the current cascade completes. |
[:dispatch-later {:ms ms :event event-vec}] |
options map | v1 | 002 | "Schedule this event after N ms." |
[:rf.http/managed args-map] |
per :rf.fx/managed-args |
v1 (optional) | 014 | The canonical managed-HTTP fx. See 07 — HTTP. |
[:rf.nav/push-url url-string] |
URL string | v1 | 012 | Navigate. See 06 — Routing. |
[:raise event-vec] |
event vector | v1 | 005 | Machine-only. Inside a machine action's :fx, routes the event back into the same machine atomically and pre-commit. Unbound outside machine actions. |
[:rf.machine/spawn spawn-spec] |
per :rf.fx/spawn-args |
v1 | 005 | Spawn a dynamic actor instance whose snapshot lives at [:rf.runtime/machines :snapshots <gensym'd-id>] (in runtime-db). See 04 — Machines. |
[:rf.machine/destroy actor-id] |
actor id (keyword) | v1 | 005 | Symmetric counterpart to :rf.machine/spawn. Runs the actor's :exit action, dissociates [:rf.runtime/machines :snapshots <id>] (in runtime-db), clears its event-handler registration. |
[:rf.fx/reg-flow flow-map] |
flow map | v1 | 013 | Register a flow at runtime via :fx. See 05 — Flows. |
[:rf.fx/clear-flow id] |
id | v1 | 013 | Clear a registered flow at runtime via :fx. |
[:http args] |
impl-specific | — | — | User-registered via reg-fx. The legacy un-managed shape; new code uses :rf.http/managed. |
SSR-side server-only fx (:rf.server/set-status, :rf.server/set-header, :rf.server/redirect, etc.) are rowed in 09 — SSR. Their :platforms metadata gates them off the client.
Standard interceptors¶
The interceptor chain wraps the handler. Every interceptor has a :before (runs before the handler) and / or :after (runs after the handler). The runtime threads a context map — the ctx — through the chain, and the chain composes deterministically. Interceptors are how you add cross-cutting behaviour (validation, focus-on-path, logging) without writing it into every handler.
The v2 standard-interceptor surface is two specific helpers (path, unwrap) plus the pre-built validate-at-boundary-interceptor and the ->interceptor primitive. The principle is: keep helpers that do specific, non-trivial work; drop helpers that are just (->interceptor :before f) with no other logic. Five v1 interceptors are removed (debug, trim-v, on-changes, enrich, after); so is inject-cofx — coeffect delivery is now declared with :rf.cofx/requires, not injected by an interceptor (see 01 — Core §reg-cofx and 15 — Removed).
path¶
- Kind: function
- Signature:
- Description: Focus the handler on an
app-dbsub-slice.:beforerewrites the:dbcofx to(get-in db path);:aftersplices the result back into the parent. The handler sees and returns a sub-tree, not the full db.
unwrap¶
- Kind: Var (interceptor value)
- Signature:
- Description: Assert
[id payload-map]event shape; replace the:eventcoeffect with just the payload map; restore on:after. Sugar over the canonical map-payload form (per MIGRATION §M-19).
reg-interceptor¶
- Kind: macro (with
reg-interceptor*as the programmatic*-twin) - Signature:
- Description: The public custom-interceptor authoring form. Register a named interceptor with
:beforeand / or:after, then reference it by id from areg-event/reg-frame:interceptorsvector. Use this for any work not covered by the standard interceptors above — analytics, logging, validation, ad-hoc context manipulation. The interceptor is named, addressable, and queryable like any other artefact. (->interceptoris the framework-internal lowering constructor that turns a descriptor into an executable chain entry; it is not the application-authoring form and must not appear directly in a public chain.)
validate-at-boundary-interceptor¶
- Kind: Var (interceptor value)
- Signature:
- Description: A pre-built interceptor value, not a fn (interceptor
:idis:rf.schema/at-boundary). Add it to areg-eventmetadata map's:interceptorsvector for production-boundary schema validation. Do not call it as a fn — it has no fn arity; invoking(rf/validate-at-boundary-interceptor ...)raisesArityException.
The path interceptor: focus on a slice¶
(rf/reg-event :cart/add-item
{:interceptors [(rf/path [:cart :items])]}
(fn [{:keys [db]} {:keys [item]}]
{:db (conj db item)})) ;; the handler sees and returns the slice (as :db)
The :before rewrites (:db cofx) to (get-in db [:cart :items]). The handler returns the new slice. The :after splices it back with (assoc-in db [:cart :items] result). A handler that focuses on a slice with path and also needs auxiliary world facts declares those facts with :rf.cofx/requires — the slice arrives via the interceptor, the facts via the coeffect declaration (see 01 — Core §reg-cofx).
The unwrap interceptor¶
(rf/reg-event :foo/update
{:interceptors [rf/unwrap]}
(fn [cofx {:keys [id new-value]}] ;; :event coeffect is the payload map
...))
You wrote (rf/dispatch [:foo/update {:id 1 :new-value "x"}]); the handler receives the payload map directly under :event instead of the full vector. Sugar — it's not load-bearing — but it composes cleanly with the canonical map-payload form.
Building custom interceptors with reg-interceptor¶
(rf/reg-interceptor :log-on-error
{:after (fn [ctx]
(when-let [err (:rf.error/last-event ctx)]
(js/console.error err))
ctx)})
(rf/reg-event ::save-cart
{:interceptors [:log-on-error]} ;; reference by id
(fn [cofx _] ...))
Register the interceptor once with reg-interceptor, then reference it by id from the :interceptors vector. The :before / :after fns receive and return the context map; {:before :after} is the entire behaviour vocabulary, and every standard interceptor is just a registered interceptor with specific behaviour baked in.
Context plumbing¶
The interceptor context — the ctx — is the value threaded through the chain. It carries :coeffects (everything available to the handler before it runs), :effects (everything the handler produced), and the queue / stack of remaining interceptors. Most app code never reaches into ctx directly. The rare interceptor body that does works the context map with ordinary Clojure: read coeffects with (get-in ctx [:coeffects k]), read effects with (get-in ctx [:effects k]), and write either slot with assoc-in before returning the updated ctx.
(rf/reg-interceptor :inject-now
{:before (fn [ctx]
(assoc-in ctx [:coeffects :now] (js/Date.now)))}) ;; handler reads (:now coeffects)
(rf/reg-interceptor :tag-db
{:after (fn [ctx]
(let [db (get-in ctx [:effects :db])] ;; inspect what the handler emitted
(cond-> ctx
db (assoc-in [:effects :db] (vary-meta db assoc :tagged? true)))))})
Removed: the façade context accessors
get-coeffect/assoc-coeffect/get-effect/assoc-effectare no longer exported — post-EP-0017/EP-0022 they lost their audience. Work the context map directly as shown above.
Override surfaces¶
The runtime supports three ways to swap fx behaviour without touching the handler. They differ in scope: per-frame (lexical to the frame), lexical (around a body of code), and per-call (on a single dispatch).
with-fx-overrides¶
- Kind: macro
- Signature:
- Description: "For the duration of this body, every
dispatch/dispatch-syncmerges this fx-overrides map into its envelope." Lexical scope; composes withwith-frame. Renamed from v1'swith-overridesper MIGRATION §M-50.
The three scopes compose with a clear precedence:
- Per-call —
(rf/dispatch event {:fx-overrides {...}})wins. - Lexical —
with-fx-overrideswraps the body. - Per-frame —
(rf/reg-frame :todo {:fx-overrides {...}})is the baseline.
Most tests reach for with-fx-overrides because it scopes the swap to the test body without polluting the frame. Pair tools and Story variants reach for per-call overrides because the swap is specific to a single recorded dispatch.
:fx-overrides asymmetry¶
At the pattern level ((rf/dispatch event {:fx-overrides {:my/fx :other-fx-id}})) the override value is an id — the registry name of another fx handler. The CLJS reference implementation also accepts a fn value ({:my/fx (fn [args] ...)}) for ergonomic test wiring. The asymmetry is documented: ports that don't ship fn-valued overrides remain pattern-conformant. See 002 §:fx-overrides.
See also¶
- 01 — Core —
reg-event,reg-fx,reg-cofx,dispatchrowed in the registration and dispatch sections. - 10 — Testing —
with-fx-overridesand the testing fixtures that use it. - 09 — SSR —
:platformsmetadata onreg-fxfor client vs server gating.