Views and reads¶
A Hicasso 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.hicasso :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.
h/sub is the only read form in a Hicasso body. A bare rf/subscribe is not an
untracked alternative; it throws rather than resolving. 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 Hicasso 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 Hicasso 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 Hicasso
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.
Do not interchange the two forms:
;; Don't — a plain defn cannot be a Hiccup head
[row-icon {:kind :urgent}]
;; :rf.error/hicasso-bad-head
;; Do
(row-icon {:kind :urgent})
The first mistake raises :rf.error/hicasso-bad-head. A direct defview call
throws at the call site and names the view. A defview never turns into an
inline helper because it was called with function syntax.
Keys go in the props map¶
Every member of a sequence of children needs a key. Put :key in that child's
props map. Hicasso does not read Reagent-style ^{:key id} metadata, and for
does not invent a key. Missing keys produce
:rf.warning/hicasso-missing-key in development, naming the view and child.
This page owns the spelling. Lists and collections explains key quality: use a stable domain identity, never an array index or the whole entity.
Props, children, and fragments¶
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 |
Hicasso 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 a key-bearing fragment props map | trailing forms | in the fragment props map | none |
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/hicasso-true-child. An existing React element is a valid child. A
view may return nil, one root form, or a fragment. React consumes :key, so
it never appears in the props map received by the view body.
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}]]
Return a fragment when the view needs several roots:
View bodies must be pure and safe to run again. React StrictMode invokes them twice in development. Mutation of a captured atom, starting a fetch, or using the body as a render counter therefore belongs elsewhere.
Equal props skip the body¶
Every Hicasso 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.
The reserved data vocabulary is intentionally small:
::h/value,
::h/checked,
::h/prevent, and
::h/revision. Events and controlled inputs own the
behaviour of those values.
Forward attributes with owned keys last¶
A reusable field can accept caller attributes while retaining control of its
value and handler. Use a normal merge, placing the keys owned by the field
last:
(h/defview search-field [{:keys [id] :as attrs}]
[:input.form-control
(merge (dissoc attrs :id)
{:value (h/sub [:todo.ui/search id])
:on-input [:todo.ui/set-search id ::h/value]})])
The literal entries written by the field win by presence, not truthiness. The
caller may add :placeholder, :aria-label, :data-testid, or a class, but
cannot replace the owned :value or :on-input because those keys are merged
last. Classes written in the tag still combine with a surviving caller
:class.
When a caller should control a value, omit the owned literal instead of trying
to override it. Forward maps should use the same kebab-keyword spelling as
Hiccup; a foreign props object's "className" string is a different merge
key. Controlled inputs also reserve their checked, key, and revision slots, as
described in Controlled inputs.
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/hicasso-sub-outside-render and names the query. This includes a
callback, timer, promise, delayed computation, or lazy sequence forced later.
An unforced delay passed through a view boundary raises
:rf.error/hicasso-deferred-read-at-boundary before the child can retain a
read that will never update correctly.
;; Don't — the read happens when the timer fires
(js/setTimeout
#(export! (h/sub [:todo/rows]))
1000)
;; Do — read now and retain the value
(let [rows (h/sub [:todo/rows])]
(js/setTimeout #(export! rows) 1000))
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.
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/rows]))
The runtime does not trace subscription ownership through mutable references. Treat this as undefined conduct and pass a value or explicit function input instead.
How read tracking behaves¶
Four facts explain the observable behaviour:
- 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.
Troubleshooting¶
| Symptom | Error or cause | Fix |
|---|---|---|
| A read made after rendering throws and names the query | :rf.error/hicasso-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/hicasso-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/hicasso-bad-head |
Call the helper or define it with h/defview |
Calling a defview directly throws |
The view was invoked as a function | Render [todo-row {:id 7}] |
| Development warns about a missing key | :rf.warning/hicasso-missing-key |
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 StrictMode invoked the body twice | 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 Hicasso 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 the native tier.
Advanced¶
The collector¶
Each Hicasso view has one runtime hook that opens a collection window while the 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.