Skip to content

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})
;; Don't — a defview is not called directly
(todo-row {:id 7})

;; Do
[todo-row {:id 7}]

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:

(h/defview toolbar [_]
  [:<>
   [save-button {}]
   [cancel-button {}]])

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:

  1. A subscription or context read made by the view itself changed. Its own pending update takes precedence over the props comparison.
  2. 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-click becomes onClick. :aria-*, :data-*, and CSS custom properties beginning -- pass through. :class, :for, and :charset become className, htmlFor, and charSet.
  • Values convert one level deep. Nested maps such as :style have their keys converted, so :margin-top becomes marginTop. Keywords and symbols become their names. Functions cross by identity.
  • :class accepts several shapes. It may be a string, keyword, symbol, or collection. nil entries 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 :id wins over the shorthand id. Classes from the tag and :class are combined.
  • :key is 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

  1. A view records exactly the subscriptions read during that render. A branch that did not run contributes no dependency.
  2. Framework subscriptions — route identity, resource status, or machine tags — use the same tracking mechanism as application subscriptions.
  3. 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.
  4. 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.