Frames: isolated worlds¶
Every example so far ran under one frame-root. A frame is one
running copy of your app, with its own app-db, event queue, and subscription cache,
isolated from every other copy. Most apps create one at boot and never name it again.
You need to know more when you want two copies of your app on one page, or when a
setTimeout callback raises :rf.error/no-frame-context. This page covers both.
The full boot recipe, including init!, hot reload, and the entry namespace, is in
Boot and mount an app.
Two todo lists¶
One set of registrations, mounted in two frames:
(require '[re-frame.core :as rf])
(rf/reg-event :todo/initialise
(fn [_ _] {:db {:todos {} :showing :all}}))
(rf/reg-event :todo/add
(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})})))
(rf/reg-event :todo/toggle
(fn [{:keys [db]} [_ id]]
{:db (update-in db [:todos id :done?] not)}))
(rf/reg-sub :todo/todos (fn [db _] (:todos db)))
(rf/reg-sub :todo/all {:inputs [[:todo/todos]]}
(fn [[todos] _] (vec (sort-by :id (vals todos)))))
(rf/reg-view todo-list []
[:div
[:button {:on-click #(dispatch [:todo/add "Another one"])} "Add"]
[:ul
(for [{:keys [id title done?]} @(subscribe [:todo/all])]
^{:key id}
[:li {:on-click #(dispatch [:todo/toggle id])
:style {:text-decoration (when done? "line-through")}}
title])]])
;; the new idea: the SAME code in two isolated frames
[:div {:style {:display "flex" :gap "2em"}}
[rf/frame-root {:id :todos/work
:initial-events [[:todo/initialise] [:todo/add "Write report"]]}
[todo-list]]
[rf/frame-root {:id :todos/home
:initial-events [[:todo/initialise] [:todo/add "Buy milk"]]}
[todo-list]]]
Add or toggle a todo in one list. The other doesn't change. Nothing in todo-list
names a frame: its injected dispatch and subscribe use whichever frame it renders
inside, so the same view runs against two independent app-dbs.
Each frame-root creates its frame the first time it mounts, runs its
:initial-events in order, and makes that frame current for everything inside it.
Both lists run the same :todo/initialise from app-db,
then add a different first todo.
What a frame is¶
A frame is one running instance of your app. It holds that instance's state:
- its app-db, the map this instance's events read and write;
- its event queue, the dispatches waiting to run;
- its subscription cache, the derived values over this instance's app-db.
A frame does not hold the functions you register with reg-event, reg-sub, and
reg-view. By default every reg-* writes to one shared table, the
registrar, that all frames use. Both lists above run the
same :todo/add handler against different app-dbs. Frames isolate state, not
behaviour, so showing two copies of an app never requires rewriting it.
Coming from Redux?
A frame is a store instance and frame-root is <Provider store={...}>. A
second store gives you a second state tree with the same reducers, and frames
work the same way. The difference: there is no default store. A dispatch that
can't tell which frame it belongs to throws instead of landing somewhere by
convention (below).
The normal case: one app, one frame¶
Almost every app has one frame, established at the root of the view tree:
(ns todo.core
(:require [re-frame.core :as rf]
[re-frame.adapter.reagent :as reagent-adapter]
[todo.views :refer [todo-list]]))
(defonce app-root (reagent-adapter/client-root))
(defn ^:export run []
(rf/init! reagent-adapter/adapter) ;; install the adapter (creates no frame)
(reagent-adapter/render! app-root
[rf/frame-root {:id :app :initial-events [[:todo/initialise]]}
[todo-list]]
(js/document.getElementById "app")))
init! installs the adapter, the one-time connection between
re-frame2 and your rendering library (Reagent here). It creates no frame. Then
frame-root {:id :app …} creates the :app frame on first mount, runs its
:initial-events, and makes it current for the whole subtree, so every dispatch
and subscribe below resolves to :app without naming it. That is also why you can
add frames later without touching app code.
From re-frame v1
v1's single implicit app-db becomes one explicit frame created at the root. That
one wrapper is the only change. :rf/default is a legal frame id you may choose,
but it has no special status: the runtime never falls back to it.
Seeding initial state¶
Seed app-db with named events under :initial-events, as
app-db showed.
:initial-events is an ordered vector of steps. Each step is an event vector
([:todo/initialise]) or, when it needs dispatch options, a map
({:event [:todo/add "Buy milk"] :opts {…}}). Each step runs to completion,
including any events it dispatches, before the next starts, so setup is done by the
time the frame is created.
When you want more than one¶
The cases where you need several frames, roughly in the order you'll meet them:
- The same app twice on one page, like the work and home lists above.
- Story canvases. "Show this view empty, loading, and loaded, side by side" is one set of handlers and three frames, each seeded differently. The Story runner creates them for you.
- A fresh frame per test, torn down afterwards, so no test leaks state into the next. See Test a pipeline run.
- A frame per server request. Server-side rendering creates a frame per HTTP request, renders, and destroys it. A hundred concurrent requests are a hundred isolated app-dbs.
Frame boundaries nest. Inside the root :app frame, each nested frame-root
replaces the current frame for its own subtree:
[rf/frame-root {:id :app}
[:div.split
[rf/frame-root {:id :todos/work
:initial-events [[:todo/initialise] [:todo/add "Write report"]]}
[todo-list]]
[rf/frame-root {:id :todos/home
:initial-events [[:todo/initialise] [:todo/add "Buy milk"]]}
[todo-list]]]]
In Xray, pick one frame and you see only its events and app-db.
To decide a borderline case, ask whether the two things would ever share state. If yes, they are two views over one frame's app-db. If no, they are two frames.
frame-provider and frame-root¶
There are two frame-boundary components:
frame-root {:id …}creates the frame if it doesn't exist and reuses it if it does. Use it at the root of an app and for a view that brings its own frame, such as a Story canvas or an embedded widget.frame-provider {:frame …}makes an existing frame current for a subtree, and creates and destroys nothing. Use it when an enclosingframe-rootor amake-framecall (below) already created the frame. It takes a frame id or a frame value.
A view can bring its own frame:
(rf/reg-view todo-widget []
[rf/frame-root {:id :todos/widget
:initial-events [[:todo/initialise] [:todo/add "Try the widget"]]}
[todo-list]])
Remounting frame-root doesn't reset its frame, which is why hot reload keeps your
place (frame-root's lifetime).
For JavaScript developers
frame-provider {:frame …} is a context Provider around a store someone else
created. frame-root {:id …} is closer to a useRef that lazily creates a
resource and keeps it across re-renders, except that it creates it in a
commit-phase effect and the frame outlives unmount.
The one rule: frame identity is carried, not found¶
A dispatch, a subscription, or a callback gets its frame from its context: the boundary above it, the handler it runs in, or a frame it captured. The runtime never guesses a frame, and there is no default one (frame identity is carried, not found).
So a bare (rf/dispatch [:todo/add "Buy milk"]) works only when something has
established a frame: a frame-root above it while a view renders, the event or
effect handler it runs in, or a with-frame block in a test or at the REPL
(below). Otherwise it throws:
{:rf.error/id :rf.error/no-frame-context
:operation :dispatch
:event-id :todo/add
:recovery :supply-frame}
A fallback to a default frame would work until a second frame appeared, such as a Story canvas or an SSR pass, and then send the dispatch to the wrong frame with no error. Failing where the frame was lost is easier to fix.
Naming a frame explicitly¶
Outside any frame scope, as in a test, a tool, or the REPL, pass a {:frame …}
options map as the second argument to dispatch or subscribe. An explicit frame
always wins:
(rf/dispatch [:todo/add "Buy milk"] {:frame :todos/home})
@(rf/subscribe [:todo/all] {:frame :todos/home})
:rf.error/no-frame-context means no frame at all. Naming a frame that doesn't exist
({:frame :ghost}, a typo or an already destroyed frame) fails differently:
dispatch does nothing, subscribe returns nil, and a :rf.error/frame-destroyed
record goes to the always-on error stream. The runtime
can't tell a typo from a teardown race, so it treats both like a
destroyed frame.
The async boundary: capture the frame¶
A frame gets lost when a callback created inside a frame scope runs later, after the
scope has ended: a setTimeout tick, a promise continuation, a WebSocket
onmessage, a window listener, a third-party SDK callback. A frame-root's scope
lasts only while the view renders, and a handler's scope ends when it returns, so a
bare rf/dispatch in that callback raises :rf.error/no-frame-context. Click
handlers in a reg-view are safe, because the injected dispatch is already bound
to its frame (Views).
The fix is to capture the frame while it is in scope, with
capture-frame, and close over it:
;; cf. examples/patterns/websocket/messages.cljs
(defn open-socket!
"Call from an effect handler: the runtime makes the event's frame current
while its effects run. The socket's callbacks fire later, with no frame."
[url]
(let [{:keys [dispatch]} (rf/capture-frame) ;; capture NOW
socket (js/WebSocket. url)]
(set! (.-onmessage socket)
(fn [e] (dispatch [:todo/remote-changed (.-data e)])))
socket))
(rf/capture-frame) returns a frame api: a map of operations bound to the
current frame, {:frame … :dispatch … :dispatch-sync … :subscribe …}. Open the
socket from the :todos/work frame and its messages always land there. Called with
no frame in scope, (rf/capture-frame) itself raises :rf.error/no-frame-context;
(rf/capture-frame :todos/work) binds to a named frame instead.
You don't need this to schedule a dispatch from an event handler. Return effect rows, and they carry the frame for you:
(rf/reg-event :todo/show-notice
(fn [{:keys [db]} [_ message]]
{:db (assoc db :notice message)
:fx [[:dispatch-later {:ms 3000 :event [:todo/hide-notice]}]]}))
capture-frame is for callbacks the effect system doesn't schedule, like the
socket's onmessage, even when the code that registers them runs in an effect
handler.
For JavaScript developers
This is the familiar stale-closure problem. In JavaScript, a closure over the wrong store often works silently against the wrong data. Here a callback that didn't capture its frame throws.
Subscriptions never read across frames¶
A subscription belongs to one frame. It computes from that frame's app-db and that frame's other subscriptions. There is no API for reading frame B from a subscription in frame A, and you must not build one by reading another frame's app-db inside a subscription.
Story variants stay reproducible, concurrent SSR requests stay independent, and test frames stay isolated only because nothing outside a frame affects it. The epoch record, time-travel, and replay would also misreport frame A once its values depended on frame B. If two things need shared derived state, they belong in one frame.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
:rf.error/no-frame-context from a timer, promise, or socket callback |
The callback runs after the frame scope ended | Capture with rf/capture-frame while in scope, or return :dispatch / :dispatch-later from a handler |
:rf.error/no-frame-context at the REPL or in a test |
No frame is in scope | Wrap in rf/with-frame, or pass {:frame id} |
:rf.error/frame-provider-frame-absent |
frame-provider names a frame that was never created, or was destroyed |
Use frame-root, or call make-frame first |
:rf.error/frame-root-given-frame or :rf.error/frame-provider-given-id |
:id and :frame swapped between the two components |
frame-root takes :id; frame-provider takes :frame |
:rf.error/frame-root-missing-id |
frame-root has no :id, or it isn't a keyword |
Give it a keyword :id |
:rf.error/frame-root-reconfigured |
A mounted frame-root's :id or options changed |
Give it a React key that changes with the id, or call rf/make-frame with the same :id to reconfigure |
Dispatch does nothing; :rf.error/frame-destroyed on the error stream |
{:frame …} names a mistyped or destroyed frame |
Fix the id, or stop dispatching after destroying the frame |
:rf.error/handler-exception whose exception is :rf.error/frame-construction-in-handler |
make-frame called from an event handler |
Write app-db from the handler and let a view's frame-root create the frame |
What frames are not¶
- Component-local state. A frame carries a full app-db, queue, and subscription cache. A dropdown's open flag or an input's draft text goes in the current frame's app-db; see Where should this value live?.
- Routing. Navigating changes which part of app-db matters, not which frame is running. One frame, many routes.
- Micro-frontends. Frames are copies of one app sharing the same handlers. Two surfaces with different handler sets can share a page (see Images), but two different apps on one page want iframes.
Going deeper: when two frames resolve the same id differently
This page assumed all frames use one shared registrar. The set of registrations
a frame uses is its image. Occasionally two frames should
resolve [:todo/add] to different handlers, as with two examples on one page or
an inspection tool beside the app it inspects. Then you give those frames
different images; see Images.
Advanced¶
frame-root's lifetime¶
frame-root creates the frame in a client useLayoutEffect, at commit, not during
render, and renders its children once the frame is live. A render React discards
before commit, such as a Suspense abort, creates nothing.
Remounting frame-root doesn't reset anything. After a hot reload or a Story
re-evaluation, the existing frame keeps its app-db and :initial-events do not run
again, which is why hot reload doesn't lose your place. Changing a mounted
frame-root's :id or options raises :rf.error/frame-root-reconfigured. To switch
to a different frame, give the frame-root a React key that changes with it. To
change the same frame's config, call rf/make-frame with the same :id, which
updates the config without resetting state.
Neither component destroys the frame on unmount. When a component should own a
frame's whole lifetime, such as a modal with a throwaway frame, call rf/make-frame
and rf/destroy-frame! (below) from its mount and
unmount lifecycle: a useEffect and its cleanup in UIx or React, create-class in
Reagent.
The rest of the frame config¶
The frame config is the same map whether you pass it to frame-root or to
make-frame, the function that creates a frame directly. Besides :initial-events
it accepts:
(rf/make-frame
{:id :todos/work
:doc "The work todo list."
:initial-events [[:todo/initialise]
[:todo/add "Write report"]] ;; ordered setup steps
:on-destroy [:todo/cleanup] ;; dispatched once during teardown
:fx-overrides {:todo.storage/save stub-fn} ;; per-frame effect replacements
:interceptors [:my-app/logger] ;; interceptor ids prepended to every event
:drain-depth 100 ;; run-to-completion depth limit
:preset :test}) ;; :default, :test or :story
Notes:
:on-destroyis dispatched once duringdestroy-frame!, after queued work is discarded. Events it dispatches into the same frame also run before the frame is removed.:fx-overridesreplaces effect handlers by id, usually with test doubles, so the frame never touches storage or the network.:interceptorsprepends interceptor ids to every event in the frame; see Interceptors.:drain-depthcaps the run-to-completion drain.:presetexpands into a bundle of defaults.:teststubs:rf.http/managed, sets:drain-depthto 100, and makes coeffect minting strict;:storystubs HTTP and sets:drain-depthto 16. Your own keys win, and(rf/frame-meta :todos/work)shows the result.
Here two frames run the same registrations with different :fx-overrides. One
routes :rf.http/managed to a stub that answers from a route map. The other routes
it to :rf.http/managed-canned-failure, which fails every request as if the network
were down. Click Sync in each:
(require '[re-frame.core :as rf]
'[re-frame.http.managed]
'[re-frame.http.test-support :as http-test-support])
(http-test-support/install-managed-request-stubs!
{[:get "/api/todos"] {:reply {:ok [{:id 1 :title "Write report"}
{:id 2 :title "Book flights"}]}}})
(rf/reg-event :todo/sync
(fn [_ _]
{:fx [[:rf.http/managed {:request {:method :get :url "/api/todos"}
:on-success [:todo/synced]
:on-failure [:todo/sync-failed]}]]}))
(rf/reg-event :todo/synced
(fn [{:keys [db]} [_ {:keys [value]}]]
{:db (assoc db :synced value :sync-error nil)}))
(rf/reg-event :todo/sync-failed
(fn [{:keys [db]} [_ {:keys [error]}]]
{:db (assoc db :sync-error (:kind error))}))
(rf/reg-sub :todo/synced (fn [db _] (:synced db)))
(rf/reg-sub :todo/sync-error (fn [db _] (:sync-error db)))
(rf/reg-view sync-panel []
(let [todos @(subscribe [:todo/synced])
error @(subscribe [:todo/sync-error])]
[:div
[:button {:on-click #(dispatch [:todo/sync])} "Sync"]
(cond
error [:p "Failed: " (str error)]
todos [:ul (for [{:keys [id title]} todos] ^{:key id} [:li title])]
:else [:p "Not synced"])]))
;; the new idea: each frame decides what :rf.http/managed does
[:div {:style {:display "flex" :gap "2em"}}
[rf/frame-root {:id :todos/online
:fx-overrides {:rf.http/managed :rf.http/managed-test-stub}}
[sync-panel]]
[rf/frame-root {:id :todos/offline
:fx-overrides {:rf.http/managed :rf.http/managed-canned-failure}}
[sync-panel]]]
The left panel lists both todos and the right one shows
Failed: :rf.http/transport. Nothing in sync-panel or its handlers names a
stand-in; each frame chooses its own.
The :observability key is covered in
Observability, and
the full grammar in the API reference.
Construction throws :rf.error/bad-frame-classification before any setup runs if the
config carries :sensitive or :large (those belong on handler effects; see
data classification) or a malformed
:observability entry. A shape mistake such as {:initial-events [:todo/initialise]},
a bare event instead of a vector of steps, is also rejected before any setup runs,
with :rf.error/initial-events-bare-event and a message naming the fix
([[:todo/initialise]]).
If a step fails (its handler throws, or a declared coeffect is missing),
construction stops: the partial frame is destroyed and make-frame or
frame-root throws :rf.error/initial-events-step-failed, naming the step's
:step-index and :event. A step whose event has no handler is not a failure of
this kind: it emits :rf.error/no-such-handler and the frame is built without it.
As an app author you call init! once and create frames.
re-frame.substrate.adapter/install-adapter!, rf/destroy-adapter!, and the
adapter-spec map are for people writing an adapter.
Ending and resetting a frame¶
Most frames live for the whole program. Tests, tools, and SSR harnesses tear theirs down explicitly:
(rf/destroy-frame! :todos/work) ;; run teardown and remove the frame
;; Reset to "just created": destroy, then create again with the same config.
(rf/destroy-frame! :todos/work)
(rf/make-frame config) ;; the same config, carrying :id :todos/work
destroy-frame! takes a frame id or frame value. It discards the frame's queued
events immediately. An event already running may finish its own code, but nothing it
produced is committed, no effects run, and nothing renders. Then the :on-destroy
event runs, if there is one, and finally the subscription cache is disposed, feature
resources are released, and the frame is removed.
After that, a dispatch or subscribe aimed at the frame does not throw: dispatch
does nothing, subscribe returns nil, and a :rf.error/frame-destroyed record goes
to the always-on error stream. The runtime can't tell a
harmless teardown race from a real use-after-destroy bug, so it recovers and reports.
A full reset, destroy-frame! then make-frame with the same config, clears app-db
to {}, clears the subscription cache and queue, and runs :initial-events again.
Tests and Story "reset" buttons use it. For a frame built from images, pass the same
:images again. The two calls are not atomic, so run them outside any handler. To
reset only app-db, dispatch [:rf/set-db {}] to the frame, or, with
day8/re-frame2-epoch loaded, call (rf/replace-frame-state! frame-id {:rf.db/app {}}).
Creating a frame inside an event handler throws
:rf.error/frame-construction-in-handler, which the error stream reports as a
:rf.error/handler-exception carrying it. Handlers change app-db; views, boot code,
and SSR request code create frames. A handler that wants a new frame writes app-db to
say so, and a view's frame-root creates it.
Scoping a frame in a test or at the REPL¶
Tests and the REPL run outside any view, so no frame is in scope, and adding
{:frame …} to every call is tedious. Two macros make a frame current for a block:
;; Make an EXISTING frame current for the block (creates and destroys nothing):
(rf/with-frame :todos/work
(rf/dispatch-sync [:todo/add "Buy milk"])
@(rf/subscribe [:todo/all]))
;; CREATE a frame, use it, and destroy it on exit, even if the body throws:
(rf/with-new-frame [f (rf/make-frame {:initial-events [[:todo/initialise]
[:todo/add "Buy milk"]]})]
(rf/dispatch-sync [:todo/add "Walk the dog"])
(is (= 2 (count (:todos (rf/app-db-value f))))))
with-frame is the block-scoped version of frame-provider. with-new-frame owns
the frame's lifetime and destroys it when the block exits. Inside either, a plain
dispatch or subscribe uses the bound frame. make-frame returns a live frame
value, and every API that takes a frame accepts that value or its id.
These examples use dispatch-sync, which returns only after the event has run to
completion, so the next line can assert on the result
(Run to completion).
Test a pipeline run covers the full test setup.
with-frame binds a dynamic var, so its scope ends when control leaves the block.
An async callback created inside the block that fires later has no frame, and
with-new-frame has destroyed its frame by then. Capture a frame api with
capture-frame, or pass {:frame …}, before the async boundary.
Hold, scope, override¶
Code gets its frame in one of three ways. Prefer them in this order:
- Hold: carry the frame as a value. This survives every boundary, including
async callbacks and functions handed to outside libraries. The primitive is
capture-frame, and each context has its own form of it:- In a Reagent
reg-view, the injecteddispatchandsubscribeare already bound. - In UIx, the
(use-frame)hook returns the same frame api (see Use UIx or reagent-slim). - Anywhere else, call
rf/capture-framedirectly.
- In a Reagent
- Scope: make a frame current for a region.
frame-rootandframe-providerdo it for a React subtree through context;with-framedoes it for a synchronous block through a dynamic var. Scope lasts only while control stays in the region, which is why async callbacks need to hold. - Override: pass
{:frame f}explicitly, for a tool, a test, or an SSR pass working on a frame from outside. Needing it in app code usually means the code lost its frame and should hold instead.
Run to completion is per frame¶
Each frame has its own queue and its own drain.
Frame A's drain settles A's queue and B's settles B's; they never merge. A depth-limit
halt or a destroy-frame! ends only that frame's drain, and each frame's
epochs and time-travel history are
independent.
Cross-frame dispatch-sync during a drain¶
Calling dispatch-sync for the current frame from inside that frame's running
handler drops the event, and a dev build reports
:rf.error/dispatch-sync-in-handler. A dispatch-sync aimed at a
different frame is allowed: the target frame's drain runs to completion, then the
caller's frame continues.
That is rarely what you meant, so the runtime emits
:rf.warning/cross-frame-dispatch-sync-during-drain and proceeds. To send an event
to another frame, prefer (rf/dispatch event {:frame other}), which queues it on the
target to run after your drain settles. Keep the synchronous form for tests and tools
that need the other frame settled before the next line.