Interceptors¶
Some chores apply to many event handlers: save to storage after every change, log every event, snapshot state for undo. Copying that code into each handler repeats it everywhere.
An interceptor holds the chore instead. You write it once, register it under an id, and wrap handlers, or every handler in a frame, by referencing that id. Most apps have few interceptors or none.
| Situation | Prefer instead |
|---|---|
| Logic for one handler | Keep it in that handler |
| Something that does I/O | An effect (reg-fx) |
| A derived value many places need | A subscription or flow |
A first interceptor: saving todos¶
On the Effects page, every todo handler ended with the same
row, [:todo.storage/save todos]. An interceptor can add that row for them:
(rf/reg-interceptor :todo/persist
{:doc "After the handler, save the todos if it changed them."}
{:after (fn [ctx]
(let [before (get-in ctx [:coeffects :db :todos])
after (get-in ctx [:effects :db :todos])]
(if (and (contains? (:effects ctx) :db)
(not= before after))
(update-in ctx [:effects :fx] (fnil conj []) [:todo.storage/save after])
ctx)))})
reg-interceptor takes an id, an optional metadata map, and a descriptor map with
a :before function, an :after function, or both. :before runs before the
handler; :after runs after it.
Each function takes one argument, ctx, the context, and returns it, possibly
changed. Here :after compares the todos the handler received with the todos it
returned, and if they differ it appends a :todo.storage/save row to the handler's
:fx.
Notes:
- Return the context from every function. Returning
nilis treated as "unchanged", so anassocyou meant to keep is silently lost. - Each dispatch gets a fresh context. Nothing on it carries over to the next dispatch, so one registration is safe on any number of handlers and frames.
- The id is the handle. Chains reference the interceptor by id, and the trace stream, Xray, and overrides find it by id.
Document it
Like every other reg-*, an interceptor without a :doc gets a dev warning
(:rf.warning/missing-doc, once per id, elided from
production). Tools show the :doc wherever they show the id.
Attaching it to a handler¶
Registering an interceptor doesn't run it. You put its id in a handler's chain,
the :interceptors key of the event's metadata:
(rf/reg-event :todo/toggle
{:doc "Flip one todo between done and not done."
:interceptors [:todo/persist]} ;; an id, not the interceptor itself
(fn [{:keys [db]} [_ id]]
{:db (update-in db [:todos id :done?] not)}))
(rf/reg-event :todo/delete
{:interceptors [:todo/persist]}
(fn [{:keys [db]} [_ id]]
{:db (update db :todos dissoc id)}))
The handlers are back to returning just :db, and every change to :todos is still
saved.
reg-event checks that every id in the chain is registered, and throws
:rf.error/unregistered-interceptor if one isn't, so register interceptors in a
namespace your event namespaces :require. The behaviour is looked up at dispatch
time, so re-registering :todo/persist applies to the next dispatch without
re-registering any event. And because the chain is a vector of ids, it is plain data
you can print and diff.
An interceptor map placed directly in a chain, as in {:interceptors [{:after …}]},
raises :rf.error/inline-interceptor-removed. Register it and reference the id.
Coming from Express or Koa middleware?
Interceptors are layers around one core action, each acting on the way in and on
the way out, like Koa's "onion". Three things differ. There is no next(): the
chain is a fixed vector the runtime runs forward, then backward. What passes
through is an immutable map you return a new version of. And middleware performs
I/O itself, while an interceptor adds effect rows and lets the runtime perform
them (below).
The context map: two keys¶
The context is one immutable map passed through the whole chain. Two keys matter:
| Key | Holds | Filled by |
|---|---|---|
:coeffects |
The handler's inputs: :event, :db, and each fact it declared with :rf.cofx/requires |
The runtime, completely, before the chain runs |
:effects |
The handler's outputs: the new :db and the :fx vector |
The handler; then :after functions may change it |
Just after :todo/toggle runs, the context looks like this:
{:coeffects {:event [:todo/toggle 1]
:db {:todos {1 {:id 1 :title "Buy milk" :done? false}} :showing :all}}
:effects {:db {:todos {1 {:id 1 :title "Buy milk" :done? true}} :showing :all}}}
These are the coeffects the handler reads and the
effects it returns. So a :before sees only :coeffects, because the
outputs don't exist yet, and an :after sees both. That is why :todo/persist is an
:after: it compares the two.
A :before can change :coeffects, but it never sees a half-filled map: the runtime
delivers every declared fact before the chain starts.
From re-frame v1: coeffect injection left the chain
In v1, coeffect injection was itself an interceptor, so one placed before it saw
an incomplete :coeffects map. In re-frame2 injection happens before the chain.
re-frame.core has no inject-cofx, so a leftover entry fails to compile; declare
the fact with :rf.cofx/requires instead. See
From re-frame v1.
Before and after: a logger¶
This interceptor uses both functions. It logs each event on the way in and how long the handler took on the way out:
(rf/reg-interceptor :my-app/logger
{:doc "Log each event on the way in, and its timing on the way out."}
{:before (fn [ctx]
(js/console.log "→" (pr-str (get-in ctx [:coeffects :event])))
(assoc ctx ::started-at (js/performance.now)))
:after (fn [ctx]
(let [elapsed (- (js/performance.now) (::started-at ctx))]
(js/console.log "←" (pr-str (get-in ctx [:coeffects :event])) (str elapsed "ms"))
ctx))})
The context is the only way an interceptor passes information from :before to
:after: :before stores the start time on it, and :after reads it back.
::started-at, with a double colon, is a keyword namespaced to the current file, so
it can't collide with anyone else's keys.
The sandwich: how a chain runs¶
Several interceptors wrap each other like the layers of a sandwich: the first in the
chain is the outermost. With three interceptors A, B, C around a handler H,
the runtime makes two passes over one context:
declared: [A B C] + handler H
pass 1, :before in declaration order:
A:before → B:before → C:before → H (the handler runs as the last :before)
pass 2, :after in REVERSE order:
C:after → B:after → A:after
As code, the whole run is one threading expression:
(-> context
((:before A)) ((:before B)) ((:before C))
((:before H)) ;; the handler, wrapped
((:after C)) ((:after B)) ((:after A)))
The runtime also resolves the ids and guards each call, but this is the shape. The
handler is wrapped as an interceptor too, and the way out mirrors the way in: what
B:before sets up, B:after sees after everything inside it has run.
The handler doesn't know it is wrapped, and an interceptor doesn't know what it wraps. They communicate only through the context. Don't write an interceptor that only works when another one wraps it; reordering the chain breaks it.
From re-frame v1: the chain no longer rewrites itself
v1's context carried :queue and :stack, and an interceptor could change them
to rewrite the chain mid-run. re-frame2 has neither: the chain you declare is the
chain that runs, so tools can print it without running it. A :before can still
skip the handler (below), and a
:factory builds a
configured interceptor up front.
The one standard interceptor: path¶
Core ships one standard interceptor, attached with a second kind of reference, an
[id arg] vector. [:rf.interceptor/path <path>] focuses a handler on part of
app-db: it hands the handler just that part as :db, and puts
the returned part back into the full app-db afterwards.
(rf/reg-event :todo/toggle
{:interceptors [:todo/persist [:rf.interceptor/path [:todos]]]}
(fn [{:keys [db]} [_ id]]
{:db (update-in db [id :done?] not)})) ;; db here is the :todos map
The handler reads and writes as if [:todos] were all of app-db. Order matters when
you combine interceptors: :todo/persist comes first, so it is the outer layer, and
its :after runs once path has put the todos back into the full app-db. Placed
after path, it would see only the focused :todos map and find no :todos key in
it.
The [id arg] form works for any parameterized interceptor: the id names a
registered factory (Advanced)
and arg configures it. There is no rf/path function: calling
it throws :rf.error/path-removed. Every chain entry is a keyword or an [id arg]
vector.
Two edge cases:
[]focuses the whole app-db.[:rf.interceptor/path []]passes all of app-db as:dband installs whatever the handler returns.- An unchanged part stays a no-op. If the handler returns no
:db,pathadds none.
Keep an unchanged slice unchanged
The standard path returns the original app-db object when the returned slice
is identical? to the one it passed in. This preserves the commit's no-change
check. A hand-written wrapper that rebuilds the map can miss that fast path;
subscription equality checks still stop unchanged derived values from causing
downstream recomputation or re-renders.
From re-frame v1: the helper interceptors are gone
debug, trim-v, enrich, after, and on-changes do not exist in
re-frame2. Each is a few lines of reg-interceptor. path remains, as
[:rf.interceptor/path …], and flows replace on-changes.
Two places to attach¶
Besides a handler's own chain, you can attach interceptors to a frame:
(rf/make-frame
{:id :app
:interceptors [:my-app/logger]}) ;; wraps EVERY event handled in this frame
Frame interceptors are prepended to each event's own chain, so they run outermost: frame interceptors, then the event's, then the handler. A chore for every event becomes one frame interceptor with no change to handler code.
From re-frame v1: reg-global-interceptor is gone
Per-frame :interceptors replace it. Each frame keeps its own list, so nothing
leaks across SSR requests, Story variants, or test fixtures.
Contribute, don't perform¶
The chain is part of the pure event pipeline that replay and tests re-run. So don't
perform I/O in an interceptor. It would run again on every replay, and
:fx-overrides couldn't redirect it, because :fx-overrides replaces registered
effects, not a localStorage call inside an :after. Add an
effect row instead, as :todo/persist does:
;; Don't do this: runs again on replay, invisible to :fx-overrides and the trace
:after (fn [ctx]
(.setItem js/localStorage "todos" (pr-str (get-in ctx [:effects :db :todos])))
ctx)
;; Do this: add an effect row; the registered effect does the write
:after (fn [ctx]
(if (contains? (:effects ctx) :db)
(update-in ctx [:effects :fx] (fnil conj [])
[:todo.storage/save (get-in ctx [:effects :db :todos])])
ctx))
The one exception is diagnostics: the logger's console.log can stay, because
repeating it on replay is harmless.
Interceptors do two kinds of work:
-
Decide. A
:beforecan skip the handler by setting:rf/skip-handler? trueon the context; every:afterstill runs. An auth guard can skip the handler and add a redirect instead (Require sign-in on a route shows it in full)::before (fn [ctx] (if (get-in ctx [:coeffects :db :auth :user]) ctx (-> ctx (assoc :rf/skip-handler? true) (assoc-in [:effects :fx] [[:dispatch [:auth/show-login]]]))))To validate untrusted input you don't need an interceptor: the
:boundary? trueregistration flag checks the handler's own:schemabefore the chain runs (see Validate with schemas). -
Decorate. Change
:coeffects, rewrite[:effects :db], or add:fxrows.
Gotcha — a frame interceptor runs on the server too
A frame interceptor runs on every event in that frame, SSR
included. The :todo.storage/save row is safe under SSR because it is only data,
and its reg-fx declares :platforms #{:client}, so the server skips it. An
interceptor that reads js/localStorage directly has no such check and fails
during a server render. Keep host access in effect handlers.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
:rf.error/unregistered-interceptor at load time |
A chain names an id that isn't registered yet: a typo, or the interceptor's namespace loads after the event's | Fix the id, or :require the interceptor's namespace from the event's |
:rf.error/inline-interceptor-removed |
A chain holds an interceptor map instead of an id | Register the map and reference its id |
An :after change is lost |
The function returned nil, which counts as "unchanged" |
End every :before / :after with the context |
:rf.error/interceptor-exception with :phase :after |
The :after assumed [:effects :db] exists |
Check for it first (When the chain throws) |
| A side effect repeats on replay, or fails during SSR | The interceptor performs I/O itself | Add an :fx row instead (above) |
Advanced¶
Parameterized interceptors: the :factory descriptor¶
You can write parameterized interceptors too. A descriptor can be {:factory f},
where f takes the reference's one argument and returns an ordinary :before /
:after descriptor:
(rf/reg-interceptor :my-app/stamp
{:doc "After the handler, record which event last changed app-db, under the given key."}
{:factory (fn [stamp-key]
{:after (fn [ctx]
(if (contains? (:effects ctx) :db)
(assoc-in ctx [:effects :db stamp-key]
{:by (first (get-in ctx [:coeffects :event]))
:at (get-in ctx [:coeffects :rf/time-ms])})
ctx))})})
Reference it with the bracket form, passing the one argument. For several inputs, pass a map or vector.
(rf/reg-event :todo/add
{:interceptors [[:my-app/stamp :todo/last-change] :todo/persist]
:rf.cofx/requires [:rf/time-ms]} ;; the :after reads it from :coeffects
(fn [{:keys [db]} [_ title]]
(let [id (inc (apply max 0 (keys (:todos db))))]
{:db (assoc-in db [:todos id] {:id id :title title :done? false})})))
An interceptor sees only the facts the event declared, because
:rf.cofx/requires belongs to the event. Leave that line off and :at is nil.
The factory runs when the chain is built. [:my-app/stamp :a] and
[:my-app/stamp :b] are two different chain entries. An override
(below) keyed by
[:my-app/stamp :a] matches only that entry; a bare :my-app/stamp key matches
every entry with that id.
A real interceptor: undo¶
Undo fits an interceptor well: "remember the old todos, push them if they changed" would otherwise repeat in every handler that changes todos. Register it once, and events opt in by referencing it:
;; cf. examples/core/seven_guis/circle_drawer/core.cljs
(rf/reg-interceptor :todo/undoable
{:doc "Remember the todos before the handler; if it changed them, push the old ones onto :undo."}
{:before (fn [ctx]
(assoc ctx ::prior (get-in ctx [:coeffects :db :todos])))
:after (fn [ctx]
(let [prior (::prior ctx)
db (get-in ctx [:effects :db])]
(if (and db (not= prior (:todos db)))
(update-in ctx [:effects :db :undo] (fnil conj []) prior)
ctx)))})
(rf/reg-event :todo/delete
{:interceptors [:todo/persist :todo/undoable]}
(fn [{:keys [db]} [_ id]]
{:db (update db :todos dissoc id)}))
(rf/reg-event :todo/undo
{:interceptors [:todo/persist]}
(fn [{:keys [db]} _]
(if-let [prior (peek (:undo db))]
{:db (-> db (assoc :todos prior) (update :undo pop))}
{})))
:before stores the todos from :coeffects on the context. :after reads the new
:db from :effects, which is absent if the handler returned none, and pushes an
undo step only if the todos changed. Events that shouldn't create undo steps, such as
typing into a draft, just don't reference it. Undo itself is an ordinary event; redo
is the same with the stacks swapped.
Introspecting a chain¶
A chain is data, so the introspection that works on events and subs works on
interceptors too. handler-meta reads an interceptor's metadata and source location:
(rf/handler-meta {:source :store :kind :interceptor :id :todo/persist})
;; => {:doc "After the handler, save the todos if it changed them."
;; :ns todo.events :line 12 :file "..." ...}
An event's metadata gives you its effective chain: your references, then the
framework's handler wrapper (a map with :rf/default? true).
(remove :rf/default? (:interceptors m)) is the chain as written:
(rf/handler-meta {:source :store :kind :event :id :todo/toggle})
;; => {:doc "Flip one todo between done and not done."
;; :interceptors [:todo/persist {:id :rf/event-handler :rf/default? true ...}] ...}
A tool reads the references off the event, then looks up each one's source and
:doc. That is how Xray draws a chain with links to the source.
(The trace stream already records every event with timings, so
a real app doesn't need the logger above.)
Removing or swapping a reference: :interceptor-overrides¶
A test can remove or replace one interceptor without touching registrations.
:interceptor-overrides matches a chain entry by its reference and removes it
(nil) or replaces it with another reference, per dispatch or per frame:
(rf/dispatch-sync [:todo/toggle 1]
{:frame :app
:interceptor-overrides {:todo/persist nil}}) ;; don't save in this test
(rf/make-frame
{:id :todos/story
:interceptors [:my-app/logger]
:interceptor-overrides {:my-app/logger :story/quiet-logger}}) ;; swap one for another
A parameterized entry is matched in full: {[:rf.interceptor/path [:todos]] nil}
removes only that path. Replacements are references too, so override maps stay
plain data.
When a frame and a dispatch both supply overrides, they merge and the dispatch wins
on shared keys. A key or replacement that isn't a valid reference raises
:rf.error/interceptor-override-invalid.
Testing an interceptor¶
:before and :after are pure functions from context to context, so an interceptor
tests like a handler: call the function with a literal
context and check the result. Name the functions with defn so a test can reach
them:
(defn persist-after [ctx]
(let [before (get-in ctx [:coeffects :db :todos])
after (get-in ctx [:effects :db :todos])]
(if (and (contains? (:effects ctx) :db) (not= before after))
(update-in ctx [:effects :fx] (fnil conj []) [:todo.storage/save after])
ctx)))
(rf/reg-interceptor :todo/persist
{:doc "After the handler, save the todos if it changed them."}
{:after persist-after})
(deftest persist-adds-a-save-row
(let [ctx {:coeffects {:db {:todos {}}}
:effects {:db {:todos {1 {:id 1 :title "Buy milk" :done? false}}}}}]
(is (= [[:todo.storage/save {1 {:id 1 :title "Buy milk" :done? false}}]]
(get-in (persist-after ctx) [:effects :fx])))))
Build the context from the two-key shape: a :before
test supplies only :coeffects, an :after test supplies both. Give each :after
a test with [:effects :db] missing, the shape it gets when the handler returns no
:db or throws (When the chain throws).
To test the wiring, dispatch through a test frame and check the committed state
(a handler's runtime check).
When another interceptor gets in the way,
:interceptor-overrides
removes it for one dispatch.
When a reference is wrong¶
A chain is data, so the runtime checks it at registration. A chain naming an
unregistered id makes reg-event (or make-frame) throw
:rf.error/unregistered-interceptor, naming the id, when the namespace loads. The
other chain errors also fail loud:
| Error | What you did |
|---|---|
:rf.error/invalid-interceptor |
Gave reg-interceptor a descriptor that isn't {:before}, {:after}, {:before :after}, or {:factory} |
:rf.error/unregistered-interceptor |
Referenced an id with no registration |
:rf.error/invalid-interceptor-ref |
Put an entry that is neither a keyword nor an [id arg] vector in a frame's :interceptors chain (in reg-event the same mistake is :rf.error/reg-event-bad-interceptors, below) |
:rf.error/inline-interceptor-removed |
Put an interceptor map, value, or Var in a chain instead of an id |
:rf.error/interceptor-factory-arity |
Used a bracket reference on a non-:factory interceptor, referenced a :factory interceptor as a bare keyword (:rf.interceptor/path with no argument), or the factory can't build for that argument |
:rf.error/reg-event-bad-middle-slot |
Passed the chain positionally, (rf/reg-event :id [:todo/persist] f), instead of as {:interceptors [:todo/persist]} |
:rf.error/reg-event-bad-interceptors |
Gave reg-event's :interceptors a value that isn't a vector, or an entry that isn't a reference |
:rf.error/path-interceptor-bad-path |
Gave [:rf.interceptor/path …] a path that isn't a vector |
:rf.error/interceptor-override-invalid |
Used an :interceptor-overrides key or replacement that isn't a valid reference |
When the chain throws¶
Every :before and :after runs inside a guard.
Gotcha — your :after must survive error paths
A throw in a :before or in the handler skips the remaining :before functions
and the handler. But the :after pass always runs in full, in reverse order,
for every interceptor in the chain, even those whose :before never ran. So
cleanup belongs in :after, and an :after that assumes the handler set
[:effects :db] will itself throw.
Errors collect on the context: the first under :rf/interceptor-error, all of them
under :rf/interceptor-errors, so Xray and Story can show each one. A throw anywhere
means the event installs nothing: app-db is unchanged and no :fx runs. The reported
error names the source:
:rf.error/handler-exception: the event handler threw.:rf.error/coeffect-exception: a coeffect supplier threw, before any:beforeran.:rf.error/interceptor-exception: one of your interceptors threw.:failing-idnames it and:phasesays:beforeor:after.
An :after that throws is recorded but doesn't stop the remaining :after
functions, so one broken cleanup can't block the others. Errors covers
error records.
Where this pattern comes from
Interceptors came to Clojure from the Pedestal team. The shape is older: Tomcat's interceptors, Netty's channel pipeline, and the J2EE intercepting-filter pattern.