Composition and theming¶
You need more than “Hiccup in :children” — fixed regions, row renderers, safe
attribute forwarding, or library parts that consumers can style without forking
your control.
Use the lightest structural form that matches the job. Mixing forms is fine; inventing a slot registry or post-render tree rewrite is not. Host widgets are a separate plane (qualified leaves, behaviors, wrappers).
Props → trailing children → compound views → pure slots → declared child views.
The ladder (read top-down)¶
| Need | Form | Example |
|---|---|---|
| Plain data (string, number, enum, event vector) | prop | {:title "Cart" :on-close [:cart/close]} |
| One default / fixed body region | trailing children → reserved :children |
[panel {:title "…"} [body {}]] |
| Several fixed regions (trigger + panel, label + control) | compound child views | named child components for each region |
| Parameterised row / cell / item renderer (pure) | v/render-fn + v/slot |
table row body from the caller |
Stateful customisation (own sub, events, identity) |
declared child view | [person-row {:key id :id id}] |
Prefer the highest row that still fits. A pure string is a prop, not a slot. A pure row renderer is a slot, not a child view with empty events. A control that must subscribe is a view boundary, not a closure smuggled through a prop.
One props map¶
Every internal view receives one map. There is no second args vector of children the way some React APIs split “props” and “kids.”
(v/defview panel [{:keys [title children]}]
[:section.panel
[:h2 title]
children])
[panel {:key panel-id :title "Details"}
[details {:id panel-id}]]
Contract details that bite people:
| Rule | Meaning |
|---|---|
| Trailing children | become the reserved :children vector in the props map |
Caller :children in the props map |
rejected — write trailing forms, not a hand-built key |
:key |
sibling identity only; stripped before the body sees props |
| Children policy | descriptor metadata :children-policy (:none / :optional / :required) — a view can refuse children |
| Equality | internal props use re-frame value equality for memoisation |
| Host mutables | refs, instances, foreign handles → host boundary, not neutral props |
Optional props schemas (Malli vector form, closed by default) document and check
what a library leaf accepts. :key stays outside the schema; children policy is
descriptor metadata, not a prop field you invent ad hoc.
Declaring children policy¶
Children policy is descriptor metadata (product spine ABI field
:children-policy). Authoring puts the same key on the v/defview options
map alongside {:compiled true} when you need to state it. Values are exactly
:none, :optional, or :required — not a prop, and not a second map key
named :children (that name is reserved for the trailing-children vector
delivered into the body).
;; Leaf that must not be given trailing children
(v/defview icon-button
{:children-policy :none}
[{:keys [event label]}]
[:button {:on-click event :aria-label label}
[icon-glyph {:name label}]])
;; Default region allowed but optional
(v/defview card
{:children-policy :optional}
[{:keys [title children]}]
[:article.card
(when title [:header title])
(when (seq children) [:div.card-body children])])
;; Parent that requires a body region
(v/defview dialog-shell
{:children-policy :required}
[{:keys [title children]}]
[:section.dialog
[:h2 title]
[:div.dialog-body children]])
| Policy | Meaning |
|---|---|
:none |
trailing children are a finding / error — misuse is not silent drop |
:optional |
zero or more trailing children → :children vector (may be empty) |
:required |
at least one trailing child expected |
Omit the option when you accept the product default for application views
(typically optional). Library leaves that own a fixed chrome should set :none
or :required so the checker and catalog can prove the contract.
Trailing children (default region)¶
Use trailing children when the parent has one obvious content hole: the body of a card, the items of a list shell, the main of a layout.
(v/defview card [{:keys [title children]}]
[:article.card
(when title [:header title])
[:div.card-body children]])
[card {:title "Invoice"}
[line-items {:invoice-id id}]
[totals {:invoice-id id}]]
Both trailing forms land in one :children vector. The parent places that vector
where the default region lives. No registry, no string slot names.
Compound child views (fixed regions)¶
When a control has several fixed holes — trigger vs panel, toolbar vs canvas,
label vs control — prefer named child views (or named props that hold Hiccup
for pure markup) over overloading a single :children bag with positional magic.
;; Library shape (illustrative): fixed regions as props that hold structure,
;; or as dedicated child components the parent knows how to place.
(v/defview disclosure [{:keys [id label children]}]
(let [open? (v/sub [:disclosure/open? id])]
[:div.disclosure
[:button {:aria-expanded open?
:on-click [:disclosure/toggled id]}
label]
(when open? [:div.body children])]))
Rules of thumb:
- Fixed regions stay named in the parent’s contract (schema/docs).
- Event vectors inside those regions stay data — structural tests still see intent after composition.
- Do not use compound regions as a substitute for theming (
:partsis attrs-only — see below). Replacing structure is composition; recolouring is CSS/parts.
Parameterised content: v/render-fn and v/slot¶
Tables, lists, and grids often need “for each row, render this pure body.” That is not trailing children (the parent owns the loop) and not a full child view (the body may be a few cells with no state).
(v/defview data-table [{:keys [rows row]}]
[:table
[:tbody
(for [item rows]
[:tr {:key (:id item)}
(v/slot row item)])]])
[data-table
{:rows people
:row (v/render-fn [person]
[:<>
[:td (:name person)]
[:td (:email person)]])}]
| Piece | Job |
|---|---|
v/render-fn |
caller-supplied pure body; no sub, dispatch, hooks, or refs |
v/slot |
parent applies that body at a known site with arguments |
Interpreted mode may also accept an ordinary pure function for the same job.
Compiled mode needs a lexically visible v/render-fn / v/slot so the
analyzer can see the body (see Compilation).
When the row needs a subscription, its own events, or stable occurrence identity, extract a declared child:
(v/defview person-row [{:keys [id]}]
(let [person (v/sub [:person/by-id id])]
[:tr
[:td (:name person)]
[:td [:button {:on-click [:person/edit id]} "Edit"]]]))
;; parent
(for [id ids]
[person-row {:key id :id id}])
Do not hide sub inside an opaque function the parent treats as a “render prop.”
That breaks compilation, tools, and the “handlers are data” story.
Keys and repeated children¶
Repeated view children use real keys:
(v/defview todo-list [_]
[:ul.todo-list
(for [id (v/sub [:todo/visible-ids])]
[todo-row {:key id :id id}])])
| Practice | Why |
|---|---|
Stable identity in :key |
rows move with their cells; presence and memoisation work |
| Pass domain id as a prop too | the body must not re-derive identity only from React’s key |
Keyed for of declared views under compiled parents |
the closed loop grammar can see structure |
| Unkeyed dynamic lists | broken identity; compile error when compiled |
A plain helper can still build a deliberately coarse region; isolate changing rows as keyed declared children. No separate “region DSL” is required.
Spreading props (attribute forwarding)¶
Composition of structure uses the ladder above. Forwarding attributes
uses two common-grammar forms. This section is the canonical explanation;
events (controlled door) and
host boundaries (foreign v/spread) only point here.
Spreads do not replace children. Putting structure into a spread map is the wrong plane — use trailing children, compound regions, slots, or child views.
Two forms¶
| Form | Where | Promise |
|---|---|---|
v/spread-safe |
internal DOM / library element that must keep controlled + identity invariants | caller attrs sit under owned literals; forbidden keys cannot clobber the contract; preserves the controlled-input door |
v/spread |
foreign host head (open prop ABI) | owned literals still win collisions; forwarded map is otherwise opaque — does not claim the door |
Same meaning in interpreted and compiled mode. The classic “attrs-forwarding
wrapper” (labelled-field → inner input) is ordinary vocabulary, not a
compiled-only trick.
Shape¶
;; owned literals first, caller (or part) map second
(v/spread-safe
{:data-part "control"
:value value
:on-input (conj on-change ::v/value)}
caller-attrs)
;; foreign leaf — open remainder, owned contract wins
(v/spread
{:selected date
:on-change (v/event [v] [:picker/picked v])}
(dissoc props :date :on-pick))
v/spread takes one or two arguments: (v/spread base) forwards a map alone, and
(v/spread base overrides) lets the overrides win every collision. Every key is
judged by the rule a literal attribute key is judged by — the same refusals, read
off the same emitted slot — so a map assembled at run time cannot smuggle in a
spelling the grammar refuses at a visible site. :key is refused outright: it is
not an attribute, and it is literal at the element that carries it.
Merge law (v/spread-safe)¶
- Owned literals win. Caller keys never override what the component wrote for identity, control, or handlers it owns.
- Caller sits underneath for everything else (
class,data-*, approved ARIA/DOM attrs, extra listeners the component allows). :classcomposes — owned first, then the caller's, because a caller passing a utility class is adding to the element rather than replacing what the component put there.- Deny law (these may not appear in the caller map at all):
| Denied | Why |
|---|---|
:key, :ref |
occurrence identity and host handles are not caller toys |
:value, :checked |
the controlled contract |
the component's own on-* families, both bubble and capture phases |
library-wired intents stay the product contract |
That list is the whole deny law, and it runs in every build rather than only in development. An offender is a loud refusal, not a silent drop — and alternate spellings do not route around it, because a key is judged by the slot it is about to be written into, so a namespaced, string, symbol or already-camel spelling of a denied name is denied with it.
Structure is not on the list because it is not an attribute: putting children into a spread is the wrong plane entirely — use the composition ladder.
Why spread-safe exists¶
A dynamic props map on a controlled input normally forfeits the synchronous
controlled door into batching.
Libraries still need to forward consumer attrs — class, analytics data-*,
extra ARIA — onto that input.
v/spread-safe is the one dynamic-map form that keeps the door proof: owned
:value/:checked and the owned handler remain literal and eligible; the
forwarded residue cannot rewrite them. Opaque v/spread at a foreign widget
cannot claim that guarantee — foreign components own their own timing.
Worked example — library field¶
(v/defview field
[{:keys [label value on-change parts]}]
[:label {:data-component "my.lib/field"
:data-part "root"
:class "my-lib-field"}
[:span (v/spread-safe
{:data-part "label"}
(get parts :label))
label]
[:input
(v/spread-safe
{:data-part "control"
:value value
:on-input (conj on-change ::v/value)}
(get parts :control))]])
[field {:label "Email"
:value email
:on-change [:account/email-changed]
:parts {:control {:class "wide-control"
:data-analytics "signup-email"}}}]
The caller can restyle and annotate the control. They cannot pass
{:value other :on-input [:different/event] :key :new-id} through :parts and
steal the door. Full parts/theming policy: Theming and parts.
Worked sketch — foreign widget¶
(v/defview date-field [{:keys [date on-pick] :as props}]
[DatePicker
(v/spread {:selected date
:on-change (v/event [v] (on-pick v))}
(dissoc props :date :on-pick))])
Use v/spread (not spread-safe) when the head is foreign and open. The
literal owned keys still win collisions so a forwarded map cannot clobber your
committed callback. Callback phase/identity still follows the
escape roster.
Choosing quickly¶
| You are… | Use |
|---|---|
Forwarding onto an internal input / DOM node / library leaf that is controlled |
v/spread-safe |
Merging :parts attrs onto a declared data-part |
v/spread-safe |
| Forwarding remainder props onto a foreign React/host component | v/spread |
| Replacing a label, row body, or trigger structure | composition ladder — not a spread |
Hoping a dynamic map keeps the door without spread-safe |
it won’t — fix the merge |
Interpreted vs compiled¶
The taxonomy is common to both modes:
| Surface | Interpreted | Compiled |
|---|---|---|
Trailing :children |
same contract | same contract |
| Compound fixed regions | same | same |
| Parameterised row | ordinary pure fn OK | lexically visible render-fn / slot |
| Stateful customisation | declared child view | declared child view (statically named under compiled parents) |
| Cross-mode children | fine | statically named interpreted children of a compiled parent are explicit; no hidden walker |
Call sites stay [view props]. Structural tests should not care which mode produced
the tree for the common subset. See Compilation — seam laws.
What is not composition¶
| Temptation | Why it is wrong | Do this instead |
|---|---|---|
| Late tree→tree “theme” that rewrites Hiccup | smashes keys, controlled values, handlers; no runtime tree under compiled leaves | CSS / data-part / safe :parts + this ladder |
:parts that swap children or handlers |
parts are attrs-only via spread-safe |
structural form from the ladder |
Dynamic map on a controlled input without spread-safe |
forfeits the door into batching | v/spread-safe for the residue |
v/spread on an internal controlled input |
no door promise | v/spread-safe |
| Opaque reactive closure as a slot | hides sub / identity |
declared child view |
Emulating Radix asChild / prop cloning in neutral Hiccup |
compound React protocol | a React component, entered as a child |
Caller-built :children inside the props map |
reserved channel is trailing forms only | trailing children syntax |
| Unkeyed list “for convenience” | identity and presence break | stable :key per child |
Theming and parts¶
Library authors need a way to restyle controls without rewriting structure or
breaking controlled inputs. D018 settles Freehand’s contract: CSS cascade for
tokens, data-part / data-component for addresses, and bounded :parts
maps merged safely. No late tree-transform theme system.
App authors mainly consume data-theme and :parts from those libraries.
Two planes¶
| Plane | Job | Mechanism |
|---|---|---|
| Portable styling | colours, spacing, density, motion tokens | CSS custom properties + classes / data-theme on a root or scope |
| Structure | replace a label, row, or trigger | this page’s ladder: children, compound children, v/render-fn/v/slot, declared child views |
Do not use “theme” to mean “rewrite the Hiccup after analysis.” Compiled leaves
have no runtime tree for that, and uncontrolled rewrites can smash :key,
controlled values, and handlers.
Tokens: CSS, not reactive subs¶
Prefer:
.my-app[data-theme="dark"] {
--my-lib-field-bg: #1a1a1a;
--my-lib-field-fg: #f5f5f5;
}
.my-lib-field[data-part="control"] {
background: var(--my-lib-field-bg);
color: var(--my-lib-field-fg);
}
A theme switch that only flips data-theme (or a class) on a root re-renders
nothing in Freehand. That is intentional: no per-leaf token subscriptions, no
fan-out.
Reserve re-frame values for the genuinely dynamic residue (user density preference that must also drive layout math, not only paint).
Parts: stable addresses¶
Components emit literal part markers, scoped by a component marker:
(v/defview field
[{:keys [label value on-change parts]}]
[:label {:data-component "my.lib/field"
:data-part "root"
:class "my-lib-field"}
[:span (v/spread-safe
{:data-part "label"}
(get parts :label))
label]
[:input
(v/spread-safe
{:data-part "control"
:value value
:on-input (conj on-change ::v/value)}
(get parts :control))]])
Rules:
data-partis identity for styling and docs — classes carry variants only, not “which part this is.”- Renaming a public part id is a breaking library change.
- Document part ids in the component schema/catalog when you ship library leaves.
:parts maps: attrs only¶
Callers may pass bounded overrides:
[field {:label "Email"
:value email
:on-input [:account/email-changed ::v/value]
:parts {:label {:class "quiet-label"}
:control {:class "wide-control"
:data-analytics "signup-email"}}}]
Each declared part merges through v/spread-safe so the library’s owned
literals win and the controlled-input door stays intact. That is why :parts is
legal on controlled parts. Full merge law:
Spreading props.
This must not be allowed as “theming”:
;; illegal intent — must not replace controlled contract via :parts
{:parts {:control {:value other
:on-input [:different/event]
:key :new-identity}}}
Unknown part ids and denied attributes should become source-located development findings.
Structural customisation is not a part map — use the ladder above. Replacing a label with “icon + help popover” is structure, not CSS.
Interpreted vs compiled (theming)¶
The portable plane (CSS + data-part + safe :parts) works in both modes.
Do not design a library theme that requires interpreting compiled output at
runtime. Arbitrary late tree→tree transforms may exist as interpreted/test
tooling only — never as the portable Freehand theme contract.
Day-one checklist¶
- Start with props; add trailing children only for a real default region.
- Name fixed regions when there is more than one hole.
- Use
render-fn/slotonly for pure parameterised bodies. - Promote to a declared child view as soon as state or events appear.
- Key every dynamic list of views.
- Forward attrs with
spread-safe(internal/controlled) orspread(foreign) — never a bare dynamic map on a controlled door.
If something feels wrong¶
| Symptom | Fix |
|---|---|
| Controlled input lost door after attrs forward | v/spread-safe, not bare merge / v/spread |
| Part override stripped role | owned a11y is denied in part merges — keep library roles |
Slot body hides sub |
declared child view, not a reactive closure prop |
| Theme requires rewriting structure | CSS / data-part / :parts attrs — structure uses the ladder |
Library styling depth (tokens, parts maps): sections above. Under compilation, keep slot bodies lexically visible and child heads statically named.