Spec 006 — Reactive Substrate¶
Status: Drafting. v1-required (CLJS reference).
For where the adapter sits in relation to the rest of the runtime — the frame container, sub-cache, drain loop, and trace bus — see Runtime-Architecture.
Abstract¶
re-frame2 separates the dataflow core from the reactivity / rendering substrate — the abstract surface this spec defines. The substrate-agnostic core — registrar, frames, drain, dispatch envelope, subscription topology, sub computation, effect-map interpretation, trace stream — is JVM-runnable and has no dependency on Reagent, React, or DOM. A pluggable adapter (each implementation of the substrate contract) supplies the reactive container for app-db, the change-tracking that drives view re-renders, and the render-tree → surface step.
Substrate scope: React + VDOM. re-frame2 commits to React + VDOM as the rendering substrate. The adapter contract has two parts. The reactive-container half (entries 1-5 + 9 of §The adapter API contract:
make-state-container,read-container,replace-container!,subscribe-container,make-derived-value,dispose-adapter!) is substrate-agnostic in shape — its description does not mention React; it would generalise to any reactive primitive. The render-side half (entries 6-8:render,render-to-string,register-context-provider) is React-shaped:rendermounts viareact-dom/client.createRoot,render-to-stringwalks a hiccup-or-equivalent virtual-DOM tree to HTML (the contract for SSR (Spec 011)), andregister-context-providerreturns aReact.createContext-style provider. Ports are scoped to the eight JS-cross-compile-to-React-binding languages enumerated in 000 §The pattern. Non-React substrates (Vue, Solid, Svelte, vanilla DOM, Replicant, Lit) are out of scope; substrate-agnostic shape on the reactive-container side reflects "the contract generalises if we ever wanted it to," not "we ship adapters for them."Terminology. Throughout this spec, "substrate" names the abstract contract — the closed set of functions an adapter must implement. "Adapter" names each implementation: the Reagent adapter, the UIx adapter, the Helix adapter, the plain-atom adapter. Per rf2-0imy the implementation directory is
implementation/adapters/, the CLJS namespaces arere-frame.adapter.<name>, and the Maven artefacts areday8/re-frame2-<name>(unchanged from the earliersubstrates/directory naming).
This Spec defines:
- The boundary between core and adapter.
- The adapter API contract — the closed set of functions every adapter implements.
- Subscription cache invalidation semantics that adapters must respect.
The CLJS reference ships two adapters: a Reagent adapter (browser default) and a plain-atom adapter (JVM, used by SSR and headless tests). The same core runs against both; the observable behaviour of events, subs, and effects is identical across adapters given the same core inputs.
The boundary¶
re-frame2 splits into three layers:
┌─────────────────────────────────────────────────────────────────────┐
│ Application code (events, subs, views, fx, machines) │
│ ──────────────────────────────────────────────────────────────── │
│ Substrate-agnostic core (frame, registrar, drain, dispatch) │
│ - Pure data flow │
│ - JVM-runnable │
│ - No Reagent, no React, no DOM │
│ ──────────────────────────────────────────────────────────────── │
│ Substrate adapter (Reagent in CLJS reference; or others) │
│ - Reactivity primitives (atom-equivalent, derived-value-equivalent)│
│ - Render-tree → DOM (or to string for SSR) │
└─────────────────────────────────────────────────────────────────────┘
The substrate-agnostic core is what every implementation supplies. The adapter is where host-specific choices live.
What the core owns¶
The core is the substrate-agnostic part. It owns:
- The handler registrar.
(kind, id) → metadatalookup. Pure data. JVM-runnable. - The frame contract. Each frame holds an
app-dbvalue, a queue, a sub-cache, and an id. The "value" interface is what the core requires; the adapter provides the reactive container that holds it. - The dispatch envelope and event queue. Per 002 §Routing. Pure data, FIFO.
- The drain mechanism. Run-to-completion drain (per 002 §Run-to-completion). Pure logic over the queue.
- Subscription topology. The static
:<-graph derived fromreg-subchains. Pure data, JVM-runnable. - Subscription computation.
(compute-sub query-v db)— running a sub's body against anapp-dbvalue. Pure function. JVM-runnable. - Effect map interpretation. Walking
:fxand dispatching to registered fx handlers. Per Spec-Schemas §:rf/effect-map. - The trace event stream. Per 009. Pure data.
If you can plumb a runtime through these primitives, you have re-frame2's substrate-agnostic spine. None of it requires a reactivity library.
What the adapter owns¶
The adapter is the substrate-specific part. Per §Abstract, the adapter splits into a universal half (the reactive-container contract — would work over any reactive primitive) and a React-shaped half (the render side — explicitly assumes React + VDOM).
Universal half — the reactive container for app-db and its tracking.
- The reactive container for
app-db. In CLJS, this is a Reagent ratom. In CLJS-headless / SSR, aclojure.core/atom. In a TypeScript-React port, a tiny atom-shape overuseSyncExternalStore's snapshot store; same shape for the Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint ports atop their host's React binding. - Subscription tracking — the runtime side of reactivity. The view's
subscribecall returns a value, and when the underlyingapp-dbslice changes, the view re-renders. The view-render side is React's job; how the container's mutation feeds React's render scheduler is the adapter's call: in Reagent, Reagent's reaction graph + the React renderer; in UIx / Helix / TS-React / Fable.React / Feliz / ReasonReact / Halogen-React / kotlin-react,useSyncExternalStoreover the container'ssubscribe-containerwatch.
React-shaped half — render and frame-routing.
- Render-tree consumption. Walking the hiccup (or equivalent virtual-DOM tree) and producing DOM via React. In CLJS, Reagent does this through
react-dom/client. In every in-scope JS-cross-compile port, the host's React binding (Fable.React'screateRoot, kotlin-react'screateRoot, ReasonReact'screateRoot, etc.) calls into the samereact-domunderneath. SSR is a hiccup-or-equivalent → HTML pure walk on the JVM (per Spec 011); equivalent on the server-side runtime in any JS-cross-compile port. - Component lifecycle. Mount, update, unmount — React's lifecycle. Adapters wire into it via React's hooks or class-component machinery, host-binding-specific.
- Frame-routing for views. React context — per 002 §View ergonomics. The CLJS reference uses Reagent's
:contextType(class-component path) and a function-component_currentValueread; other ports' React bindings exposeuseContextas the standard mechanism. The contract is "context value carrying the current frame-id; views read via the host React binding's hooks-equivalent." See §Frame-provider via React context below for the per-port realisation.
Adapter behaviour is observably equivalent across the in-scope React-binding adapters given the same core: the same events produce the same state, the same subs return the same values. The adapter only changes how the view sees those values reactively and which React binding mounts the tree.
The adapter API contract¶
Every adapter implements the surface below. The contract is closed for v1 — the function set is fixed, signatures are fixed, dispose-after-use is fixed; new adapter capabilities ship post-v1 additively (a new fn with a feature predicate consumers can branch on).
The adapter contract is the canonical mechanism for bridging external reactive sources (timers, JS event streams, external pub/sub, signals from other libraries). The v1
reg-sub-rawescape hatch — which v1 users sometimes leaned on for non-app-db reactivity — is not shipped in v2 (per MIGRATION §M-18). A custom adapter brings the external source into the substrate; subs consume normally viareg-sub. State that needs to live across Goal 2 — Frame state revertibility must reachapp-dbthrough an event handler (Pattern-AsyncEffect plus a registered fx), not through an adapter-private side channel — see §What an adapter MUST NOT do.
The v1 adapter surface is six required functions, two optional functions, and one lifecycle function — nine entries in total. The Normative contract section below specifies the call-shape for each; §Operational semantics covers cache-invalidation behaviour the adapter must respect; §CLJS reference: Reagent as default adapter covers reference-host implementation notes.
Normative contract¶
Required (6): every adapter must implement.
| Fn | Purpose |
|---|---|
make-state-container |
Create a reactive container holding an app-db value. |
read-container |
Read the current value (pure). |
replace-container! |
Mutate the container with a new value (the only mutation primitive). |
make-derived-value |
Construct a derived (memoised) container from one or more sources. |
render |
Render a render-tree onto the substrate's surface; return an unmount fn. |
render-to-string |
Pure render to an HTML string (JVM-runnable). |
Optional (2): adapters may omit; the core falls back when an optional fn is absent.
| Fn | Purpose | Fallback when absent |
|---|---|---|
subscribe-container |
Register a change-listener for invalidation. | Core runs invalidation inline within replace-container!. |
register-context-provider |
Return a context-provider component that scopes a frame to a subtree. | Core falls back to explicit-frame-as-argument; the user's view code threads the frame. |
Lifecycle (1): every adapter must implement.
| Fn | Purpose |
|---|---|
dispose-adapter! |
Tear down: release listeners, caches, host resources. |
(make-state-container initial-value) → container¶
Returns a container that holds an app-db value. The container is opaque to the core; the adapter exposes operations on it via the next three functions.
value is an immutable map (the initial app-db). The container's identity is stable — operations later in this section refer to the same container.
CLJS-Reagent: returns a Reagent r/atom.
CLJS-headless: returns a clojure.core/atom.
TS-React: returns a tiny atom-shape ({value, subscribers}) wired into React via useSyncExternalStore.
Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint: same atom-shape over the host's React binding's useSyncExternalStore equivalent.
(read-container container) → value and (replace-container! container new-value) → nil¶
The two basic operations on a container. read-container is pure; replace-container! is the only mutation primitive — partial updates aren't supported (the core always replaces the entire app-db after a drain).
(read-container container) ;; → current app-db value
(replace-container! container new-value) ;; → nil; container now holds new-value
Nil-container guard (defense-in-depth). The core's replace-container! wrapper guards against the destroy-race case where a write (router :db commit, drain rollback, flows recompute, epoch restore, SSR write) arrives after the owning frame has been destroyed and frame/get-frame-db has started returning nil. When container is nil, the wrapper SKIPS the underlying adapter's replace-container! call and emits a :warning :rf.warning/write-after-destroy trace (per 009 §Where trace emission lives) with :recovery :no-recovery — the write is dropped, no exception is thrown. The earlier behaviour (rf2-ft2b reproducer) was an NPE on a background thread; the guard centralises destroy-race handling on the one mutation primitive that every frame app-db write flows through. Adapter implementations may assume container is non-nil; the guard is in the core's wrapper, not in the adapter contract.
(subscribe-container container on-change) → unsubscribe-fn¶
Optional. Registers a callback that fires after replace-container! runs. The callback receives (prev-value, new-value).
(subscribe-container container on-change) ;; → unsubscribe-fn
;; on-change signature: (fn [prev-value new-value] ...)
;; unsubscribe-fn signature: (fn [] nil) — idempotent
If the adapter supports it, the core uses subscribe-container to wire reactive sub-cache invalidation. The CLJS reference adapters (Reagent, UIx, Helix, plain-atom) all supply it — the add-watch/remove-watch realisation is the lowest-common-denominator listener surface that every Clojure-host atom or atom-shape exposes for free. An adapter that genuinely cannot supply listeners (a host whose container primitive offers no observer hook) signals "unsupported" by either omitting the entry from its adapter spec map or returning nil from subscribe-container; in that case the core falls back to running invalidation inline within replace-container! itself (the adapter must, in that case, ensure replace-container! runs the core's invalidation hook before returning).
CLJS-Reagent: Reagent's reaction machinery handles this implicitly; subscribe-container returns a function that cancels the registration. The reference Reagent adapter additionally exposes the listener surface via add-watch on the underlying r/atom so the substrate contract is honoured uniformly across adapters — see §CLJS reference: Reagent as default adapter.
CLJS-headless (plain-atom adapter, JVM and Node): supported via add-watch on the clojure.core/atom container; the returned unsubscribe-fn calls remove-watch. This lets headless tests and SSR builders register change-listeners without resorting to polling — see §Plain-atom adapter (JVM, SSR, headless).
TS-React / Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint: returns a function that detaches the listener from the atom-shape's subscriber list (the same store useSyncExternalStore consumes).
(make-derived-value source-containers compute-fn) → container¶
Returns a derived container whose value is computed from one or more source containers. The derived container updates automatically when any source's value changes (transitively).
(make-derived-value source-containers compute-fn) ;; → container
;; source-containers: vector of containers
;; compute-fn signature: (fn [& source-values] ...) — pure; called with deref'd values
The returned container supports read-container; replace-container! is not supported on derived containers (errors with :rf.error/derived-container-replaced). subscribe-container works as on a base container.
The derived container's caching responsibility is adapter discretion: an adapter MAY memoise the derived value (and is encouraged to where the host primitive makes it cheap — Reagent's Reaction does this for free), or MAY recompute on every read-container and rely on the per-frame sub-cache (§Subscription cache — contract and operational semantics) to enforce the =-equality invariant across recomputes. Either shape is conformant. What is NOT conformant: a derived container whose recompute fires for an input that did not change by = and whose downstream propagation does not collapse on =-equal new values — that would break the cascade rule in §Invalidation algorithm.
CLJS-Reagent: a Reagent reaction — memoising; re-runs only when an input deref changes by =. CLJS-headless (plain-atom adapter): an IDeref wrapper that recomputes on every read; no memoisation at the substrate layer because SSR runs each sub at most a handful of times per request and the sub-cache (when present) handles =-equality cascading. TS-React / UIx / Helix / other JS-cross-compile ports: an IDeref+IWatchable-shaped wrapper that recomputes on read and broadcasts change via the source containers' watch machinery (see §CLJS reference: UIx as alternative substrate and §CLJS reference: Helix as alternative substrate).
(render render-tree mount-point opts) → unmount-fn¶
Renders the render-tree onto the substrate's surface and returns a function that unmounts.
(render render-tree mount-point opts) ;; → unmount-fn
;; render-tree: a serialisable nested data structure (per Spec 004)
;; mount-point: implementation-specific (DOM element passed to react-dom/client.createRoot)
;; opts: open map; standard keys: :on-mismatch (per Spec 011), :hydrate? (boolean)
;; unmount-fn signature: (fn [] nil) — idempotent; releases all resources
CLJS-Reagent: wraps reagent.dom.client/create-root + reagent.dom.client/render (React 18+ Root API); the unmount-fn closes over the Root and calls (rdc/unmount root). Hydrate path uses (rdc/hydrate-root mount-point render-tree) which returns its own Root (rf2-fn5rk).
SSR-on-JVM: this function isn't called server-side — render-to-string is used instead. The adapter may stub render to throw on the JVM.
(render-to-string render-tree opts) → string¶
Pure function. Renders the render-tree to an HTML string. JVM-runnable in the CLJS reference.
(render-to-string render-tree opts) ;; → string
;; opts: open map; standard keys: :doctype? (boolean), :frame (frame-id for resolving registered views)
The implementation is the per-host pure walk of the render-tree (per Spec 011 §The render-tree → HTML emitter).
(register-context-provider frame-keyword) → component¶
Optional. For substrates with a context concept, returns a component that scopes a frame to a subtree.
CLJS-Reagent: returns the frame-provider Reagent component (a React Context Provider).
TS-React: returns an equivalent React.createContext-backed Provider component.
Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint: each returns the host React binding's createContext-backed Provider component (Feliz / Fable.React createContext, scalajs-react createContext, React.Basic.Hooks.createContext, kotlin-react createContext, ReasonReact React.createContext).
Headless / SSR (no React, no DOM): not supplied — the core falls back to explicit-frame-as-argument; the user's view code threads the frame.
Adapter disposal lifecycle¶
Every adapter exposes:
Called by the core when the runtime shuts down (process exit, test-frame teardown, or explicit (rf/shutdown-runtime!)). The adapter must:
- Cancel all in-flight reactive subscriptions.
- Release any host-specific resources (DOM event listeners, websocket subscribers, timers).
- Discard internal caches.
- Make subsequent calls to other adapter functions return
:rf.error/adapter-disposed(or throw, host-dependent).
The adapter is single-use after disposal; restart requires (install-adapter!) again.
In CLJS-Reagent: clears Reagent's reaction caches, unmounts any active root. In CLJS-headless: no-op (no resources held).
Revertibility constraints on adapters¶
Per 000 §Frame state revertibility, Goal 3 commits that a frame's complete runtime state is a single persistent value — reverting that value to any prior point fully reverts the frame. The adapter sits between the core and the host's reactivity layer, so its contract has to honour the goal explicitly: an adapter must not stash information that survives a revert of the frame value.
The rule:
An adapter may hold internal state if and only if that state is derivable from the frame's value. State that adds information not present in the frame value is prohibited.
"Derivable" means: dropping the adapter's internal state and recomputing it from the frame's current value yields equivalent observable behaviour. Memoisation caches, reaction caches, and listener-registration tables are derivable — they exist for performance and reattachment, not to hold information. State that adds information (an undo stack the adapter owns; a counter the adapter increments per render and reads back later; observer-side data that survives replace-container!) is not derivable and is therefore prohibited.
What this means per adapter primitive¶
make-state-container— the container holds the frame'sapp-dbvalue. The container's identity is stable but its value is the frame value; nothing else lives there. ✓read-container— pure read of the held value. No state. ✓replace-container!— single mutation primitive; after it returns, the container's value IS the supplied new value. The framework's revert path is(replace-container! container prior-value); this is the entire mechanism. ✓subscribe-container— registers a change-listener. The adapter's listener registry is transient infrastructure: dropping it and re-registering listeners is observably equivalent (modulo a tick of latency). The registry holds no information about the frame value. ✓make-derived-value— caches a derived value computed from sources. The cache is a pure memoisation of(compute-fn @source-1 @source-2 ...); if the cache were dropped, the next read would recompute and produce an equal value. Derivable. ✓render— produces DOM/UI as an external side effect. The DOM is outside the frame value entirely; reverting the frame value does NOT un-paint the DOM. This is the registered-fx seam Goal 3 names: external side effects need compensation, not reversal. The view layer re-renders on the next dispatch cycle and the UI follows. ✓register-context-provider— returns a stateless component (the host's context-provider). No state. ✓dispose-adapter!— tears down the adapter. After disposal,install-adapter!recreates a fresh one; no state survives. ✓
Reference-adapter compliance¶
- CLJS-Reagent. Reagent's
Reactionmachinery caches derived values (memoisation: derivable). The track-cache that Reagent maintains for reaction graphs is regenerable from the underlying ratoms (which hold the frame value) — drop the cache, the next deref rebuilds it. Reagent's listener registry is transient. No observer state outside the frame value. ✓ - CLJS plain-atom (headless). The container is a
clojure.core/atom. The adapter exposessubscribe-containerviaadd-watch/remove-watchandmake-derived-valueas anIDerefwrapper that recomputes on every read (no memoisation at the substrate layer — see §make-derived-value). The watch-key registry is transient — drop it, re-register, and observable behaviour is unchanged (modulo a tick); the derived-value wrapper holds no state beyond its source-container references. No reactivity graph and no value cache live outside the frame container. ✓ - TS-React / Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint adapters. Same constraint applies: each port's atom-shape subscriber registry, the
useSyncExternalStoresnapshot store React caches, and any derived-value memoisation must all be derivable from the frame's value. Ports verify the host React binding doesn't squirrel away non-derivable state outside the frame container.
What an adapter MUST NOT do¶
These would all violate revertibility and are prohibited by the adapter contract:
- Maintain a separate "previous values" history outside the frame's epoch buffer — any history-of-state lives in the framework's epoch-history (per Tool-Pair §Time-travel), not inside the adapter.
- Hold an adapter-private mutable cell that view code can read or write through a side channel — every view-visible value must come through
read-container(transitively, throughmake-derived-value/subscribe-container), so that reverting the container reverts what views see. - Cache derived values keyed on identity rather than value — caches must invalidate on
=-equality of inputs (per §Subscription cache invalidation) so that a revert to a prior=-equal state surfaces the prior derived values. - Persist any internal state across
dispose-adapter!/install-adapter!. Disposal is total.
Verifying compliance¶
The conformance corpus does not currently include an adapter-revertibility fixture, but the operational test for any adapter is:
- Create a frame; dispatch some events; capture the frame's value as
V1. - Run more events; the container now holds
V2. - Call
(replace-container! container V1). - Re-read everything that
subscribe-container/make-derived-value/ views can see. - The observable behaviour MUST equal what step 1 produced.
If any value differs, the adapter is holding state outside the frame value — a revertibility violation.
Cross-reference: 000 §Frame state revertibility names the goal; this section locks the adapter-contract obligation that follows from it.
Source-coord annotation (mandatory; rf2-z7f7 / rf2-z9n1)¶
Every adapter MUST inject data-rf2-source-coord="<ns>:<sym>:<line>:<col>" on the rendered root DOM element of each registered view. The annotation is a normative entry on the adapter contract — devtools and pair-shaped tools (re-frame-pair, re-frame-10x, IDE jump-to-source per Tool-Pair §Source-mapping UI clicks back to code) consume it to map a clicked DOM node back to the reg-view call site. Without this annotation an adapter is non-conformant.
Capture mechanism¶
Source coordinates are captured at reg-view macro-expansion time from (meta &form) (:line, :column) and the compile-time *ns* / *file* (per Spec 001 §Source-coordinate capture). The macro stamps them onto the registry slot's metadata; the adapter reads them at render time when wiring the wrapper that produces the annotated DOM element. No runtime cost in the hot path: the coord string is computed once at registration time, then merged into attrs each render.
Attribute value format¶
The attribute value is a colon-separated four-segment string — the committed public contract :rf/source-coord-attr per Spec-Schemas:
<ns>is the keyword id's namespace — typically(namespace (registry-id)).<sym>is the keyword id's name —(name (registry-id)). Note this is the registry handler-id, not a file path.<line>is the integer source line;?when not captured.<col>is the integer source column;?when not captured.
A registration that bypassed the macro path (programmatic reg-view* with no captured coords) still annotates with <ns>:<sym>:?:? — degrading gracefully so pair tools can still resolve <ns>/<sym> via the registrar's :rf/id lookup. To recover the registration's full source-coord shape (including :file), pair tools follow up with (rf/handler-meta :view <handler-id>) which returns :rf/source-coord-meta per Spec-Schemas — :file is not encoded in the attribute string.
Production elision (mandatory)¶
The annotation site MUST sit inside (when interop/debug-enabled? ...) (the CLJS mirror of goog.DEBUG). Production builds (:advanced + goog.DEBUG=false) MUST NOT emit the attribute — the entire injection branch dead-code-eliminates so the literal data-rf2-source-coord string fragment does not appear in the bundle. Per Spec 009 §Production builds, the elision is verified by a grep against the production bundle (scripts/check-elision.cjs); the data-rf2-source-coord sentinel is part of the standard sentinel set.
Documented exemption: non-DOM roots¶
A registered view whose root element is one of:
- a React Fragment (
:<>), - a host-component head (
:>in Reagent — the React-interop marker), - a function/component head (e.g. another reg-view'd component),
…is exempt from the annotation. The adapter MUST emit a one-shot warning per id (so the developer learns the pair-tool footgun without spamming the console on re-render) and MUST NOT inject the attribute in these cases. Pair tools fall back to (rf/handler-meta :view id) for these nodes — the registry slot still carries the captured :ns / :line / :file; only the DOM-node-level mapping is skipped.
The exemption is principled: a Fragment has no DOM element to annotate, and a [:> Cmp …] interop call hands the props map straight through to React's component (which may not be a DOM-tag, may not accept arbitrary HTML attributes, and certainly should not have framework-derived strings inserted into it). Annotating these would either be a no-op (Fragment) or risk mutating semantics (interop).
Form-2 handling¶
When a registered view's render-fn returns a fn (Reagent's Form-2 closure shape per Spec 004 §Form-2), the adapter wraps the returned fn so the inner-fn's hiccup output is annotated on the next call. Annotation lands on the eventual rendered DOM root, not on the outer fn (which is not a DOM element).
Cross-host¶
Headless test adapters (no DOM) are exempt. Every in-scope React-binding adapter MUST honour this contract: the CLJS reference (Reagent, UIx [rf2-3yij], Helix [rf2-2qit]) and every JS-cross-compile-language port (TypeScript-React, Feliz / Fable.React, scalajs-react / Slinky, React.Basic, kotlin-react, ReasonReact / Melange-React). The JVM SSR emitter is the server-side equivalent — it injects the same attribute when emitting HTML for a registered view, so server-rendered pages carry the annotation too.
Source-coord stamping for state machines (rf2-8bp3)¶
The view-side annotation above is one half of the tool-pair source-mapping contract. The other half is the spec-side stamping for state machines: per Spec 005 §Source-coord stamping, the reg-machine macro walks its literal spec form at expansion time and attaches a :rf.machine/source-coords index keyed by spec-path tuples ([:guards :form-valid?], [:states :idle :on :submit], etc.). Pair tools that surface a "click on a transition's call site" gesture read the index back via (:rf.machine/source-coords (rf/machine-meta machine-id)) — symmetric to how they consume data-rf2-source-coord for views.
Both surfaces share the production-elision contract: the stamping branch is gated on interop/debug-enabled?, so under :advanced + goog.DEBUG=false the closure compiler folds it away. The rf.machine/source-coords keyword is part of the standard scripts/check-elision.cjs sentinel set (verified ABSENT in the production bundle, PRESENT in the control bundle).
Subscription cache — contract and operational semantics¶
A subscription's value lives in the per-frame sub-cache. This section defines the contract: the cache shape, the lookup algorithm, the invalidation algorithm, the ref-counting and disposal rules, the layer-1/2/3 sub semantics, and the lifetime contract that ties them together. The contract is host-agnostic; the Reagent reference adapter §Sub-cache wiring shows the CLJS realisation.
v1 reference. v1's
re-frame.subsnamespace already implements most of this — the invalidation algorithm, the cache de-duplication, the disposal-on-no-readers behaviour. What is new in re-frame2: the cache is per-frame (v1 has one global cache); disposal-on-frame-destroy is a contract, not an implementation detail; the layer-1/2/3 framing is named explicitly so non-CLJS implementors can satisfy the contract without leaning on Reagent's reaction machinery.
Cache shape¶
Each frame holds one sub-cache, keyed by [query-vector]:
;; Per-frame sub-cache, conceptual shape.
;; In CLJS the values are Reagent Reactions; on plain-atom hosts they are
;; thunks that recompute on deref. The shape is the same.
{[query-vector]
{:value v ;; current cached value
:derived-container c ;; substrate-specific container (per [§make-derived-value])
:inputs [[q1] [q2]] ;; resolved :<- chain (vector of query-vectors)
:ref-count n ;; how many readers currently hold a reference
:on-dispose [...] ;; callbacks that fire when ref-count drops to 0
:pending-dispose <handle> ;; opaque timer-handle iff disposal is scheduled (rf2-s9dn)
:registered-at <ts>}} ;; for trace correlation
The cache is held inside the frame container (per 002 §What lives in a frame). Two frames running the same (rf/subscribe [:cart/total]) compute against their own app-dbs and cache against their own caches; isolation is automatic.
Lookup algorithm¶
Lookup [query-v] in frame F:
k ← cache-key(query-v)
If F.sub-cache[k] exists:
F.sub-cache[k].ref-count += 1
return F.sub-cache[k].derived-container
Otherwise (cache miss):
meta ← registrar.lookup(:sub, first(query-v))
inputs ← resolve-inputs(meta, F) ;; recurses for :<- inputs
body ← meta.fn
derived ← substrate.make-derived-value(
inputs.map(c → c.derived-container),
(in-vals) → body(in-vals, query-v))
F.sub-cache[k] ← {:value (read derived)
:derived-container derived
:inputs inputs
:ref-count 1
:on-dispose [(fn [] (dispose-cache-slot! F k))]}
trace! :sub/registered {:query-v query-v :frame F.id}
return derived
Two properties this guarantees:
- De-duplication. Concurrent equal subscriptions share one cached computation. The cache key is the query-vector itself. v2 has a single disposal algorithm (deferred ref-counting; see §Reference counting and disposal); the v1-era composite key with
:re-frame/qand:re-frame/lifecycleis gone (rf2-7cb2 / rf2-s9dn — there-frame.alphanamespace and its lifecycle policies were dissolved before v1 ship). - Layer-1/2/3 chaining. A layer-2 sub's
:<-inputs are themselves resolved via this same lookup, recursively. The recursion terminates at layer-1 subs whose inputs are not other subs but readers overapp-dbdirectly.
Invalidation algorithm¶
The contract:
A subscription's cached value is invalidated only when an input the subscription depends on changes value (by
=equality).
The algorithm, host-agnostic:
On replace-container!(F.app-db, new-db): ;; called from drain loop step 2
;; Phase 1: layer-1 subs (those whose inputs are app-db readers).
For each k → entry in F.sub-cache where entry is layer-1:
new-val ← (entry.body new-db query-v)
If new-val = entry.value: ;; value-equal: keep cache
no-op
Else:
entry.value ← new-val
mark-dirty entry
trace! :sub/recomputed {:query-v k :frame F.id}
;; Phase 2: layer-2+ subs cascade in topological order.
For each k → entry in F.sub-cache where entry is layer-2+:
If any input in entry.inputs is marked-dirty:
new-val ← (entry.body (read-inputs entry.inputs) query-v)
If new-val = entry.value:
no-op
Else:
entry.value ← new-val
mark-dirty entry
trace! :sub/recomputed {:query-v k :frame F.id}
;; Phase 3: notify subscribers (views, tools).
For each entry that is marked-dirty:
notify each registered subscriber
Three load-bearing properties:
- No path-overlap means no recompute. A
:cart/totalsub depending on[:cart :items]does not recompute when:user-profilechanges. (How the implementation knows:=-equality on the input value. If the input is value-equal, the sub stays cached.) - Value-equal means no propagation. A no-op
(assoc db :x (:x db))produces a=-equalapp-db; no sub recomputes; no view re-renders. - Topological cascade. Layer-2 subs see the new layer-1 values when they recompute. Layer-3 subs see new layer-2 values. The cascade respects the static
:<-topology recorded during registration.
Reagent realises this automatically: each Reaction re-runs only when its derefs change by =; the reactive graph is built from the :<- chain. Non-CLJS implementations (or the plain-atom adapter) must satisfy the contract explicitly — Phase 1 / Phase 2 / Phase 3 above is the fallback algorithm.
Layer-1, layer-2, layer-3 sub semantics¶
The terminology comes from re-frame v1; the semantics carry over.
| Layer | Inputs | Example | Recompute trigger |
|---|---|---|---|
| Layer-1 | Reads app-db directly |
(reg-sub :user (fn [db _] (:user db))) |
The path it reads from app-db changes by =. |
| Layer-2 | Reads other subs via :<- |
(reg-sub :user-name :<- [:user] (fn [u _] (:name u))) |
Any input sub's value changes by =. |
| Layer-3 | Reads other subs via :<-, where one or more inputs are themselves layer-2 |
(reg-sub :user-greeting :<- [:user-name] :<- [:locale] (fn [...] ...)) |
Any input sub's value changes by =. |
Layers ≥ 3 are conventionally just "layer-2+" — the algorithm treats them all the same. The distinction matters for understanding the cascade order (layer-1 settles before layer-2, layer-2 before layer-3) but not for the implementation, which uses :<- chain depth implicitly via topological iteration.
Reference counting and disposal¶
The cache is not strong-referenced from the frame for the lifetime of the frame; entries dispose when their last reader goes away. The disposal algorithm is deferred ref-counting with a grace-period — a single algorithm. There are no pluggable lifecycle policies; the v1 alpha namespace's :safe, :no-cache, :reactive, and :forever lifecycles are not part of v2 (rf2-7cb2 / rf2-s9dn).
When the last subscriber drops, the entry is scheduled for disposal after the configured grace-period elapses. If a new subscriber arrives within that window, the scheduled disposal is cancelled and the cached value is reused.
On subscriber detach (view unmounts, tool disconnects):
entry.ref-count -= 1
If entry.ref-count == 0:
handle ← schedule-after grace-period-ms:
(when entry.ref-count == 0:
for each dispose-fn in entry.on-dispose: dispose-fn()
F.sub-cache.dissoc(k)
trace! :sub/disposed {:query-v k :frame F.id})
entry.pending-dispose ← handle
On subscriber attach (cache HIT; the slot already exists):
entry.ref-count += 1
if entry.pending-dispose is not nil:
cancel entry.pending-dispose
entry.pending-dispose ← nil
Disposal guarantees¶
- Zero-subscriber → grace-period elapses → disposed. When
ref-countreaches 0 and no resubscribe arrives withingrace-period-ms, the on-dispose callbacks fire, the cache slot is removed, and a:sub/disposedtrace event is emitted. - Resubscribe within grace-period → disposal cancelled, value reused. If a new subscriber arrives before the timer fires, the timer is cancelled and the existing reaction (and its cached value) is returned. The new subscriber observes no recomputation; the underlying substrate-specific container is the same one previously cached.
- Synchronous disposal when
grace-period-ms = 0. Setting the grace-period to 0 yields the v1-style "ref-count → 0 → dispose immediately" semantic. Useful for tests, REPL sessions, and any context that wants deterministic teardown without timer-driven races. - Hot-reload preserves the contract. Re-registering a sub disposes every cached slot for that query (regardless of ref-count) and cancels any pending grace-period timers — the next subscribe builds afresh against the new body.
- Frame teardown preserves the contract. Destroying a frame disposes every cached slot and cancels every pending grace-period timer; see §Lifetime contract — frame disposal.
The grace-period parameter¶
The grace-period is a per-runtime configuration knob:
| Knob | Default | Configure |
|---|---|---|
grace-period-ms |
50ms | (rf/configure :sub-cache {:grace-period-ms N}) |
The default of 50ms is chosen empirically: long enough to bridge React re-render churn (where a Reagent component briefly unmounts then re-renders with the same subscription), short enough that genuine disposal is observable promptly and memory does not accumulate under load. Implementations targeting non-React substrates may pick a different default but should document it.
N is a non-negative integer; 0 selects synchronous disposal.
On-dispose hooks¶
The on-dispose hook lets the adapter release substrate-specific resources (a Reagent reaction; a JS-cross-compile-port atom-shape's listener entry / derived-value memo) before the cache slot is removed. Hooks fire after the grace-period elapses (or synchronously when grace-period-ms = 0). The CLJS reference uses interop/add-on-dispose! per the Reagent realisation in §Sub-cache wiring.
Three subtleties¶
- A sub can become live again after disposal. A view unmounts; if no resubscribe arrives within the grace-period, the slot disposes. Later, the same view re-mounts (cache miss, fresh computation). This is correct — the cache is performance, not state. The recomputed value will equal what was disposed (same body, same
app-db); no observable difference. - Eager subs. A future
:reg-sub-by-path(post-v1) might keep its cache slot live regardless of ref-count, for performance. v1 has no eager subs; if added, the contract surface isentry.eager? = trueand the disposal path skips the slot. - Disposal cascades. When a layer-2 sub disposes, its layer-1 inputs lose one reader each; if they were held only by that layer-2 sub, they enter their own grace-period. Cascading disposals each pay the grace-period independently, but the timers run concurrently — total wall-clock disposal time is one grace-period regardless of chain depth.
(subscribe-value query-v) → value / (subscribe-value frame-id query-v) → value¶
The one-shot, non-reactive read of a subscription's current value. subscribe-value is the canonical end-user surface for "give me the current value of this sub right now, and don't retain a reference on my behalf." It is the right call from event handlers, machine actions, REPL sessions, SSR builders, and any non-reactive consumer; views and tools that want to track future changes use subscribe instead.
(subscribe-value query-v) ;; → value (uses the resolved current frame)
(subscribe-value frame-id query-v) ;; → value (explicit-frame form)
Semantically, subscribe-value is subscribe + deref + immediate synchronous unsubscribe:
subscribe-value(frame-id, query-v):
r ← subscribe(frame-id, query-v) ;; cache hit OR miss; ref-count += 1
v ← deref r ;; current cached value
unsubscribe(frame-id, query-v, {:grace 0}) ;; ref-count -= 1; on 1→0, dispose synchronously
return v
Contract MUSTs.
- One-shot. Each call subscribes, derefs once, and unsubscribes. The caller does not receive a deref-able reaction; the returned value is a plain immutable value of whatever the sub computes.
- Non-reactive. The caller is not registered for re-render or change notification. A subsequent
app-dbmutation that would have invalidated the slot has no observable effect on the caller ofsubscribe-value— they got their value, they're done. - Synchronous teardown (per rf2-zmufj). The internal
unsubscriberuns with{:grace 0}so the one-shot read's whole lifetime — subscribe, deref, and (if this call drove the 1→0 transition) dispose — completes in the calling tick. The caller never observes a deferred-dispose timer firing aftersubscribe-valuehas already returned. A concurrent reactive subscriber (a view holdingsubscribeon the samequery-v) keeps the slot alive via ref-count;subscribe-value's decrement only triggers synchronous disposal when it owned the last reference. - Frame-resolution. The 1-arg form resolves the current frame via the resolution chain (dynamic-var tier, React-context tier when an adapter has registered the
:adapter/current-framelate-bind hook per §Frame-provider via React context,:rf/defaultfallback). The 2-arg form is explicit and bypasses the chain. - Missing frame is not an error.
subscribe-valueagainst a destroyed or never-created frame returnsnil(and emits the same:rf.warning/unknown-frametracesubscribedoes); it does NOT throw. - Missing sub is not an error. Per §What happens when a sub references an unknown sub, an unregistered
query-vemits:rf.error/no-such-sub(recovery:replaced-with-default) and yieldsnil;subscribe-valuepropagates thenil. - JVM-runnable.
subscribe-valueis part of the substrate-agnostic call-site surface; it works against the plain-atom adapter (no Reagent dependency). On the JVM, the deref step reads the substrate's container directly; tests, SSR builders, and headless tools rely on this.
Where it differs from compute-sub. compute-sub (per 008 §compute-sub algorithm) is a pure function over an explicit app-db value — it bypasses the cache entirely and runs the sub's body fresh. subscribe-value is cache-aware: it materialises the cache entry (cache hit reuses; cache miss populates briefly under the grace-period), then immediately drops its reference. Use compute-sub when you want to test a sub's body against a snapshot in isolation; use subscribe-value when you want what the running frame would see right now.
(unsubscribe query-v) → nil / (unsubscribe frame-id query-v) → nil¶
The explicit teardown of a subscribe call. unsubscribe decrements the cache entry's ref-count by 1; on the 1 → 0 transition, the cache slot is scheduled for disposal after the configured grace-period elapses (per §Reference counting and disposal). Reagent views auto-dispose via the reaction lifecycle and do not need to call unsubscribe explicitly; tests, REPL sessions, tools, and machine actions that subscribed imperatively are the call sites that need it.
(unsubscribe query-v) ;; → nil (uses the resolved current frame)
(unsubscribe frame-id query-v) ;; → nil (explicit-frame form)
(unsubscribe frame-id query-v {:grace 0}) ;; → nil (explicit-frame + opts; per rf2-zmufj)
Opts map (3-arity only). The optional opts map accepts:
| Key | Type | Effect |
|---|---|---|
:grace |
non-neg int | Override the configured grace-period-ms for this call only. {:grace 0} forces synchronous disposal on the 1→0 transition — used by subscribe-value's internal teardown (per rf2-zmufj). Absent → use the per-runtime configured grace. |
Contract MUSTs.
- Decrement, not destroy.
unsubscribedecrements the slot's ref-count by 1. The slot itself disposes only when ref-count reaches 0 and the grace-period elapses without a resubscribe (per §Reference counting and disposal). A caller that holds N concurrent subscriptions to the samequery-vmust callunsubscribeN times to fully release; each call decrements one share. - Pair with
subscribe. Everysubscribe(including thesubscribehalf ofsubscribe-value) increments the slot's ref-count by 1; everyunsubscribedecrements by 1. Imperative subscribers are responsible for the pairing; views and tools that hold reactions through the reaction lifecycle get the decrement automatically when the reaction disposes. - Idempotent past zero. Calling
unsubscribeafter ref-count has already reached 0 is a no-op — the count floors at 0, and an already-scheduled grace-period timer is not restarted. A secondunsubscribefrom the same path (cleanup hook +finallyblock both running) does not stack disposal timers or accidentally disposed-twice the slot. - No-op past disposal. Calling
unsubscribeafter the slot has already been disposed (the grace-period elapsed and the slot was removed fromF.sub-cache) is a no-op —unsubscribereturnsnilwithout trace emission. There is no "double-free" failure mode. - Missing frame is not an error.
unsubscribeagainst a destroyed or never-created frame returnsnil(and emits the same:rf.warning/unknown-frametracesubscribedoes); it does NOT throw. - Frame-resolution. The 1-arg form resolves the current frame via the resolution chain (dynamic-var tier, React-context tier when an adapter has registered the
:adapter/current-framelate-bind hook per §Frame-provider via React context,:rf/defaultfallback). The 2-arg and 3-arg forms are explicit. - Composes with grace-period reuse. When
unsubscribe(the no-opts form) triggers the 1 → 0 transition, the slot enters its grace-period rather than disposing immediately. Asubscribearriving within the window cancels the timer and reuses the cached value. The{:grace 0}opts form opts out of this — the slot disposes synchronously on the 1→0 transition; it is the pathsubscribe-valueuses internally (per rf2-zmufj).
Composability with subscribe-value. subscribe-value internally invokes subscribe then unsubscribe with {:grace 0} — the teardown is synchronous, not deferred (per rf2-zmufj). The user does NOT call unsubscribe for a subscribe-value call — the pairing is internal. Users only call unsubscribe for the subscribe calls they made themselves.
Why explicit teardown exists alongside the grace-period. The grace-period handles the automatic case: a view unmounts, the reaction disposes, the underlying unsubscribe fires from the reaction's on-dispose hook, the slot drains. Explicit unsubscribe is the imperative-callers' equivalent: tools, REPL sessions, machine actions, and tests that took out a subscription without an enclosing reaction lifecycle to manage it. The two paths funnel into the same ref-count decrement and the same grace-period scheduling — there is one disposal algorithm, two arrival surfaces.
Lifetime contract — frame disposal¶
When a frame is destroyed (per 002 §Destroy):
On destroy-frame F:
For each k → entry in F.sub-cache:
For each dispose-fn in entry.on-dispose:
dispose-fn()
F.sub-cache.clear()
trace! :sub-cache/cleared {:frame F.id}
Three contract guarantees this enforces:
- No leaks. Every cached substrate-specific resource (Reagent reaction; JS-cross-compile-port atom-shape's listener entry / derived-value memo) is released. Long-lived processes that create and destroy frames (test runs, SSR request handling) reach steady-state memory.
- No stale reads. After
destroy-frame, attempts to subscribe toFraise:rf.error/frame-destroyed. There is no path that returns a value from a destroyed frame's cache. - Adapter symmetry. The adapter's
dispose-adapter!(§Adapter disposal lifecycle) is the per-process counterpart; it disposes every frame's sub-cache as part of process teardown.
Cross-spec interactions¶
- Drain-loop integration (002 §Drain-loop pseudocode): invalidation fires once per
process-event!step 2, after the:dbwrite and before the:fxwalk. A handler can rely on subscriptions reflecting the newapp-dbfrom insidedo-fx. - Hot reload (001-Registration): re-registering a sub disposes the cache slot for that query (regardless of ref-count); next subscribe rebuilds with the new body. Tracked with the rest of hot-reload semantics in the bead-tracked work.
- Machine subscriptions (005 §Subscribing to machines via
sub-machine): a machine's snapshot lives at[:rf/machines <id>]and is read like any other slice ofapp-db;sub-machineis a thin convenience overreg-sub. Sub-cache invalidation works the same. clear-subis a registry-only operation (rf2-79tl):(clear-sub id)and(clear-sub)remove:subregistrations but leave already-materialised per-frame cache slots in place. Caching is governed by the disposal contract above (ref-count + grace-period, hot-reload eviction, frame-destroy eviction); cache eviction independent of those triggers isclear-subscription-cache!'s job. This split preserves v1's documented contract — see theclear-subdocstring's note: "Depending on the usecase, it may be necessary to callclear-subscription-cache!afterwards."
Per-host implementation notes¶
- CLJS-Reagent. Reagent's
Reactionhandles invalidation, ref-counting, and disposal automatically. Layer-1 reads viar/atomderef; layer-2+ build a graph of reactions; equality checks happen at each layer. The cache wraps Reagent's own machinery — see §Sub-cache wiring (Reagent realisation). - CLJS-headless / SSR. No caching.
compute-subis a pure function that runs the sub's body fresh every time it's called. Cheap because no SSR run does it twice. The contract above is satisfied trivially: no cached values means no invalidation question. - In-scope JS-cross-compile-language ports (TS-React / Fable / Scala.js / PureScript / Kotlin/JS / Melange / ReScript / Reason / Squint). Must satisfy the algorithm above explicitly — the per-port adapter implements layer-1/2/3 invalidation atop its host's React binding. The atom-shape's watch/listener machinery and any derived-value memoisation cooperate with React's
useSyncExternalStore-driven render scheduling to surface invalidation to views. Tools relying on the trace stream's:sub/recomputedevents depend on the equality-check-on-invalidation rule.
What happens when a sub references an unknown sub¶
A sub registered via :<- referencing an undefined input is an error:
(rf/reg-sub :cart/total
:<- [:cart/items] ;; OK
:<- [:nonexistent/data] ;; ❌ no :nonexistent/data registered
(fn [...] ...))
The behaviour is environment-specific:
- At registration time (when the macro runs), the runtime cannot fully validate
:<-— the input might be registered later in the load order. - At first use (when something tries to subscribe to
:cart/total), the runtime resolves all inputs. If any input is unregistered, the runtime emits a:rf.error/no-such-subtrace event (per 009 §Error contract) and returnsnilfor that input. Recovery::replaced-with-default.
The subscription's body still runs with nil substituted for the unresolved input. This is intentional: it keeps the trace stream readable (the agent sees one error event rather than a chain of cascading throws) and lets the caller handle the missing data gracefully if it can.
A related case is subscribe itself naming an unregistered sub-id — most often a boot-order or lazy-load race where the consumer subscribes before the registering namespace has loaded. The runtime emits the same :rf.error/no-such-sub trace event, returns a nil-yielding reaction (recovery :replaced-with-default), and does not populate the per-frame sub-cache. Skipping the cache on miss preserves the v1 semantic that a later registration is observed by the next subscribe — no stale nil-reaction lingers (rf2-l9u5).
CLJS reference: Reagent as default adapter¶
The CLJS reference ships two adapters across two Maven artefacts: the plain-atom (JVM/headless) adapter ships in the core artefact (day8/re-frame2); the Reagent adapter ships in its own sibling artefact (day8/re-frame2-reagent). Both implement the closed nine-fn contract above; the runtime picks per platform. UIx and Helix adapters ship as further sibling artefacts as they land. Per Conventions §Adapter shipping convention and rf2-0hxm.
This section is the bridging pseudocode for both. For each contract function, the pseudocode shows which Reagent (or, on the JVM, plain-Clojure) primitive realises it. An AI implementing the CLJS reference can lift this directly; non-CLJS implementors read it as one worked example of the contract.
Reading note. v1 of re-frame already implements most of these primitives (
re-frame.interop,re-frame.subs,re-frame.subs/cache-and-return,reagent.core/atom,reagent.ratom/make-reaction). The pseudocode below tracks v1's working code closely; what's new is the contract surface itself (the v1 code does not separate "core" from "adapter" — the substrate decoupling is the v2 work). Use v1 source as the implementation reference for everything below the contract line.
Per-contract-fn pseudocode¶
(ns re-frame.adapter.reagent
(:require [reagent.core :as r]
[reagent.ratom :as ratom]
[reagent.dom.client :as rdc]
[re-frame.frame-context :as fc] ;; the frame-keyword React Context
[re-frame.render.hiccup-to-html :as hiccup]
[re-frame.subs.cache :as sub-cache]))
;; -- 1. make-state-container ------------------------------------------------
;; A Reagent ratom holds the frame's app-db. r/atom is the only mutation point;
;; reagent.ratom captures all the change-tracking semantics for free.
(defn make-state-container [initial-value]
(r/atom initial-value)) ;; → IReactiveAtom
;; -- 2. read-container ------------------------------------------------------
;; Plain deref. Outside a reactive context this does not register a dependency;
;; inside one, Reagent automatically wires the dependency edge.
(defn read-container [container]
@container)
;; -- 3. replace-container! --------------------------------------------------
;; The single mutation primitive. Reagent's reset! schedules dependent
;; reactions; the core's invalidation hook runs synchronously *before* the
;; first :fx entry per [002 §:fx ordering] — Reagent's batching cooperates
;; because reactions only re-fire on next deref or the next animation frame.
(defn replace-container! [container new-value]
(reset! container new-value)
nil)
;; -- 4. subscribe-container -------------------------------------------------
;; Reagent itself drives invalidation through reactions; the explicit
;; subscribe-container surface exists for non-reactive substrates and tools
;; that want raw change events. Implemented via add-watch on the underlying
;; ratom — observably equivalent across substrates per [§Operational semantics].
(defn subscribe-container [container on-change]
(let [k (gensym "rf-sub-")]
(add-watch container k (fn [_ _ prev nu] (on-change prev nu)))
(fn unsubscribe [] (remove-watch container k))))
;; -- 5. make-derived-value --------------------------------------------------
;; reagent.ratom/make-reaction wraps a compute-fn in a Reaction that
;; (a) re-runs only when its derefs change by =, (b) caches the result,
;; (c) participates in the reactive graph so dependent views auto-rerender.
;; Equality-on-=-of-inputs is the rule the sub-cache invariant relies on.
(defn make-derived-value [source-containers compute-fn]
(ratom/make-reaction
(fn [] (apply compute-fn (map deref source-containers)))))
;; -- 6. render --------------------------------------------------------------
;; React 18+ takes a `Root` (from `reagent.dom.client/create-root`) — NOT
;; a raw DOM element. The non-hydrate path creates the Root then renders
;; into it; the hydrate path's `hydrate-root` returns its own Root. The
;; returned unmount-fn closes over the Root so the runtime can release it
;; without re-consulting the DOM element. Idempotent: calling unmount
;; twice is a no-op (rf2-fn5rk).
(defn render [render-tree mount-point opts]
(let [hydrate? (boolean (:hydrate? opts))]
(if hydrate?
(let [root (rdc/hydrate-root mount-point render-tree)]
(fn unmount [] (rdc/unmount root)))
(let [root (rdc/create-root mount-point)]
(rdc/render root render-tree)
(fn unmount [] (rdc/unmount root))))))
;; -- 7. render-to-string ----------------------------------------------------
;; Pure JVM-runnable walk over the hiccup render-tree per [011-SSR
;; §The render-tree → HTML emitter (CLJS reference)]. No Reagent, no React;
;; the same pure emitter the plain-atom adapter uses.
(defn render-to-string [render-tree opts]
(hiccup/emit render-tree opts))
;; -- 8. register-context-provider -------------------------------------------
;; Returns the frame-provider component (a React Context Provider whose value
;; is the frame keyword, never the frame record — see [002 §Reading the frame
;; from React context]). Re-registering a frame is picked up on next render
;; because the context value is a keyword resolved against the registry.
(defn register-context-provider [frame-keyword]
(fc/provider frame-keyword))
;; -- 9. dispose-adapter! ----------------------------------------------------
;; Total disposal. Order matters: tear down sub-cache reactions first (so
;; nothing observes the ratom going away), then the frame-providers, then
;; release the Reagent reaction caches Reagent itself owns.
(defn dispose-adapter! []
(sub-cache/dispose-all!) ;; per-frame sub-cache disposal
(fc/dispose-providers!) ;; release any cached providers
(ratom/flush!) ;; drain Reagent's pending queue
nil)
Sub-cache wiring (Reagent realisation)¶
The per-frame sub-cache (§Subscription cache invalidation) is the bridge between reg-sub and a Reagent reaction. v1's working algorithm in re-frame.subs is the reference. The CLJS-reference v2 wiring:
;; The cache is per-frame: keyed by [query-vector], stored on the frame.
;; Each entry points to a Reagent Reaction that wraps the sub's body.
(defn subscribe [frame query-v]
(let [k (cache-key query-v)
cache (:sub-cache frame)]
(or (get @cache k) ;; cache hit: existing reaction
(let [r (compute-and-cache frame query-v)] ;; cache miss: build chain
r))))
(defn- compute-and-cache [frame query-v]
(let [meta (registrar/lookup :sub (first query-v))
inputs (mapv (fn [input-q] (subscribe frame input-q)) ;; recurse for :<-
(:input-signals meta))
body-fn (:fn meta)
;; The Reaction wraps the sub body. Reagent re-runs body-fn only when
;; one of its derefs (the inputs) changes by =. This is the layer-1/2/3
;; sub semantics from v1 — same algorithm, now scoped per frame.
r (ratom/make-reaction
(fn [] (apply body-fn (conj (mapv deref inputs) query-v))))]
(swap! (:sub-cache frame) assoc k r)
;; When this reaction's last reader disposes, GC the cache slot.
(interop/add-on-dispose! r
(fn []
(swap! (:sub-cache frame)
(fn [cm] (if (identical? r (get cm k)) (dissoc cm k) cm)))))
r))
(defn dispose-frame-subs! [frame]
(let [cache (:sub-cache frame)]
(doseq [[_ r] @cache] (interop/dispose! r))
(reset! cache {})))
What this gives:
- Hot reload (001-Registration, bead-tracked): re-registering a sub disposes the cache slot for that query; next subscribe rebuilds with the new body.
- Frame teardown (002 §Destroy):
dispose-frame-subs!fires from the frame's lifecycle hook; every reaction is disposed; no leaks. - Layer-1/2/3 semantics: the recursion in
compute-and-cachebuilds a chain. A layer-2 sub's reaction:<-s into a layer-1 sub's reaction; Reagent's tracking propagates=-equality up the chain.
Frame-provider via React context¶
register-context-provider returns the frame-provider component. The CLJS implementation lives in re-frame.frame-context; the design is owned by 002 §Reading the frame from React context — this section names the adapter-side hook into it.
;; The single React Context. Default value is :rf/default — the Spec
;; guarantees this frame always exists per [002 §:rf/default].
(defonce ^:private frame-context
(.createContext js/React :rf/default))
(defn provider []
;; Returns a Reagent component the user includes in their tree:
;; [provider :auth
;; [some-view ...]]
;; The Provider's value is the keyword, never the frame record;
;; consumers resolve the keyword against the global frame registry on
;; every read, so re-registering frames is picked up automatically.
;; 0-arity (rf2-4y60): a single built component services every frame —
;; the frame keyword lives in the Provider's :value at render time, not
;; in a build-time closure.
(fn [frame-kw & children]
;; `:r>` bypasses Reagent's `convert-prop-value` so the keyword's
;; namespace survives the React-context round trip — see Spec 002
;; §`frame-provider` for the prop-conversion rationale.
(into [:r> (.-Provider frame-context) #js {:value frame-kw}] children)))
The read-frame-from-context lookup chain (*current-frame* dynamic var → React context → :rf/default) is documented in 002 §Reading the frame from React context.
Frame propagation across React-binding ports¶
The CLJS-reference shape. The shared re-frame.adapter.context/frame-context primitive lives in the core artefact (day8/re-frame2) — a CLJS-only file that the JVM build does not load (per 000 §C2 Cross-platform). Every React-shaped CLJS adapter (Reagent, UIx, Helix) consumes it; mixed-substrate apps therefore compose providers across substrates rather than silos.
Per-language ports realise the same contract via the host React binding's own context primitive. The mechanism varies by binding; the contract — a context value carrying the current frame-id keyword; views read it via the host React binding's hooks-equivalent — does not. Per-port realisations:
| Port | React-context primitive | Hooks-equivalent read |
|---|---|---|
| TypeScript-React | React.createContext<FrameId>(":rf/default") |
useContext(FrameContext) |
| Fable (F#) — Feliz / Fable.React | React.createContext |
React.useContext |
| Scala.js — scalajs-react / Slinky | React.createContext (binding-shaped) |
useContext hook |
PureScript — React.Basic.Hooks |
Hooks.createContext |
Hooks.useContext |
| Kotlin/JS — kotlin-react | createContext |
useContext |
| Melange / ReScript / Reason — ReasonReact | React.createContext |
React.useContext |
| Squint | reuses the CLJS-Reagent shape (Squint preserves Clojure keywords) | same as CLJS |
The spec does not prescribe JS implementation details (_currentValue reads, class-component :contextType shapes, prop-stringification quirks) — those are port discretion. What the spec requires is the contract: the provider's value is a frame-id keyword (or the host's identity-primitive equivalent), and the views inside the provider's subtree resolve subscriptions / dispatches against that frame.
Adapter responsibility — :adapter/current-frame late-bind hook (rf2-d4sf). Each React-shaped substrate adapter (Reagent, UIx, Helix) MUST register its React-context-aware current-frame impl through the :adapter/current-frame late-bind hook at namespace-load time. re-frame.subs/subscribe, re-frame.subs/subscribe-value, re-frame.subs/unsubscribe, and the dispatch envelope's :frame default consult the hook on CLJS so the React-context tier of the resolution chain is live rather than dead code. Without the registration the call sites fall back to re-frame.frame/current-frame (dynamic-var tier and :rf/default only); the React-context tier silently no-ops, so a (rf/subscribe ...) under a non-default frame-provider would route to :rf/default regardless of what the provider named. Hook signature: (fn [] frame-id-keyword).
The impl is substrate-specific:
- Reagent registers
re-frame.views/current-frame, which uses Reagent's class-component(.-context cmp)path. The path is intentionally narrow — it surfaces context only to components whose:contextTypematchesframe-context(i.e. components registered viareg-view*). Plain Reagent fns route to:rf/default, which is what the:rf.warning/plain-fn-under-non-default-frame-oncewarning targets. - UIx / Helix register
re-frame.adapter.context/function-component-current-frame, which reads_currentValuedirectly off the shared context object. Function components have no(.-context cmp)slot, so_currentValueis the substrate-portable path; UIx'suse-contextand Helix'suse-contextare sugar over the same read.
Both impls share the dynamic-var tier (re-frame.frame/*current-frame*, set by with-frame / bound-fn) and the :rf/default tier; only the middle (React-context) tier differs. The canonical user-facing surface (rf/frame-provider) mounts the Provider via Reagent's :r> interop head so the props map bypasses reagent.impl.template/convert-prop-value — the :value keyword (namespace and all) reaches React unchanged. As defensive cover, both impls round-trip the prop-stringified shape via re-frame.adapter.context/coerce-context-value so a raw-hiccup [:> Provider {:value :tenant}] mount (not via rf/frame-provider) is still observed correctly by every substrate. The helper is lossy for namespaced keywords on raw-hiccup mounts under the classic adapter ((name :foo/bar) is "bar"); raw-hiccup mounts that need namespaced frame-ids should switch to rf/frame-provider or re-frame.adapter.context/provider-element.
Plain-fn-under-non-default-frame warning. A plain Reagent fn (not registered via reg-view) cannot subscribe to the closest enclosing frame-provider because plain fns lack the ^{:context-type frame-context} metadata reg-view attaches. When such a plain fn calls (rf/subscribe ...) and the React-context tier resolves to a non-default frame, the runtime emits :rf.warning/plain-fn-under-non-default-frame-once (per Conventions §Reserved namespaces) — once per (fn, frame) pair, not per render — pointing the user at reg-view or with-frame.
The detection sits in subscribe: if (reagent.core/current-component) returns a component whose contextType does not match frame-context, the dynamic-var tier is checked; if neither names a non-default frame, no warning fires; if the closest enclosing provider names a non-default frame and *current-frame* is unset, the warning fires.
Plain-atom adapter (JVM, SSR, headless)¶
The plain-atom adapter is the same nine-fn contract realised against clojure.core/atom instead of Reagent. It is what runs on the JVM (per 000 §C2. Cross-platform: JVM interop preserved) and what SSR and headless tests use (§SSR-specific behaviour, 008-Testing).
How it differs from the Reagent adapter:
(ns re-frame.substrate.plain-atom
(:require [re-frame.render.hiccup-to-html :as hiccup]))
(defn make-state-container [initial-value]
(atom initial-value)) ;; clojure.core/atom; reactivity via add-watch (see subscribe-container)
(defn read-container [container] @container)
(defn replace-container! [container nu] (reset! container nu) nil)
(defn subscribe-container [container on-change]
(let [k (gensym "rf-sub-")]
(add-watch container k (fn [_ _ prev nu] (on-change prev nu)))
(fn [] (remove-watch container k))))
;; No Reaction — derived values are computed on every read. SSR runs each
;; sub once, so caching wouldn't help. Tests that want caching swap in the
;; Reagent adapter via the reagent-cljs-jvm interop layer.
(defn make-derived-value [source-containers compute-fn]
(reify clojure.lang.IDeref
(deref [_] (apply compute-fn (map deref source-containers)))))
;; render is not used on the JVM — render-to-string is the only path.
(defn render [_ _ _]
(throw (ex-info "render not supported on plain-atom adapter; use render-to-string"
{:rf.error :rf.error/render-on-headless-adapter})))
(defn render-to-string [render-tree opts]
(hiccup/emit render-tree opts)) ;; same emitter as Reagent
;; No React, no context concept. The pattern's explicit-frame addressing
;; (per [002 §Routing]) handles frame routing without a context provider.
(defn register-context-provider [_frame-keyword]
nil) ;; optional fn, returning nil is the spec'd absence
(defn dispose-adapter! []
;; Watch handles are GC'd with their atoms; nothing else to clean up.
nil)
Three design decisions worth naming:
- No caching for derived values. SSR runs each sub at most a handful of times per request; caching would add complexity for negligible gain. Tests that want repeatable performance characteristics can swap in the Reagent adapter on the JVM.
renderthrows. SSR usesrender-to-stringexclusively; callingrenderon the JVM is a programmer error worth surfacing loudly. The conformance fixture for:rf.error/render-on-headless-adapterpins this.- No context provider. The pattern-level contract is explicit-frame addressing. Hosts without a context concept fall back to threading the frame as an argument; the headless adapter is the simplest such host.
The plain-atom adapter is trivially revertibility-compliant (§Reference-adapter compliance) because it holds no state outside the container.
Adapter selection at boot¶
Per rf2-agql (replaces rf2-84po; resolves rf2-4cb6) (rf/init! adapter-map) requires the consumer to pass an adapter spec map explicitly. Each adapter namespace exports an adapter Var (the spec map); the consumer requires the namespace and passes the Var:
;; Reagent (CLJS, day8/re-frame2-reagent):
(require '[re-frame.core :as rf]
'[re-frame.adapter.reagent :as reagent])
(rf/init! reagent/adapter)
;; UIx (CLJS, day8/re-frame2-uix):
(require '[re-frame.core :as rf]
'[re-frame.adapter.uix :as uix])
(rf/init! uix/adapter)
;; Helix (CLJS, day8/re-frame2-helix):
(require '[re-frame.core :as rf]
'[re-frame.adapter.helix :as helix])
(rf/init! helix/adapter)
;; SSR / JVM (day8/re-frame2-ssr):
(require '[re-frame.core :as rf]
'[re-frame.ssr :as ssr])
(rf/init! ssr/adapter)
;; Headless / plain-atom (re-frame.substrate.plain-atom in core):
(require '[re-frame.core :as rf]
'[re-frame.substrate.plain-atom :as plain-atom])
(rf/init! plain-atom/adapter)
(rf/init! …) accepts exactly one argument shape:
(rf/init! adapter-map)— install the literal adapter spec.
Calling (rf/init!) with no args raises a language-level ArityException at the call site (per rf2-3ubmv — the no-arg arity was cut from the fn defn entirely, so the mistake surfaces at compile/load time rather than at runtime). Calling (rf/init! :reagent) (or any non-map value) and (rf/init! nil) raise :rf.error/no-adapter-specified at runtime — there is no default-adapter registry and no keyword-to-adapter lookup table. The runtime error message points the consumer at the adapter-ns + adapter-Var pattern.
No registry, no implicit defaults. The previous design (rf2-84po) shipped a default-adapter registry populated by adapter ns-load side-effects so that (rf/init!) with no args could resolve the only registered candidate. rf2-agql drops the registry entirely. Two reasons:
- Explicit > implicit at the call site. Reading any app's
runfunction tells you exactly which adapter is in use, with no need to chase ns-load side-effects through the require graph. - Bundle-size. A registry is bundle weight even when unused. Under rf2-agql, an app that requires only the adapter it needs ships only that adapter's code; the registry-and-resolver paths are gone.
A mixed-substrate app — say a build that imports both re-frame.adapter.reagent (for stories) and re-frame.adapter.uix (for production views) — picks the active adapter by passing the right Var to init!. There is no multi-adapter ambiguity to resolve at boot: only one adapter is ever installed.
install-adapter! is called once per process by init!'s implementation. Subsequent calls without an intervening dispose-adapter! raise :rf.error/adapter-already-installed (§Single adapter per process).
The CLJS adapter namespaces (Reagent, UIx, Helix) and the SSR namespace each export their adapter Var; the contract surface is the same nine-fn map (see §The adapter API contract above). The plain-atom adapter in re-frame.substrate.plain-atom is reachable on both JVM and CLJS — useful for headless tests on either platform.
CLJS reference: UIx as alternative substrate (rf2-3yij)¶
The UIx adapter ships in day8/re-frame2-uix and implements the same nine-fn contract as the Reagent adapter — same observable behaviour for events, subs, effects; different rendering substrate for views.
Per rf2-3yij the locked decisions (2026-05-09) are:
- Hook naming. The substrate's subscription surface is
use-subscribe, matching the React/UIx idiom. Symmetric ergonomics to Reagent's(rf/subscribe ...)deref shape; asymmetric naming because hooks live in hook-named space. - Frame propagation. Both the UIx and Reagent adapters read the same React Context object — factored out of
re-frame.viewsintore-frame.adapter.context(CLJS-only file in core). A future mixed-substrate app's frame-provider chain therefore composes across substrates rather than living in per-adapter silos. - Auto-injection. None for UIx. Components call
(use-subscribe [:foo])and(rf/dispatcher)directly — there is no UIx-side analogue toreg-view'sdispatch/subscribelexical bindings. The hook surface is the canonical UIx access path. reg-viewmacro scope.reg-viewstays Reagent-only (auto-defs the Var, auto-injects the lexicaldispatch/subscribe, threads source-coords through Reagent's:contextTypemachinery). UIx users register views viareg-view*(the plain-fn surface inre-frame.core); source-coord stamping for UIx-rendered roots happens at the adapter's render-time wrapper, not at registration time.- Source-coord DOM annotation. The UIx adapter wraps user components in a thin layer that calls
React.cloneElementto adddata-rf2-source-coord="<ns>:<sym>:<line>:<col>"on the rendered root DOM element wheninterop/debug-enabled?is true. Production-elision contract per rf2-z7f7: under:advanced+goog.DEBUG=falsethe entire wrapper branch DCEs and the literaldata-rf2-source-coordstring fragment is absent from the bundle. Fragments and non-DOM roots are exempt with the standard one-shot warning per id. - Render flush for tests. The adapter exposes
flush-views!wrapping React'sact(). Tests dispatching against a UIx-mounted tree call(flush-views!)after a dispatch to settle pending React effects before reading the DOM. - Smoke-test example set. counter + login (under
examples/uix/counter_uix/andexamples/uix/login_uix/). Realworld is skipped per Decision 7 — heavy with Reagent-flavoured idioms; deferred until a UIx user wants it. - UIx version target. UIx 2.x (hooks-based). UIx 1.x back-compat is explicitly out of scope.
The CLJS-reference code follows the same per-contract-fn shape as the Reagent adapter; the differences are at the React layer:
make-state-containerreturns aclojure.core/atomrather than a Reagentr/atom— UIx has no built-in reactive atom primitive. View-side reactivity flows throughuseSyncExternalStoreinuse-subscriberather than through Reagent reactions.make-derived-valuereturns anIDeref+IWatchablewrapper that recomputes on deref and broadcasts changes via the source containers' watch machinery. Equality-on-= invariants ride on the core's sub-cache (Spec 006 §Invalidation algorithm), not on the substrate's caching.renderwrapsreact-dom/client.createRoot+root.render; the unmount-fn callsroot.unmount().register-context-providerreturns a UIxdefuicomponent reading the sharedframe-contextviause-context.
Every other adapter primitive (read, replace, subscribe-container, dispose) is structurally identical to the Reagent adapter's — the contract is genuinely substrate-agnostic.
CLJS reference: Helix as alternative substrate (rf2-2qit)¶
The Helix adapter ships in day8/re-frame2-helix and implements the same nine-fn contract as the Reagent and UIx adapters — same observable behaviour for events, subs, effects; different rendering substrate for views. Helix occupies the minimal-React-wrapper niche: it is structurally similar to UIx (React + hooks; no reactive-atom primitive) but ships a smaller surface and does not auto-instrument hooks.
Per rf2-2qit the locked decisions (2026-05-10) transfer one-for-one from rf2-3yij — the React + hooks substrate model is the same:
- Hook naming.
use-subscribe(matches the React/Helix idiom). - Frame propagation. Reads the same React Context object the Reagent and UIx adapters consume (
re-frame.adapter.context/frame-contextin core). - Auto-injection. None. Components call
(use-subscribe [:foo])and(rf/dispatcher)directly. reg-viewmacro scope. Stays Reagent-only; Helix users register registry-keyed views viareg-view*(the plain-fn surface) when they need it. Most Helix components are baredefncand don't need registry addressing.- Source-coord DOM annotation. The Helix adapter wraps user components in a thin layer that calls
React.cloneElementto adddata-rf2-source-coord="<ns>:<sym>:<line>:<col>"on the rendered root DOM element wheninterop/debug-enabled?is true. Production-elision contract per rf2-z7f7: under:advanced+goog.DEBUG=falsethe entire wrapper branch DCEs. Same Fragment / non-DOM-root exemption as the UIx adapter. - Render flush for tests.
flush-views!wrapping React'sact()— same surface as the UIx adapter. - Smoke-test example set. counter + login (under
examples/helix/counter_helix/andexamples/helix/login_helix/). Realworld is skipped — same rationale as UIx (heavy with Reagent-flavoured idioms; deferred until a Helix user wants it). - Helix version target. Helix 0.2.x (the latest published Helix release line). Older Helix versions are explicitly out of scope.
Implementation notes:
make-state-containerreturns aclojure.core/atomrather than a Reagentr/atom— Helix has no built-in reactive atom primitive (same as UIx). View-side reactivity flows throughuseSyncExternalStoreinuse-subscribe.make-derived-valuereturns anIDeref+IWatchablewrapper that recomputes on deref and broadcasts changes via the source containers' watch machinery — structurally identical to the UIx adapter.renderwrapsreact-dom/client.createRoot+root.render(Helix doesn't ship ahelix.dom/render-rootwrapper of its own; the lower-level call is the cross-version-stable path).register-context-providerreturns a Helixdefnccomponent reading the sharedframe-contextviahelix.hooks/use-context.use-subscribecallsReact.useSyncExternalStoredirectly becausehelix.hooksdoesn't ship ause-syncExternalStorewrapper (Helix is the minimal-wrapper substrate); deps are wired throughhelix.hooks/use-memo*/use-callback*(the function-form hooks) so the adapter doesn't pull in Helix's macro layer.
Every other adapter primitive (read, replace, subscribe-container, dispose) is structurally identical to the Reagent and UIx adapters' — the contract is genuinely substrate-agnostic, and the Helix port surfaces no friction against the rf2-3yij decision set.
Subscription topology vs subscription tracking¶
A subtle distinction worth pulling out: the static topology of the sub graph is core; the runtime tracking is adapter.
The topology is "what depends on what" — the static :<- chain you can derive from registrations alone, without running any code. (rf/sub-topology) returns this graph as data, shaped {sub-id {:inputs [<input-sub-ids>] :doc :ns :line :file}} per 002 §The public registrar query API. :inputs is always present (empty for layer-1 / direct-app-db subs) and lists the upstream sub-ids in declaration order; :doc and the source-coord keys are present when the registration carries them. JVM-runnable. No adapter needed.
sub-topology is a literal projection of the registrar — it does not validate the resulting graph. Cycle detection, "this :<- references an unregistered sub", and similar diagnostics are debugger / tool-pair concerns that traverse the returned map; the topology query itself reports verbatim what was registered. (Cycles in :<- are not legal at runtime — the resolved sub will throw — but the topology query stays a static projection.)
The tracking is "when source X changes, recompute everyone who depends on X" — the runtime mechanism that makes views update reactively. This requires the adapter's make-derived-value and is substrate-specific.
In CLJS dev-mode tests, you often want sub computation without tracking: (compute-sub [:total] db-value) runs the sub's body against a static app-db value and returns the computed result. Pure function. No Reagent, no reactions. This is the "JVM-runnable" path that 008-Testing and 011-SSR use.
SSR-specific behaviour¶
Per 011, the server-side render path doesn't use the adapter's reactivity machinery at all. The flow:
- Server creates a frame (per 002 §reg-frame).
- The frame's
app-dbis a plain atom (the core's plain-atom adapter, not the Reagent adapter). :on-createevents run; the drain settles.- The view fn is called as a plain function against the now-stable
app-dbvalue. - The hiccup output is rendered to a string by
render-to-string.
No Reagent. No React. No reactivity. Pure data → pure data → string.
The adapter that the core uses on the server is the plain-atom adapter (or "headless adapter"). The CLJS reference ships this alongside the Reagent adapter; the runtime picks based on platform.
CLJS reference scope¶
The CLJS reference ships across multiple Maven artefacts (rf2-0hxm; per Conventions §Adapter shipping convention):
day8/re-frame2— the substrate-agnostic core (the registrar, the drain, the dispatch envelope, the trace stream, sub topology, sub computation, effect-map interpretation) plus the adapter API contract, the plain-atom (headless) adapter used by SSR and headless tests, and (per rf2-3yij Decision 2) the shared React frame Context object atre-frame.adapter.contextthat every React-shaped adapter consumes.day8/re-frame2-reagent— the Reagent adapter (browser default).day8/re-frame2-uix— the UIx adapter (rf2-3yij). Targets UIx 2.x; ships theuse-subscribehook (Decision 1), theflush-views!test-flush helper (Decision 6), a source-coord wrapping component (Decision 5), and aframe-providerconsuming the shared React context (Decision 2). Apps written for UIx callreg-view*(plain-fn) directly — thereg-viewmacro stays Reagent-flavoured per Decision 4.day8/re-frame2-helix— the Helix adapter (rf2-2qit). Targets Helix 0.2.x; ships the sameuse-subscribehook,flush-views!test-flush helper, source-coord wrapping component, and shared-contextframe-provideras the UIx adapter. Apps written for Helix callreg-view*(plain-fn) directly — thereg-viewmacro stays Reagent-flavoured per Decision 4. The eight UIx decisions transferred unchanged because Helix and UIx share the React + hooks substrate model.
In the CLJS reference repository the three adapter sources live under implementation/adapters/<name>/ — implementation/adapters/reagent/, implementation/adapters/uix/, implementation/adapters/helix/. Per-feature artefacts (schemas, machines, routing, flows, http, ssr, epoch) stay flat under implementation/<name>/. The directory split surfaces the adapter-vs-per-feature distinction in the layout — adapters implement the §adapter API contract; per-feature artefacts plug into core via the late-bind hook table per Conventions §Independence rule. Maven artefact names are unchanged across the move per rf2-zha9; per rf2-0imy the directory is adapters/, not substrates/ — "substrate" names the abstract contract, "adapter" names each implementation.
Per-host adapters for non-CLJS implementations ship as separate packages, implementing the same contract — the per-adapter-artefact pattern is JS-cross-compile-language-agnostic across the eight in-scope hosts (TypeScript-React, Fable.React / Feliz, scalajs-react / Slinky, React.Basic, kotlin-react, ReasonReact, Melange-React, Squint-with-React). All ship a React-binding adapter; non-React substrates are out of scope per §Abstract.
Open questions¶
Cooperative rendering substrate¶
A cooperative rendering substrate — a rendering layer designed natively to cooperate with re-frame, instead of re-frame wrapping Reagent — is on the horizon. Substrate-agnostic decoupling (this Spec) is the prerequisite. Whether the cooperative variant ships depends on a benefits-vs-cost evaluation in a later cycle.
Multi-adapter coexistence¶
The current contract is single-adapter-per-process. If a concrete use case for per-frame adapter selection emerges, multi-adapter support can be added additively without breaking the single-adapter contract.
Resolved decisions¶
Adapter selection¶
Per rf2-agql (replaces rf2-84po; resolves rf2-4cb6) the consumer passes an adapter spec map explicitly to (rf/init! adapter-map). There is no default-adapter registry. Each adapter namespace exports an adapter Var; consumers require the namespace and pass the Var.
See §Adapter selection at boot above for the boot-time wiring, the legal call shapes, and the rationale (explicit > implicit; bundle-size; no implicit cross-adapter coupling).
Re-installing after frames exist is an error (:rf.error/adapter-already-installed trace event; recovery: :no-recovery, the call is rejected).
Other-language ports follow the same pattern: each adapter package exports a public adapter spec; the consumer requires the package and passes the spec to the language's init! equivalent.
Adapter introspection¶
Two complementary accessors:
-
(rf/current-adapter)returns a discriminator keyword identifying the active adapter (the:kindslot of the installed adapter spec map), ornilif no adapter is installed. Canonical values: -
:reagent— CLJS browser default (bridge adapter) :reagent-slim— CLJS browser, slim adapter (no stock-Reagent dep):uix— CLJS browser, UIx substrate:helix— CLJS browser, Helix substrate:plain-atom— CLJS JVM headless / tests / Node-based CLJS:ssr— CLJS JVM SSR (re-frame.ssr adapter)-
:custom— user installed a custom adapter that didn't pick one of the canonical kinds -
(rf/current-adapter-spec)returns the installed adapter spec map (the value passed to(rf/init! ...)), ornilif no adapter is installed. This is the map carrying the contract fns (:make-state-container,:replace-container!,:make-derived-value, …) plus the:kinddiscriminator.
Use current-adapter for predicate / branch code ("what substrate am I on?"); use current-adapter-spec for tool code that needs the adapter fn handles, or for identity checks across the install/dispose lifecycle.
Tools (10x, re-frame-pair) use the keyword to branch on host capabilities — for instance, the time-travel UI is meaningful in browser-Reagent but not in plain-atom.
The keyword is informational. Behaviour-affecting decisions should be based on :platforms metadata (per 011 §S-3) or on explicit configuration, not on which adapter is loaded.
Single adapter per process¶
One adapter per process. Frames within a process all use the same adapter.
Reasons:
- Per-frame adapter selection adds complexity in the runtime, the registry, and the dispatch envelope (which adapter's reactivity is in scope?).
- The use cases people propose for multi-adapter (headless tests inside a browser app; mixed Reagent and UIx) are better served by separate processes (test JVMs, separate apps) or by the existing
compute-subheadless path (no reactivity at all).
Re-installing an adapter after frames exist is rejected (per Adapter selection above).
Cross-references¶
- 000 §Substrate decoupling — the framework-level commitment to substrate decoupling.
- 011-SSR.md — SSR uses the plain-atom adapter on the JVM.
- 008-Testing.md — the headless-test path uses the plain-atom adapter.
- 002-Frames.md — frames are the core's primary structure; the adapter holds their
app-dbcontainers. - 004-Views.md — view rendering is the adapter's job.