Views and reads¶
A Fresco view can read a subscription where the value is needed without forcing a parent to own that read. The view that performs the read becomes the unit that re-renders when the value changes.
(ns todo.views
(:require [re-frame.fresco :as h]))
(h/defview todo-row [{:keys [id]}]
(let [todo (h/sub [:todo/by-id id])
editing? (h/sub [:todo.ui/editing? id])]
[:li
[:span (:title todo)]
[:button {:on-click [:todo/toggle id]} "✓"]
(when editing?
[:input {:value (h/sub [:todo.ui/draft id])
:on-input [:todo.ui/edit id ::h/value]}])]))
h/sub is legal anywhere in the synchronous body: in a
let, conditional, loop, or ordinary helper call. Each
h/defview records the subscriptions read while its
body runs. When one of those subscription values changes, that view
re-renders.
View bodies must be pure and safe to run again: React may call a body more than
once for one commit, and under StrictMode it does so deliberately in
development. Mutating a captured atom, starting a fetch, or counting renders
belongs in events and effects.
h/sub is the only read form in a Fresco body. A bare rf/subscribe there
throws :rf.error/ambient-frame-refused, because a read the view does not
track would never re-render it. Event vectors and the
::h/value marker in the example are covered in
Events as data.
Views and plain helpers¶
These forms look similar but create different runtime structure:
[todo-row {:key id :id id}] ;; a separate Fresco view
(row-icon {:kind :urgent}) ;; a plain function call, inlined here
A view in head position is an independently re-rendering unit. This guide calls
that unit a boundary. h/defview creates a boundary
that tracks:
- React identity for the view
- subscription reads made by the body
- the props used by its equality bail-out
- the re-frame2 frame used by event vectors produced by the body
Native tags, fragments, and h/defhost heads also appear
in vector position, but they do not create Fresco boundaries.
A plain defn is only a function call. Its Hiccup is inserted into the caller's
tree, and any h/sub calls it makes are recorded by the surrounding Fresco
view. It adds no independent re-render granularity. This lets a helper read the
current filter or other state directly instead of requiring the caller to
thread that value through its arguments.
In the cell below, each row is a view that reads its own todo, and
remaining-label is a plain helper called by todo-list, which itself reads
only the ids. Click ✓ on a row: the row re-renders from its own read, and the
count updates because todo-list recorded the helper's read as its own.
(require '[re-frame.core :as rf]
'[re-frame.fresco :as h])
(rf/reg-event :todo/initialise
(fn [_ _]
{:db {:todos {1 {:id 1 :title "Buy milk" :done? false}
2 {:id 2 :title "Walk the dog" :done? true}
3 {:id 3 :title "Post letter" :done? false}}}}))
(rf/reg-event :todo/toggle
(fn [{:keys [db]} [_ id]]
{:db (update-in db [:todos id :done?] not)}))
(rf/reg-sub :todo/all
(fn [db _]
(vec (sort-by :id (vals (:todos db))))))
(rf/reg-sub :todo/ids
(fn [db _]
(sort (keys (:todos db)))))
(rf/reg-sub :todo/by-id
(fn [db [_ id]]
(get-in db [:todos id])))
;; A plain helper: its read is recorded by the view that calls it.
(defn remaining-label []
(let [n (count (remove :done? (h/sub [:todo/all])))]
(str n (if (= 1 n) " todo" " todos") " left")))
;; A view: a boundary that re-renders when its own read changes.
(h/defview todo-row [{:keys [id]}]
(let [{:keys [title done?]} (h/sub [:todo/by-id id])]
[:li
[:span title (when done? " (done)") " "]
[:button {:on-click [:todo/toggle id]} "✓"]]))
(h/defview todo-list [_]
[:section
[:ul
(for [id (h/sub [:todo/ids])]
[todo-row {:key id :id id}])]
[:p (remaining-label)]])
[h/frame-root {:id :app :initial-events [[:todo/initialise]]}
[todo-list {}]]
Do not interchange the two forms:
;; Don't — a plain defn cannot be a Hiccup head
[row-icon {:kind :urgent}]
;; :rf.error/fresco-bad-head
;; Do
(row-icon {:kind :urgent})
The first mistake raises :rf.error/fresco-bad-head. The second gets no Fresco
error: a defview is a React component, which only React may call. Called
outside a render, it fails with React's invalid-hook error. Called inside
another view's body, it runs as part of that view and receives none of the
props you passed. Always mount it as a Hiccup head.
Keys go in the props map¶
(h/defview todo-list [_]
[:ul
(for [{:keys [id]} (h/sub [:todo/visible])]
[todo-row {:key id :id id}])])
Every member of a sequence of children needs a key. Put :key in that child's
props map. Fresco does not read Reagent-style ^{:key id} metadata, and for
does not invent a key.
Use a stable domain identity such as the todo's id, never an array index or the whole entity. Lists and collections explains why.
Props, children, and fragments¶
A view receives trailing children as a vector of Hiccup forms. Splice that vector into the result rather than inserting the vector as a single child:
(h/defview card [{:keys [title children]}]
(into [:section.card
[:h2 title]]
children))
[card {:title "Inbox"}
[todo-row {:id 1}]
[todo-row {:id 2}]]
Inserting it whole, as [:section.card [:h2 title] children], puts a vector of
forms where one form belongs and raises :rf.error/fresco-bad-head.
Return a fragment when the view needs several roots:
Equal props skip the body¶
Every Fresco view compares its complete props map with ClojureScript =. If
the props are equal to the previous render, the body does not run merely
because its parent ran. There is no public opt-out. A child that must change
with its parent should receive a prop that represents that change.
Two sources of invalidation still run the body:
- A subscription or context read made by the view itself changed. Its own pending update takes precedence over the props comparison.
- A prop compares unequal. Function-valued props and ordinary JavaScript objects use reference identity, so a fresh inline closure or fresh JS object defeats the bail-out on every parent render.
Reading a subscription in a parent and passing its result down remains correct. The parent invalidates when the read changes, and each descendant that receives a changed value gets unequal props. Moving the read closer to the view that displays it improves granularity; it is not required for correctness.
Attribute conversion¶
The attribute map remains ordinary Hiccup:
(h/defview title-input [_]
(let [invalid? (h/sub [:todo.ui/title-invalid?])]
[:input#title.form-control
{:type :text
:value (h/sub [:todo.ui/title])
:placeholder "Todo title"
:aria-label "Title"
:style {:margin-top 8}
:class ["is-wide" (when invalid? "is-invalid")]
:on-input [:todo.ui/set-title ::h/value]}]))
Five conversion rules cover the normal cases:
- Attribute names become React names. Kebab-case becomes camelCase, so
:on-clickbecomesonClick.:aria-*,:data-*, and CSS custom properties beginning--pass through.:class,:for, and:charsetbecomeclassName,htmlFor, andcharSet. - Values convert one level deep. Nested maps such as
:stylehave their keys converted, so:margin-topbecomesmarginTop. Keywords and symbols become their names. Functions cross by identity. :classaccepts several shapes. It may be a string, keyword, symbol, or collection.nilentries are removed and the rest are joined with spaces.- Tag shorthand composes with explicit props. Write the id before classes:
:input#title.form-control, not:input.form-control#title. An explicit:idwins over the shorthand id. Classes from the tag and:classare combined. :keyis consumed by the runtime. It is not emitted as a normal prop.
A reusable view that forwards a caller's attribute map while keeping its own value and handler is covered in Forward caller attributes safely.
Where h/sub may run¶
h/sub may run only during the direct synchronous execution of an active view
body. Branches, loops, and ordinary helper calls are included. Lazy sequences
used as Hiccup children are forced during the same Hiccup-to-element pass, so
their reads are still recorded by the active view.
A read deferred past that render raises
:rf.error/fresco-sub-outside-render and names the query. This includes a
callback, timer, promise, delayed computation, or lazy sequence forced later.
;; Don't — the read happens when the timer fires
(js/setTimeout
#(export! (h/sub [:todo/all]))
1000)
;; Read during render; start the timer only when the user clicks.
(h/defview export-button [_]
(let [todos (h/sub [:todo/all])]
[:button {:type "button"
:on-click (fn [_]
(js/setTimeout #(export! todos) 1000))}
"Export this list"]))
The callback retains the rendered snapshot of todos. Starting the timer in
the view body would itself be an effect during render, even with the read in
the right place.
For work that needs current state later, move the work into the event layer and
declare the state as a coeffect with :rf.cofx/requires. The handler then has
an explicit state dependency instead of a deferred view read.
Troubleshooting¶
| Symptom | Error or cause | Fix |
|---|---|---|
| A read made after rendering throws and names the query | :rf.error/fresco-sub-outside-render |
Read during the body and retain the value. Event handlers obtain current state through coeffects |
An unforced delay in props throws at the child view |
:rf.error/fresco-deferred-read-at-boundary |
Force it in the owning body or pass an ordinary function/value with an explicit contract |
A plain defn used as a Hiccup head throws |
:rf.error/fresco-bad-head |
Call the helper or define it with h/defview |
A view called as (todo-row {:id 7}) ignores its props, or fails with React's invalid-hook error |
The view was invoked as a function | Render [todo-row {:id 7}] |
| React warns about a missing key | A sequence member has no :key in its props map |
Put :key in each sequence member's props map; metadata is not read |
| The first render reports an unknown subscription | :rf.error/no-such-sub |
Require the namespace that registers the subscription before mounting |
| A child runs although its props look the same | A prop uses reference identity or one of the child's own reads changed | Hoist a function/JS object, pass persistent data, or inspect the child's own subscriptions |
| One state change re-renders many unrelated views | The subscription read is higher in the tree than necessary | Move the read into the view that displays the value |
| A body effect happens twice in development | React called the body twice, as StrictMode does in development |
Keep the body pure and move effects to the event/effect layer |
| Subscription instances are constantly recreated | Query arguments are not stable under = |
Use value-stable persistent arguments; fresh-but-equal persistent values are fine |
When not to create another view¶
Use a plain helper when the markup has no independent reads and should always render with its caller. Create a separate Fresco view when that part of the tree needs its own subscription tracking or props bail-out, not merely because the source became long.
If a measured region remains hot after fixing read placement and props stability, use the method in Performance before moving it to React (Islands).
Advanced¶
Head shapes and child values¶
The supported head shapes have different props and children contracts:
| Head | Props | Children | :key |
:ref |
|---|---|---|---|---|
Native tag — [:div …] |
attribute map | trailing forms | in the attribute map | callback ref, legal |
Fresco view — [todo-row …] |
one props map | trailing forms arrive as (:children props) |
in the props map; removed before the body sees props | not a view surface; use ids |
Fragment — [:<> …] |
none, except an optional map for :key and :ref |
trailing forms | in the fragment props map | passed to React's fragment |
Foreign host — h/defhost or [:>] |
converted according to the host declaration | Hiccup children become React elements | in props | callback ref, legal |
Nested and lazy child sequences are realized once and flattened one level.
nil and false render nothing. true raises
:rf.error/fresco-true-child. A string or number renders as text, and a keyword
or symbol as its name. An existing React element is a valid child. Any other
value, such as a whole entity map, is handed to React, which refuses it with
Objects are not valid as a React child. A
view may return nil, one root form, or a fragment.
How read tracking behaves¶
- A view records exactly the subscriptions read during that render. A branch that did not run contributes no dependency.
- Framework subscriptions — route identity, resource status, or machine tags — use the same tracking mechanism as application subscriptions.
- Subscription identity is
(query-id, args)under value equality. Rebuilding an equal persistent map produces the same cache key. A changed value, function argument, or JS object creates a different key because functions and JS objects compare by identity. - When the set of taken branches changes, the view refreshes the complete recorded set. Dynamic reads are supported; whole-set refresh is their cost.
The collector¶
Each Fresco view opens a collection window while its body runs. The body may probe subscription reads, but only a committed render installs them. A render that React retries or abandons therefore leaves no subscriptions behind.
Lazy child sequences are forced while the window is open, which is why their
reads are attributed correctly. Once the window closes, a deferred h/sub
call can no longer be assigned to a view and raises the named error instead of
creating a value that looks correct once and then stops updating.
An unforced delay passed through a view boundary raises
:rf.error/fresco-deferred-read-at-boundary before the child can retain a
read that will never update correctly.
A mutable thunk can attach the read to the wrong view
A thunk containing h/sub can be stored in a mutable reference and later
forced by another active view. It does not throw because a view is
rendering at that moment, but the read is recorded against the view that
forced the thunk rather than the code that created it.
;; Don't — whichever view invokes this thunk acquires the subscription
(reset! !later #(h/sub [:todo/all]))
The runtime does not trace subscription ownership through mutable references. Treat this as undefined behaviour and pass a value or explicit function input instead.