Skip to content

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-time capture-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 new, experimental first-party view substrate is the compiled re-frame.ui, whose one component form is ui/defview over a single props map (Spec 004D §ui/defview) and whose stateful-bridge equivalent — component refs, ui/effect, and observation handles (Spec 004D §Effects) — is being specified through the re-frame.ui S1–S7 program and is not yet the shipping bridge. When it lands, this pattern's compiled form is specified in Spec 004 alongside the shipping adapters — no spec/004A compatibility appendix lands and nothing relocates. Until then, the Form-3 / reg-view* / capture-frame shape documented here is the one to use.

Role

A named pattern, not a Spec. Re-frame2's view substrate (Spec 004 — Views) 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 (per Spec 004D §Effects — the view-side surface). 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:

  1. 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 atom for Reagent; a ui/ref for hooks-based adapters). Apply the initial props.
  2. 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, …).
  3. 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-effect bodies 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-mount add-watch key + 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 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 per Spec 004D §Removed forms — normative absences) 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 this on 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 around create-class, and use its :dispatch op.
  • UIx — call the use-frame hook at the top of the defui body: the hook-position spelling of capture-frame, returning the same {:frame :dispatch :dispatch-sync :subscribe} frame api. The hook reads the surrounding frame-provider / frame-root through 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 with use-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 current bridge until the compiled re-frame.ui equivalent lands (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:

  1. The outer is trivially small — sub, deref, pass. All the complexity lives in the inner, behind a stable interface.
  2. Per-mount state lives in closure atoms. el-ref, map-instance, marker are (atom) cells inside the inner's outer fn — one set per mount. Don't use top-level def or defonce; those leak across mounts and across hot-reloads.
  3. 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.
  4. The library callback (m.on "moveend") dispatches via the captured dispatch. 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.
  5. :component-will-unmount is 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.
  6. Props are a map. The vector form [(rf/view :my-app.map/map-inner) pos] — where pos is a map — is what r/argv destructures inside the lifecycle callbacks. Per Reagent's contract, (reagent.core/props comp) only works when props are a map; v1's Using-Stateful-JS-Components.md documents 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; Spec 004D §Effects owns the RAF-as-fx and effect-plus-ref mechanics those regimes use. 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. Each is owned (with the full reason) by Spec 004D §Effects — the view-side surface; listed here so the trap is visible from the pattern doc.

  • Attaching addEventListener from a render body (Spec 004D §Views MUST NOT attach native DOM event listeners from render bodies) — 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/default fall-through). The listener also leaks: nothing detaches it on re-render or unmount. The right home for addEventListener is the inner's lifecycle hook, with a cleanup that removes the listener on unmount.
  • Owning a library lifecycle directly in a render body (Spec 004D §Views MUST NOT own imperative library lifecycles directly) — (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 (or use-effect mount phase).
  • Calling @(subscribe …) inside a lifecycle hook body. Subscriptions need reactive context; :component-did-mount, :component-did-update, :component-will-unmount, and use-effect bodies 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-mount add-watch key + 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.)
  • Stashing the library instance in defonce or a top-level def. 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