Use UIx or reagent-slim¶
This recipe moves an app's views to UIx, for a team that writes React function components, or to reagent-slim, for a Reagent app whose bundle is too large. Either way you swap the substrate, the layer that drives React underneath your views, and leave every event, subscription, effect, and app-db untouched. The boot call (init!) names the substrate, and only the view code uses its notation.
Coming from Redux?
The adapter plays react-redux's role (frame-provider is <Provider>, use-sub is useSelector), except that it is a value you pass explicitly at boot, and exactly one is installed per runtime.
Which substrate¶
Reagent is the default. It has the full example set and is the notation used throughout this guide. Choose UIx when your team or host codebase already writes React function components; its examples are the counter and login (which mirror the Reagent versions) and an analytics dashboard (examples/substrates/uix/dashboard/). Choose slim when you have measured that the bundle is too large.
1. The one line that changes¶
In the boot shape from Boot and mount an app, the substrate is chosen by the first line of run:
To switch substrates, change that argument. Each adapter namespace exports a value named adapter; require the namespace and pass that value to init!:
;; Reagent — the canonical pick
(require '[re-frame.adapter.reagent :as reagent-adapter])
(rf/init! reagent-adapter/adapter)
;; UIx
(require '[re-frame.adapter.uix :as uix-adapter])
(rf/init! uix-adapter/adapter)
The published reagent-slim artefact puts its adapter at the same re-frame.adapter.reagent namespace, so its boot line is identical to stock Reagent's:
;; reagent-slim: the same require and init! line as stock Reagent
(require '[re-frame.adapter.reagent :as reagent-adapter])
(rf/init! reagent-adapter/adapter)
Moving from stock Reagent to slim therefore changes your deps.edn coordinate and your reagent.* view requires (step 6), not your boot call.
Using the repository instead of the published jar?
In this repository the slim adapter is at re-frame.adapter.reagent-slim, because both Reagent adapters share one classpath there; publishing renames it to re-frame.adapter.reagent. A :git/sha dependency therefore requires re-frame.adapter.reagent-slim. The rest of this page assumes the published name.
There is no adapter registry and no automatic install; the substrate is always named in your boot code:
(rf/init!)with no argument is an arity error.- A keyword,
nil, or anything other than an adapter map raises:rf.error/no-adapter-specified. - Calling
init!again with the adapter already installed does nothing, which keeps hot reload safe. Calling it with a different adapter raises:rf.error/adapter-already-installed; to switch, call(rf/destroy-adapter!)first.
What an adapter does
The registrar, the event pipeline, and your handlers, subscriptions, and effects never touch React, which is why none of them care about the substrate. The adapter is a small map of functions at the one point where re-frame2 meets a rendering library: it provides the reactive container app-db lives in, re-renders components when a subscription's value changes, and mounts the tree. Its contract has ten entries: the required make-state-container, read-container, replace-container!, make-derived-value, render, and render-to-string; the optional subscribe-container, register-context-provider, and flush-render!; and the dispose-adapter! lifecycle hook. You never call these yourself.
One adapter per build (the coordinate table)¶
Each adapter is its own artefact beside the core, so an app bundles only the one it depends on, and init! installs exactly one:
| Substrate | Coordinate | View library |
|---|---|---|
| Reagent | day8/re-frame2-reagent |
reagent (hiccup) |
| UIx | day8/re-frame2-uix |
com.pitch/uix.core (UIx 2 publishes as Maven 1.x); com.pitch/uix.dom only if you drive a React root yourself |
| reagent-slim | day8/reagent-slim |
reagent2 (ships inside it) |
A UIx app needs three coordinates:
;; deps.edn for a UIx app
{:deps {day8/re-frame2 {:mvn/version "<latest>"} ; core
day8/re-frame2-uix {:mvn/version "<latest>"} ; the UIx adapter
com.pitch/uix.core {:mvn/version "1.4.4"}}} ; defui / $ / hooks
<latest> is the released re-frame2 version; every re-frame2 artefact ships at the same version. The adapter mounts through its own client-root / render! (step 4), so the app doesn't need com.pitch/uix.dom. Add it, at the same version as uix.core, only if something drives a React root by hand, as the component-test recipe in Testing views does.
Coordinates are not published yet
re-frame2 is pre-alpha; these coordinates publish with the first public release. In the repository the adapters build from implementation/adapters/.
2. Write a UIx view¶
Your events, subs, and effects port without edits; only the views change. Here is the counter in UIx:
;; cf. examples/substrates/uix/counter/core.cljs
(ns my-app.views
(:require [uix.core :refer [$ defui]]
[re-frame.adapter.uix :as uix-adapter]))
(defui counter []
(let [value (uix-adapter/use-sub [:value])
{:keys [dispatch]} (uix-adapter/use-frame)]
($ :div
($ :button {:on-click #(dispatch [:dec])} "-")
($ :span {:style #js {:margin "0 1em"}} value)
($ :button {:on-click #(dispatch [:inc])} "+"))))
Three rules for UIx components:
- Read subs with
use-sub. It is a React hook built onuseSyncExternalStore, so a subscription re-renders the component like any other hook. It uses the frame from the surrounding provider;(use-sub [:q …] {:frame f}), the same opts formsubscribetakes, reads from an explicit frame instead. - Dispatch through
use-frame.(use-frame)is a hook that returns the frame api, the map{:frame :dispatch :dispatch-sync :subscribe}for the surrounding provider's frame, which is exactly what(rf/capture-frame)returns. Takedispatchfrom it during render and close over it; never call a barerf/dispatchin a callback (step 3 explains why). - There is no
reg-viewfor UIx. UIx components are plaindefui.rf/reg-view*exists for the rare component that needs a registry id.
For JavaScript developers
use-sub is useSelector: a hook over useSyncExternalStore, the primitive react-redux uses. The explicit-frame form is spelled the same way as Reagent's: (use-sub [:q] {:frame f}) beside @(rf/subscribe [:q] {:frame f}).
3. Why callbacks dispatch off the frame api¶
A click handler runs after render, with no frame in scope, so a bare rf/dispatch in it raises :rf.error/no-frame-context (Frames). use-frame is UIx's spelling of capture-frame: take the frame api during render and close over it.
The frame api holds every frame-bound operation:
(use-frame) ;; identical to (rf/capture-frame) for the ambient provider frame
;; =>
{:frame :app
:dispatch (fn ([event]) ([event opts])) ;; async-dispatch into the captured frame
:dispatch-sync (fn ([event]) ([event opts])) ;; synchronous variant — drains before returning
:subscribe (fn [query-v])} ;; frame-locked reaction — deref it (see below)
Usually you take only :dispatch from it, as the view above does. The other entries:
:dispatch-syncprocesses the event before returning, for when it must settle before the next line runs (initialisation, a confirm-then-read flow).:subscribereturns a frame-bound reaction for a query. Outside a view, balance a retained reaction withrf/unsubscribe. For a callback that only needs the current value, prefer(rf/subscribe-once query-v {:frame frame}), using the captured map's:frame. For reads that should re-render the component, useuse-sub.
The captured frame can't be overridden: a :frame in the dispatch opts is ignored in favour of the frame the map was captured for.
Hooks run only during render. Outside any component (an async setup function, a tool, a test), call (rf/capture-frame frame-id) to get the same map for a named frame:
;; A WebSocket message arrives long after render, outside any component.
;; Capture the frame by name at setup time and dispatch through it.
(let [{:keys [dispatch]} (rf/capture-frame :app)]
(ws/on-message (fn [msg] (dispatch [:todo/synced msg]))))
From re-frame v1
v1's global re-frame.core/dispatch worked anywhere because there was one implicit app. re-frame2 can run many frames and never guesses one, so the frame api is how a callback keeps the right frame (Frames).
4. Mount it: scope a frame into the subtree¶
Mount the root inside a frame-root, which creates the frame on first mount and makes it the frame every use-sub and use-frame below it uses. This is the Reagent boot shape from Boot and mount an app with the UIx adapter:
;; cf. examples/substrates/uix/counter/core.cljs
;; The entry namespace requires [re-frame.core :as rf], [uix.core :refer [$]]
;; and [re-frame.adapter.uix :as uix-adapter], plus the registration namespaces.
;; client-root does no DOM work, so this is safe at namespace load;
;; defonce keeps one React root across hot reloads.
(defonce app-root (uix-adapter/client-root))
(defn ^:dev/after-load mount! []
(when-let [el (and (exists? js/document)
(js/document.getElementById "app"))]
(uix-adapter/render! app-root
($ uix-adapter/frame-root {:id :app
:initial-events [[:initialise 0]]}
($ counter))
el)))
(defn run []
(rf/init! uix-adapter/adapter)
(mount!))
The client-root / render! / unmount! trio works as it does for Reagent: the first render! creates the React root (or hydrates one, with {:hydrate? true}), every later call updates that root, and unmount! releases it and is safe to repeat. The difference is the tree: UIx's render! takes a React element built with $, and passing it hiccup throws :rf.error/hiccup-on-element-render-slot. Children are ordinary $ trailing arguments, ($ frame-root {:id :f} ($ a) ($ b)).
For JavaScript developers
frame-root and frame-provider play the role of react-redux's <Provider>: the use-sub hooks below them find their frame through React context, as useSelector finds its store.
A missing or wrong frame throws
A tree rendered with no frame-root or frame-provider above it raises :rf.error/no-frame-context at the first use-sub. re-frame2 never guesses a frame.
5. Ensure a view's own frame¶
frame-root {:id …} creates its frame on first mount, taking any make-frame option (:images, :initial-events, :url-bound?, …), and reuses it on remount; frame-provider {:frame …} scopes a frame that already exists. Frames covers the split. In UIx:
;; A panel that brings its own frame: created on first mount, reused on remount.
($ uix-adapter/frame-root
{:id :todos/work
:images [todo-image] ;; your image for this frame
:initial-events [[:todo/initialise]]}
($ todo-app))
;; A frame created elsewhere: scope only.
($ uix-adapter/frame-provider {:frame :todos/work}
($ todo-app))
All the React adapters use the same React context for frames, so frame components compose across substrates: a Reagent frame-root above a UIx subtree works.
A frame-provider's :frame of nil raises :rf.error/no-frame-context; a string or number raises :rf.error/bad-frame-provider-arg.
6. Switch to reagent-slim¶
reagent-slim is plain Reagent with legacy surface removed, for apps where bundle size is a measured problem. It is not a different way of writing views. The trade:
- Payoff: an estimated 7–10 KB gzipped off a typical app (25–33% of the Reagent layer), and up to about 22–27 KB for apps using Reagent's HTML-export path. These figures are estimates, not build measurements. Runtime speed is at best marginally better, so choose slim for download size, not frame rate.
- Constraints: React 19 only. No
react-dom/server; HTML export uses a small ClojureScript serializer inreagent2.dom.server.create-classaccepts only seven keys (below).
Your app depends on exactly one of day8/re-frame2-reagent and day8/reagent-slim. The migration:
- Change the deps coordinate to
day8/reagent-slim. There-frame.adapter.reagentrequire and theinit!line stay as they are (step 1). - Set
reactandreact-domto 19.x inpackage.json. - Change
reagent.*requires in your views toreagent2.*(for examplereagent2.coreforreagent.core). - If you call Reagent's DOM API directly rather than the adapter's
client-root/render!, replacereagent.dom/renderwithreagent2.dom.client/create-rootplusrender, andreagent.dom/unmount-component-at-nodewithreagent2.dom.client/unmount.
A r/dom-node call becomes a :ref callback, since React 19 removed findDOMNode. The worked example is examples/substrates/reagent_slim/counter/, whose events, subs, and views are identical to the stock Reagent counter's.
Form-3 components under slim
Most views are Form-1 or Form-2 and don't notice slim. Views that own a piece of DOM lifecycle, such as a chart or map widget, use Reagent's Form-3 shape through reagent2.core/create-class, registered with rf/reg-view*. Slim accepts seven create-class keys: :reagent-render, :component-did-mount, :component-did-update, :component-will-unmount, :get-snapshot-before-update, :component-did-catch (React 19 error boundaries), and :display-name. Any other key throws :rf.error/create-class-key-unsupported, naming the key and listing the supported set. The deprecated will-* lifecycles, :should-component-update, and :get-derived-state-from-props are not supported; FORM-3.md has a migration recipe for each. To dispatch from a lifecycle callback, capture (:dispatch (rf/capture-frame)) during render and call it from the callback.
Server-rendering and the two HTML paths¶
Slim handles two kinds of "render to HTML" differently:
- Static HTML export (clipboard exports, report HTML, anything outside the React lifecycle) uses
render-to-static-markupinreagent2.dom.server, a ClojureScript tree walk with noreact-dom/serverand no hydration attributes. This is where the HTML-export saving comes from: stock Reagent'srender-to-stringpulls in the roughly 50 KBreact-dom/servermodule, while the serializer is about 3–4 KB. - SSR that the client hydrates works under slim as it does under stock Reagent. The server render is
day8/re-frame2-ssr's (Server-side rendering), not the adapter's, and the client adopts it withrender!and{:hydrate? true}. As for Reagent, passssr/hydrate!a:render-tree-fnso it can check the server's render-tree hash.
What carries over, what doesn't¶
Only the view code changes between substrates:
| Surface | Reagent / slim | UIx |
|---|---|---|
| Events, subs, fx, app-db | identical | identical |
| Read a sub in a view | @(subscribe [:q]) |
(uix-adapter/use-sub [:q]) |
| Read a sub from an explicit frame | @(rf/subscribe [:q] {:frame f}) |
(uix-adapter/use-sub [:q] {:frame f}) |
| Dispatch from a callback | dispatch injected by reg-view |
(:dispatch (use-frame)) |
| View form | reg-view + hiccup |
defui + $ |
| Registry-keyed view (when needed) | reg-view |
(rf/reg-view* id render-fn) |
| Scope an existing frame | [rf/frame-provider {:frame f} [app]] |
($ uix-adapter/frame-provider {:frame f} ($ app)) |
| Ensure a named frame | [rf/frame-root {:id f :images […]} [app]] |
($ uix-adapter/frame-root {:id f :images […]} ($ app)) |
| Flush renders in a test | (reagent-adapter/flush-views!) (slim's adapter has its own) |
(uix-adapter/flush-views!) |
Reagent's lazy-seq warning ("Reactive deref not supported in lazy seq, it should be wrapped in doall") doesn't apply under UIx. Reagent records the derefs that happen during render, and a lazy seq can delay a deref until after render, so on Reagent you realise the seq inside the render function: (doall (for …)), (mapv child @sub), or (into [:<>] (map child) @sub). A UIx use-sub registers its dependency when the hook is called, whenever the surrounding seq is realised.
Flushing renders in tests
After a test dispatch-syncs against a UIx-mounted tree, React hasn't committed the update yet. Wrap the dispatch as (uix-adapter/flush-views! #(rf/dispatch-sync …)), which uses React's act() to commit the re-render before returning. A real click's dispatch is queued instead; wait for it with a bounded poll-until. Test a view has the full component test. flush-views! is for test code only.
To confirm the port, run the app and open Xray: the event rows and epochs match the Reagent run, because the instrumentation reads the core, which doesn't know which substrate renders.
Advanced¶
Frame-component errors, frame-root's commit-time creation, remounting and owning a frame's whole lifetime work as described in Frames; the UIx components behave identically.
Gotcha: a captured frame can outlive a destroyed frame
After you destroy-frame! a frame, a callback that captured it earlier with capture-frame (a setTimeout or late WebSocket message) can still call its dispatch or subscribe. The runtime reports :rf.error/frame-destroyed and drops the operation; a captured subscribe returns nil. A commit that reaches the frame after it is gone is dropped and reported as :rf.error/write-after-destroy (recovery :ignored). Both are reported in production too. Cancel in-flight work when you destroy the frame, or keep data that must outlast the widget in a longer-lived frame.