Views: pure functions of data¶
The update and commit phases are in place, and so are the derivations (events → app-db → subscriptions). This page is the last pure stage of the event pipeline: turn derived values into a screen.
A view has one job. Not "manage local state" — a view stores nothing. Not "fetch what it needs" — a view never touches the world. Not "coordinate a lifecycle" — there's nothing to coordinate. It reads some application state, returns the description of the screen for that state, and it's finished. State changes; the framework re-runs the view; the DOM catches up.
A view is a pure function from subscription values to hiccup.
Views are derivative, not causal. Events update centralised state. Subscriptions derive values from it. Views sit at the end of the flow and render whatever arrives — a window onto the application, not the room itself.
After the live qty cell the pure pipeline is complete. Form-2/3, multi-frame targeting, and the substrate boundary live under Advanced — open them only when a need appears.
For JavaScript developers
A re-frame2 view is a React function component with everything except rendering
removed. No useState — state lives in app-db, your app's single
state map, and arrives through subscriptions. No
useEffect — anything that touches the world is an effect,
produced as data by an event handler and never run
from a component. No JSX — a view returns plain Clojure data. The design here is
in what got subtracted, not in anything added.
The counter gets components¶
The app-db counter rendered the whole UI in one view. Real screens are built from pieces. Views compose the way the hiccup they return does — one vector inside another. Three registered views: a display, a reusable button, a parent:
(require '[re-frame.core :as rf])
(rf/reg-event :initialise (fn [_ _] {:db {:value 0}}))
(rf/reg-event :inc (fn [{:keys [db]} _] {:db (update db :value inc)}))
(rf/reg-event :dec (fn [{:keys [db]} _] {:db (update db :value dec)}))
(rf/reg-sub :value (fn [db _] (:value db)))
;; the new idea: views compose — each piece registered, each pure
(rf/reg-view counter-display []
[:span {:style {:margin "0 0.5em"}} @(subscribe [:value])])
(rf/reg-view counter-button [label event]
[:button {:on-click #(dispatch event)} label])
(rf/reg-view counter []
[:div
[counter-button "−" [:dec]]
[counter-display]
[counter-button "+" [:inc]]])
[rf/frame-root {:id :app :initial-events [[:initialise]]}
[counter]]
Notes:
- A child view is used as data.
[counter-button "−" [:dec]]is a vector whose tail is the child's arguments — same view, two argument sets. - The display subscribes for itself. The parent does not fetch the value and hand
it down — so when the value changes, only
counter-displayre-renders. - The button takes the event to announce as data. That keeps the piece reusable without knowing what its click means.
Hiccup: the screen is data¶
A view returns hiccup — the notation from early in the track: nested Clojure vectors shaped like the DOM they describe:
The rules are quick to learn:
- A vector whose first element is a keyword is an element.
:div.cartis a<div class="cart">— the.classshorthand comes from CSS selectors, and:input#email.wideadds an id too. - A map in second position is the attributes:
[:button {:on-click f :disabled true} "Go"]. - Everything after that is children. Strings become text; nested vectors become nested elements.
- A vector whose first element is a view (not a keyword) renders that view, with
the rest of the vector as its arguments —
[counter-button "−" [:dec]]above. That's the composition rule the counter used.
The important word is data. Not "data-like". Actual vectors, maps, and keywords — the same structures you manipulate everywhere else in the program. So you build screens with ordinary code and no template syntax:
And because hiccup is just data, everything you already know how to do with data
works on screens. A function can take hiccup and return hiccup. You can pprint a
view's output and read it. A pure function that walks hiccup and emits an HTML
string can run on the server — which is how
server-side rendering renders the same views without a
browser in the building.
For JavaScript developers
Template strings can do none of this. They don't compose, they don't diff, and
string-built markup is where injection bugs come from. Hiccup is closer in spirit
to React's createElement calls — a tree of data describing the UI — except it's
plain literals you can map, filter, and pass around, with no build-time
transform.
Going deeper
Hiccup is the ClojureScript render-tree — the shape that survives serialisation across the JVM/browser boundary. Other hosts use their own render-tree shape behind the same contract.
Subscribe in, dispatch out¶
A static screen isn't much use. A view needs to read live application state, and it needs to react to clicks and typing. Two jobs, exactly two openings — and both are one-way.
Reading state in: the view derefs a subscription.
This declares "I depend on this derived value. Re-run me when it changes." That's the
only way a view learns application state. It doesn't read
app-db directly, and it doesn't receive the value as an
argument threaded down through ten ancestors. It asks the
derivation graph for exactly the slice it needs,
by name — via a query vector, the [id & args] shape
that names the subscription and keys its cache. (More on subscriptions in
Subscriptions.)
Sending events out: the view dispatches. Wire the view's
dispatch to an event handler:
(dispatch here is the local reg-view injects — bound to the view's
frame, and captured so it still routes correctly when the click
fires, after the render. More on that
below.)
A dispatch announces that something happened by handing the framework an
event — a plain vector naming what occurred — and returns
immediately. It does not change state. It does not know or care what the handler will
do with it. The event pipeline takes it from there: the
handler runs, app-db moves, subscriptions repropagate, and at the very end this
view re-renders to match. (The whole traversal is the
Introduction's subject.)
Notice the shape of the round trip — it's the whole idea. A click never mutates the
number it sits next to. It dispatches an event that produces a new app-db, which
flows back through a subscription. The view can't short-circuit that path, because it
holds no state to short-circuit with. In window terms: you can see into the room, and
you can knock. What happens after the knock is the room's business, not the window's.
Coming from Redux?
subscribe is useSelector and dispatch is dispatch — the same
unidirectional dataflow. The difference is that the "selector" is a named, cached
node in a derivation graph (see subscriptions) rather than a
function you pass inline, and the event is dispatched as data rather than through
a thunk.
A view, live¶
Subscribe in, dispatch out, hiccup between. Press Ctrl-Enter (Cmd-Enter on macOS) to evaluate, then click the buttons:
(require '[re-frame.core :as rf])
(rf/reg-event :views.qty/initialise
(fn [{:keys [db]} _] {:db (assoc db :views.qty/value 1)}))
(rf/reg-event :views.qty/inc
(fn [{:keys [db]} _] {:db (update db :views.qty/value inc)}))
(rf/reg-event :views.qty/dec
(fn [{:keys [db]} _] {:db (update db :views.qty/value (fnil dec 1))}))
(rf/reg-sub :views.qty/value
(fn [db _] (:views.qty/value db)))
(rf/reg-view qty-stepper []
[:div
[:button {:on-click #(dispatch [:views.qty/dec])} "−"]
[:span {:style {:margin "0 1em"}} @(subscribe [:views.qty/value])]
[:button {:on-click #(dispatch [:views.qty/inc])} "+"]])
[rf/frame-root {:id :demo :initial-events [[:views.qty/initialise]]}
[qty-stepper]]
Keep the :demo frame-root and change its child from [qty-stepper] to
[:div [qty-stepper] [qty-stepper]], then re-evaluate. Click either stepper: both
move. Both mount under the same seeded :demo frame, so neither
owns the number — each is a window onto the one app-db value. There is no local copy
to fall out of sync. (Replacing the whole frame-root with the bare [:div …] would
drop the :demo seed and land the steppers on an uninitialised frame — keep the
wrapper.)
The pure pipeline is complete¶
With events, app-db, subscriptions, and views, you have the pure stages of the event pipeline end to end — no impurity yet:
Many features never need more. The rest of this page is how views stay registered, fast, and debuggable. When the app must touch the world, open Effects; isolation and carry are Frames; packaging a real entry point is Boot and mount an app.
reg-view: the project form¶
reg-view defines the same render function as a defn, plus two things:
- A registry entry under an auto-derived id (
my.app+qty-stepper→:my.app/qty-stepper). Tooling lists the view, jumps to source, and names renders in the trace. - Frame-aware injection. Unqualified
dispatchandsubscribeare locals bound to the frame the view renders under — so the same view mounts in several worlds without renaming anything.
(rf/reg-view qty-stepper []
[:div
[:button {:on-click #(dispatch [:cart/qty-dec])} "−"]
[:span @(subscribe [:cart/qty])]
[:button {:on-click #(dispatch [:cart/qty-inc])} "+"]])
Hot-reload just works
Re-evaluating a reg-view overwrites its registry entry; mounted instances
pick up the new body on the next render. The swap emits
:rf.registry/handler-replaced so tooling can refresh its list.
reg-view is the Reagent surface
The defn-shape macro is specific to Reagent (this page's default). On
UIx you write native components and reach the frame through adapter
hooks (use-subscribe, use-frame). The pure-function rule, the
compute-in-subs rule, and frame isolation hold on every substrate. See
Use UIx or reagent-slim.
Docstring and explicit id
Like defn, reg-view takes an optional docstring (registry :doc) and an
explicit id via ^{:rf/id :cart/line} on the symbol when the auto-derived id
must stay stable across rename.
House rule: who is a reg-view?¶
A view that
subscribes ordispatches is areg-view. A plaindefnis a helper that takes data + callbacks only. Never threaddispatch/subscribedown as arguments.
;; Anti-pattern — state ops drilled as args; child is anonymous in the trace
(rf/reg-view todo-list []
[:ul
(for [todo @(subscribe [:todos/visible])]
^{:key (:id todo)} [todo-item dispatch subscribe todo])])
(defn todo-item [dispatch subscribe {:keys [id title]}]
[:li {:on-click #(dispatch [:todo/toggle id])} title])
;; House rule — state-touching child is registered; parent passes data only
(rf/reg-view todo-list []
[:ul
(for [todo @(subscribe [:todos/visible])]
^{:key (:id todo)} [todo-item todo])])
(rf/reg-view todo-item [{:keys [id title]}]
(let [editing? @(subscribe [:todo/editing? id])]
[:li {:class (when editing? "editing")
:on-click #(dispatch [:todo/toggle id])} title]))
Helpers stay plain: inputs with :value + on-change, formatters, presentational
wrappers. The TodoMVC example follows this split.
Plain defn under a frame is not free
An unregistered defn that calls rf/subscribe under frame-provider /
frame-root fails with :rf.error/no-frame-context. Registration is how the
view finds its frame. If a helper must stay unregistered, carry the frame
explicitly ({:frame …} on each call, or a capture from a registered
ancestor) — Frames.
Setup on mount → :initial-events
Do not dispatch from the render body to "load the cart on mount." That couples
reads to writes and can loop under a reactive substrate. Name the setup event
and list it on the frame:
From re-frame v1 — Form-2 / Form-3
Form-2 (outer runs once, returns inner render fn) still works for
prop-dependent mount setup; prefer :initial-events for stable setup.
Form-3 (reagent.core/create-class) for imperative DOM libraries is out of
scope for the reg-view macro — use reg-view* (API: Views).
Full delta: From re-frame v1.
The one rule: views compute hiccup only¶
Now the single discipline that keeps views fast, correct, and easy to debug. It pays for itself within a day of writing real screens:
Views compute hiccup only. Everything else — sorting, filtering, formatting, deriving, joining — happens in a subscription.
The temptation always looks innocent. The subscribed list is almost what the screen
needs, so you reach for one little sort-by here, one .toFixed there. Don't.
Here's the before, with the view quietly doing two jobs that aren't its own:
;; Before — the view computes. The sort and the price-format re-run on
;; EVERY re-render of this view, whether or not the cart changed.
(rf/reg-view cart-lines []
[:ul
(for [item (sort-by :name @(subscribe [:cart/items]))]
^{:key (:id item)} [:li (:name item) " — $" (.toFixed (:price item) 2)])])
And the after, with the derivation pushed up into a subscription where it belongs:
;; After — the sub computes once per change to :cart/items; the view renders.
(rf/reg-sub :cart/lines-display
:<- [:cart/items]
(fn [items _]
(->> items
(map #(update % :price (fn [n] (.toFixed n 2))))
(sort-by :name))))
(rf/reg-view cart-lines []
[:ul
(for [item @(subscribe [:cart/lines-display])]
^{:key (:id item)} [:li (:name item) " — $" (:price item)])])
Ask the "after" view what it does: all it does is walk the list and emit <li>s.
That's a view that knows what it's for.
Why so strict? Because a view re-runs whenever any value it derefs changes, and
whenever a re-rendering parent hands it changed arguments — and a sort-by in the
view re-runs on every single one of those. The same sort-by in a sub re-runs
only when :cart/items changes, sits in the subscription cache, and is shared by
every view that wants the sorted list. Compute once, read many. This is the single
most common way re-frame2 apps get accidentally slow; the hunt and the fix are in
Find and fix a slow view.
Need the derived value in an event handler, not just a view?
A subscription's value is only available to views. When a handler needs the same derivation as plain state, materialise it with a flow — Flows; chooser: Where state lives.
What's the ^{:key (:id item)} for?
Same as React's key. Give each list element a stable identity
(^{:key (:id item)} [:li …]) so the substrate diffs by identity, not
position. Key by durable data, never the loop index. Missing or colliding keys
can silently keep stale DOM, drop a row, or duplicate one — not an error.
You can compose registered views, subscribe in / dispatch out, keep computation in
subs, and seed setup via :initial-events. That closes the pure pipeline stages.
Rendering reads; interaction dispatches¶
A view is a derivative projection: it turns state into hiccup, and that is all it
does. Reading is its whole job — props, local pure values, and any subscription
(:rf.route/id, :rf/resource, your own derived subs) are all fair game inside a
render body, because reading a value cannot change the world. What a view must never
do is cause: it does not fetch, ensure a resource, navigate, or dispatch merely
because it rendered.
That line is the causal boundary. Rendering sits on the reading side of it; a user
or host interaction — a click, a keypress, an :on-* callback — is what crosses to
the causing side. Dispatch from a click handler is exactly right; a fetch on the
render path is exactly wrong.
;; RIGHT — render reads; the click causes.
(rf/reg-view article-link [slug]
(let [current @(subscribe [:rf.route/id])] ; read in render — fine
[:a {:class (when (= current :route/article) "active")
:on-click #(dispatch [:rf.route/navigate ; cause on interaction — fine
{:to :route/article :params {:slug slug}}])}
"Read it"]))
;; WRONG — the ensure runs because the view rendered, so it re-fires on every
;; re-render and races every other view showing the same article.
(rf/reg-view article [slug]
(dispatch [:rf.resource/ensure {:resource :article/by-slug ; DON'T
:params {:slug slug}}])
[:article @(subscribe [:rf/resource {:resource :article/by-slug
:params {:slug slug}}])])
Calling fetch, a resource ensure, navigate, or dispatch during render is not
a supported pattern. Every fix moves the cause to where it belongs: a
route's :resources declaration, an event handler's :fx,
or the :on-* handler an interaction fires. And there is no escape hatch coming —
no load-from-view API will ever ship. A view that seems to need one is really
telling you a cause is registered in the wrong place.
Troubleshooting¶
| Symptom | Likely cause | Fix |
|---|---|---|
| Screen shows wrong data | Bad event or sub, not the view | Inspect data with Xray; test handler/sub without a browser |
| View re-renders too often | Sort/filter in the view, or too-coarse sub | Move work into a sub; slow-view recipe |
:rf.error/no-frame-context from a click path |
Bare rf/dispatch in a handler that fires after render, or an unregistered defn that dispatches |
reg-view it and use the injected dispatch; for detached callbacks capture with rf/capture-frame |
| List flickers or duplicates | Missing / colliding ^{:key …} |
Stable keys from data |
| Anonymous noise in the trace | Unregistered state-touching children | House rule: reg-view for anything that subscribes |
When something renders wrong, the bug is almost never in the view — it's in the data the view was handed.
Each render can also be traced by :render-key ([view-id instance-token]) with
what triggered it — that is how you answer "why did this re-render?" Registered
views have names; plain helpers fall under [:rf.view/anonymous nil]. Dev-only;
elided in production.
The trap: a callback that fires after render has no frame¶
A view's :on-* handler runs later — when the user clicks, not when the view
renders. By then the render is over: the dynamic frame scope
has unwound and the frame-provider's React context
has been popped. The adapter does not re-wrap :on-* callbacks to restore it —
frame identity is carried, not found.
What survives that boundary is capturing the frame at render time, which is
exactly what reg-view's injected dispatch / subscribe do: each is a
capture-frame op bound to the render frame. So reach
for the injected dispatch — not a fresh, fully-qualified rf/dispatch:
;; WRONG — `rf/dispatch` resolves the frame when the event fires, and by then
;; there is none → :rf.error/no-frame-context
[:div {:on-animation-end #(rf/dispatch [:tile/finished])}]
;; RIGHT — the injected `dispatch` captured the frame at render, so it
;; dispatches correctly after the render boundary
[:div {:on-animation-end #(dispatch [:tile/finished])}]
Attaching a listener imperatively from a render body fails the same way, and worse: the callback still fires with no frame, and every re-render stacks another listener.
;; WRONG — fires later with no frame, and leaks a listener per render
[:div {:ref (fn [el]
(when el
(.addEventListener el "animationend"
#(rf/dispatch [:tile/finished]))))}]
If there is no :on-* for what you need (setTimeout, fetch, observers,
sockets), that work belongs in a registered effect, not the view.
When you must hold a dispatch for a genuinely detached callback — a socket
message, a timer you own — capture it explicitly:
(:dispatch (rf/capture-frame)) returns a dispatch locked to the render frame
that survives any async hop. Frames is that pattern's home.
Advanced¶
Targeting a different frame
Injected dispatch / subscribe always hit the frame the view renders under.
To address another world deliberately:
(let [their-total @(rf/subscribe [:cart/total] {:frame :other-tab})]
[:button {:on-click #(rf/dispatch [:cart/clear] {:frame :other-tab})}
(str "Other tab: " their-total)])
Escape hatch, not the daily path. For a whole subtree, rf/with-frame or
frame-provider — Frames.
The substrate boundary
Handlers, subs, and app-db never name a rendering library. The adapter
((rf/init! reagent-adapter/adapter)) is where hiccup becomes pixels.
Port substrates and only init! plus view notation change —
Use UIx or reagent-slim. A more radical,
still-experimental option is Freehand, re-frame2's own
view layer — subscriptions read as plain values, handlers are event vectors,
and a hot declaration can be promoted to a compiled tier without touching a
single call site.