Pattern — Stateful Components¶
Type: Pattern The canonical outer/inner wrapping shape for views that bridge a stateful third-party JavaScript component (D3, Mapbox, CodeMirror, Three.js, GSAP, Framer Motion, ag-grid, Vega-Embed, AmCharts, SpreadJS, …). Convention, not Spec.
Code samples are in ClojureScript (the CLJS reference). The pattern itself is host-agnostic where the host has a component-lifecycle equivalent; on the JVM there is no DOM to bridge, so the pattern is browser-side.
What this pattern classifies as. The outer/inner shape below is spelled on today's shipping view adapters — the stock-Reagent compatibility/interop tier (Form-3 via
reg-view*, render-timecapture-frame) and the UIx and reagent-slim adapters. All three are first-class and actively supported — they live on (see Spec 006 §CLJS reference scope for each adapter's lifecycle role; Helix was removed at S7/W13 — rf2-d6epb, 2026-07-22). It is the current guidance for bridging a stateful JS component.The two donor view substrates that once carried a second answer here — the compiled
re-frame.uiand Freehand — were removed on 2026-08-16 (rf2-0yp7w). The Form-3 /reg-view*/capture-frameshape documented here is the shipping bridge.
Role¶
A named pattern, not a Spec. Re-frame2's view substrate is built around pure render functions that compute hiccup from state. A small but unavoidable fraction of real-world views need to wrap a third-party JS library that owns its own DOM and exposes an imperative init / update / dispose lifecycle.
This doc names the wrapping shape — outer/inner split — so feature code, adapter READMEs, and the Animations Regime C discussion cite a single canonical description rather than re-deriving the rationale per library.
The runtime contract for the lifecycle hooks themselves is owned per-adapter (§Per-adapter spelling below); this doc owns the shape that composes those hooks with the framework's reactive flow.
Why the pattern exists¶
Third-party JS components — D3 charts, Mapbox/Leaflet maps, CodeMirror / Monaco editors, Three.js scenes, animation libraries — share three properties that put them in tension with re-frame2's view layer:
- They own a DOM subtree. They build, mutate, and tear down their own elements. The view layer cannot describe what they render with hiccup; it can only declare a mount point and hand them props.
- They have an imperative lifecycle. "Initialise against this DOM node," "you have new data, please re-draw," "you're going away, please clean up." None of those phases is a pure function of props.
- They register their own listeners and timers. Map pans, chart hovers, editor selection-change events, animation completions — all attached on the library's side, all needing teardown on unmount or they leak.
A re-frame2 view body, on the other hand, MUST NOT dispatch from render, MUST NOT addEventListener from render, and MUST NOT own an imperative library lifecycle directly. The render body computes hiccup; the work that violates "pure render" lives somewhere else.
The pattern in this doc is where else.
The outer/inner pattern¶
Two views, composed:
outer (registered view)
│ reads subs, derives props
▼
inner (Form-3 / use-effect view)
│ owns the library lifecycle: mount / update / unmount
▼
library (owns its DOM subtree, listeners, internal timers)
Outer — pure re-frame2 view¶
A standard registered view (reg-view). Its job is derivation:
- Reads subscriptions for the data the library needs to render.
- Computes a single, JSON-shaped props map describing what the library should show.
- Renders an inner component with those props.
Nothing more. The outer is pure render; it never touches the DOM; it never holds an instance handle. When subs change, the outer re-renders, and re-frame2's reactive substrate feeds the inner a new props map.
Inner — Form-3-equivalent lifecycle wrapper¶
The inner view is not a Form-1. It is whatever the active adapter exposes as its Form-3 equivalent — a Reagent create-class for Reagent / Reagent-slim, a use-effect body for UIx. The inner owns three lifecycle phases:
- Mount — after first commit, the DOM mount point exists. Read it via a ref, hand it to the library's constructor, stash the resulting instance handle in a per-mount closure cell (a plain
atomfor Reagent; aui/reffor hooks-based adapters). Apply the initial props. - Update — when props change, the library's instance is already mounted; it is not torn down and re-created. Push the new props into the instance via whatever imperative API the library exposes (
.setData,.setView,.setOptions,.update,.panTo, …). - Unmount — release the instance handle. Call the library's dispose / destroy API if it has one; remove any listeners the library was unable to clean up itself; null out the closure cells so the GC can reclaim them.
The inner's render body itself is trivial — usually just [:div {:ref …}] (or the substrate-equivalent), describing the mount point but no content. The library fills that node; React/Reagent must see consistent hiccup across renders so the substrate doesn't tear the mount node out from under the library.
Why split outer/inner¶
The split is forced by reactive context:
- Subscriptions are reactive — they want to be read at render time, from a view that the substrate can re-render when the value changes.
- The library lifecycle is imperative —
:component-did-mount,:component-did-update,use-effectbodies all run after commit, on a stack with no reactive context. Reading subs from inside the lifecycle callback is undefined behaviour on every adapter (Reagent README §95, UIx README §72). (The lone disciplined exception — Reagent's captured-:subscribe+ a per-mountr/track!owner + a balanced frame-first(rf/unsubscribe frame query-v), the migration recipe's §M-11 exceptional imperative-subscription Form-3 — is the deliberate, ref-count-balanced case, not the undefined bare deref this rules out. The tracker is what makes it disciplined: a subscription on the ratom family is a Reaction built without:auto-run, so it learns its sources only throughderef-capture— the tracker supplies that, where a plain hook deref plusadd-watchwould leave the widget fed once and deaf.)
The outer handles the reactive read; the inner handles the imperative lifecycle. Props are the seam.
Per-adapter spelling¶
The shape is identical across adapters; only the lifecycle-hook surface differs. Cross-link to each adapter's README for the exact API surface and per-adapter worked example. Per the classification callout above, the Reagent and Reagent-slim rows are the stock-Reagent compatibility/interop tier and the UIx row is a first-class, actively-supported adapter — all three live on and remain the shipping spelling today.
| Adapter | Inner lifecycle surface | Registration | Reference |
|---|---|---|---|
| Reagent | reagent.core/create-class Form-3 (:reagent-render + :component-did-mount + :component-did-update + :component-will-unmount) |
reg-view* (the plain-fn surface — the reg-view macro rejects Form-3 bodies) |
Reagent adapter README |
| Reagent-slim | reagent2.core/create-class Form-3, 7-key cap (the six lifecycle keys plus :display-name) |
reg-view* |
Reagent-slim adapter README and FORM-3.md — the slim adapter's single source of truth for Form-3 |
| UIx | uix.core/use-effect inside a defui, with a deps vector listing every prop the effect reads. Cleanup is the fn the effect body returns |
ordinary defui (a plain fn) — read subs with use-subscribe, carry the frame with the use-frame hook; reg-view* is optional, only when the component must be addressable by id (registry addressing) |
UIx adapter README |
The three things that are identical across adapters:
- Mount runs after commit. The DOM node exists when the hook fires; refs are populated; library constructors can read element dimensions.
- Update receives the new props. Inside the hook body, you can read the current props (via
reagent/argv thison Reagent or the captured fn parameter on hooks-based adapters) and push them to the library instance. - Cleanup is mandatory. Unmount fires before the DOM node is removed. Skipping cleanup leaks the library instance, its listeners, and any tile / data caches it holds, across every navigation that re-mounts the component.
The one cross-adapter discipline: carry the frame from render-time into the after-commit callback — never a bare (rf/dispatch […]) from inside a lifecycle callback (it escapes the frame scope, carries no frame stamp, and fails loudly with :rf.error/no-frame-context — no :rf/default fall-through; per Spec 002 §Frame target resolution). The carried value is the same primitive on every adapter — a (rf/capture-frame) frame api, whose :dispatch op resolves to the right frame after commit — but the spelling is per-adapter:
- Reagent / Reagent-slim — capture
(rf/capture-frame)at render-time, in the closure aroundcreate-class, and use its:dispatchop. - UIx — call the
use-framehook at the top of thedefuibody: the hook-position spelling ofcapture-frame, returning the same{:frame :dispatch :dispatch-sync :subscribe}frame api. The hook reads the surroundingframe-provider/frame-rootthrough React context, which a bare render-time(rf/capture-frame)in a plain hooks component cannot — no-arg capture reads only the dynamic-var tier, so under a context-only frame it raises:rf.error/no-frame-context. Read subs in the outer withuse-subscribe(not@(subscribe …)).
Worked example — a Mapbox-shaped widget¶
A small map view, parameterised by a current position from app-db. The shape is library-agnostic; substitute D3, Three.js, CodeMirror, etc. with no structural change. Pseudo-code — the library calls are illustrative, not runnable. The spelling below is on the stock-Reagent compatibility/interop tier (Form-3 via reg-view*); it is the shipping bridge (see the classification callout above).
(ns my-app.map
(:require [re-frame.core :as rf]
[reagent.core :as r]))
;; Inner — Form-3, owns the library lifecycle. Registered via reg-view*.
(rf/reg-view* :my-app.map/map-inner
(fn [_initial-pos]
(let [el-ref (atom nil) ;; mount-point handle
map-instance (atom nil) ;; library instance handle
marker (atom nil) ;; library-owned marker
dispatch (:dispatch (rf/capture-frame))] ;; captured at render — carries frame
(r/create-class
{:display-name "map-inner"
:component-did-mount
(fn [this]
(let [[_ {:keys [lat lng zoom]}] (r/argv this)
m (js/mapboxgl.Map. (clj->js {:container @el-ref
:center [lng lat]
:zoom zoom}))]
(reset! map-instance m)
(reset! marker (-> (js/mapboxgl.Marker.)
(.setLngLat #js [lng lat])
(.addTo m)))
;; Library callback → dispatch into the captured frame.
(.on m "moveend"
(fn [_evt]
(let [c (.getCenter m)]
(dispatch [:map/user-panned (.-lat c) (.-lng c)]))))))
:component-did-update
(fn [this _ _ _]
(let [[_ {:keys [lat lng]}] (r/argv this)]
(.setLngLat @marker #js [lng lat])
(.panTo @map-instance #js [lng lat])))
:component-will-unmount
(fn [_this]
(some-> @map-instance .remove) ;; library's dispose API
(reset! map-instance nil)
(reset! marker nil)
(reset! el-ref nil))
:reagent-render
(fn [_pos]
[:div {:ref (fn [el] (reset! el-ref el))
:style {:height "400px" :width "100%"}}])}))))
;; Outer — Form-1, reads subs, hands props to the inner.
(rf/reg-view map-panel []
(let [pos @(rf/subscribe [:current-position])]
[(rf/view :my-app.map/map-inner) pos]))
Things worth noting:
- The outer is trivially small — sub, deref, pass. All the complexity lives in the inner, behind a stable interface.
- Per-mount state lives in closure atoms.
el-ref,map-instance,markerare(atom)cells inside the inner's outer fn — one set per mount. Don't use top-leveldefordefonce; those leak across mounts and across hot-reloads. - The render body is consistent across renders.
[:div {:ref …}]doesn't change shape when props change; React/Reagent leaves the mount node alone, so the library's DOM subtree survives intact between renders. The work of reacting to new props happens in:component-did-update, not in the render. - The library callback (
m.on "moveend") dispatches via the captureddispatch. The dispatcher closure was built during render, so the library callback — which fires on a fresh stack with no*current-frame*binding — still routes the dispatch to the right frame. :component-will-unmountis mandatory. Without(.remove map-instance), every navigation that unmounts the map leaks Mapbox's WebGL context, tile cache, and event listeners. Multiply by 10 navigations across a session and the tab is a memory swamp.- Props are a map. The vector form
[(rf/view :my-app.map/map-inner) pos]— whereposis a map — is whatr/argvdestructures inside the lifecycle callbacks. Per Reagent's contract,(reagent.core/props comp)only works when props are a map; v1'sUsing-Stateful-JS-Components.mddocuments the same trap.
The hooks-based adapter (UIx) compresses the lifecycle into a single use-effect body: the outer reads subs with use-subscribe, and the inner is an ordinary defui that carries the frame with the use-frame hook (never a bare render-time capture-frame, which cannot read the frame-provider from React context) and owns the library in use-effect. The structural pattern — outer reads subs, inner owns the library — is identical; only the keystrokes differ. reg-view* is optional here, needed only when the inner must be addressable by id. See the per-adapter READMEs linked above for the hooks-shaped worked example.
Animations are a special case of this pattern¶
Animation is a view-layer concern, but views are derivative — they compute a template from state. The portable principle: state is the truth; the view animates the transition; animation completion is silent unless explicitly modelled in state. Three regimes cover the space. This doc is the canonical home for the regime taxonomy. Choose the regime by what the state actually needs to know.
Regime A — Transition animations¶
The 95% case. State changes; the view re-renders with a different :class or :style; CSS (or the substrate's animation engine) completes the visual transition silently. No completion dispatch is needed — by the time the animation kicks off, app-db has already moved on and the visual is catching up. Opacity fades, slide in/out, accordion expand, list reorder, modal scrim, route transitions. Sequencing belongs in CSS (animation-delay, keyframes) or a small :dispatch-later chain that advances a :phase key at known intervals. No outer/inner; no library to wrap.
Regime B — Continuous animations (RAF loops)¶
Per-frame state mutation IS the truth. The right shape is a registered fx (e.g. :ui/raf-loop) that owns the requestAnimationFrame cycle and dispatches a per-frame event carrying delta-time; the fx captures the frame at registration (per Pattern-AsyncEffect), the handler updates state, the view renders it, and a sibling fx cancels the RAF handle. This is Pattern-AsyncEffect with requestAnimationFrame substituted for HTTP — particle systems, scroll inertia, physics, game loops all fit. No outer/inner; no library to wrap.
Regime C — Library-bridged animations¶
Framer Motion, React-Spring, GSAP, AutoAnimate — the animation library is component-shaped: it owns its own imperative timing inside its own component tree. The wrapping shape is exactly this pattern. Animation libraries are not a separate category from stateful JS components — they are one instance of it. The outer reads subs and produces state-derived props (target opacity, target x/y, easing curve, target colour); the inner is a Form-3 / use-effect wrapper that hands the library those props; the library's internal completion callbacks (e.g. Framer Motion's onAnimationComplete) are bridged at the inner boundary, dispatching via the same captured (rf/capture-frame) discipline as the outer/inner split above. Use this pattern for Regime C.
What NOT to do¶
The shapes that look tempting but compose badly with re-frame2's reactive flow, listed here so the trap is visible from the pattern doc.
- Attaching
addEventListenerfrom a render body — the listener fires on a fresh stack with no*current-frame*binding; a bare(rf/dispatch …)from inside it carries no frame stamp and fails loudly with:rf.error/no-frame-context(no:rf/defaultfall-through). The listener also leaks: nothing detaches it on re-render or unmount. The right home foraddEventListeneris the inner's lifecycle hook, with a cleanup that removes the listener on unmount. - Owning a library lifecycle directly in a render body —
(js/MyLib. el opts)from a Form-1 body builds a fresh library instance every render. The library was built to be instantiated once at mount; building it on every render leaks instances at the rate of every reactive update. The right home is the inner's:component-did-mount(oruse-effectmount phase). - Calling
@(subscribe …)inside a lifecycle hook body. Subscriptions need reactive context;:component-did-mount,:component-did-update,:component-will-unmount, anduse-effectbodies all run after commit with no reactive context. Subscribe in the outer (or, on Reagent adapters, in:reagent-render); pass the value as a prop to the inner. (The one deliberate exception is a rare imperative widget re-fed from a hook — Reagent's captured-:subscribe+ a per-mountr/track!owner + a balanced frame-first(rf/unsubscribe frame query-v), the migration recipe's §M-11 exceptional imperative-subscription Form-3; it is ref-count-balanced and does not relax this default. Note what the exception is not: a bareadd-watchon the acquired reaction. Nothing would ever activate that reaction, so the watch could not fire and the widget would go deaf after mount — the tracker is the reactive owner that supplies the missingderef-capture.) - Stashing the library instance in
defonceor a top-leveldef. Top-level cells leak across mounts and across hot-reloads, and they break when the component mounts twice (e.g. in two different frames simultaneously). The library instance is per-mount state; the closure inside the inner is the right home.
Cross-references¶
- §Regime C — Library-bridged animations — animation libraries as a special case of this pattern.
- Spec 002 §Dispatches issued from inside a handler body — why
(rf/capture-frame)must be captured at render-time, not inside the lifecycle callback. - Pattern — Async Effect — the sibling pattern for "external work + dispatched reply" outside the view layer (HTTP, IndexedDB, WebSocket, RAF loops); composes with this pattern when the library exposes its own async callbacks (e.g. a tile-loaded event from a map library).
- Reagent adapter README §Imperative escape hatch, Reagent-slim adapter README §Imperative escape hatch and
FORM-3.md, UIx adapter README §Imperative escape hatch — the three per-adapter spellings.