The native tier¶
Most Hicasso applications do not need native view code. Start with interpreted
Hiccup, tracked h/sub reads, and event vectors. Move a measured region only
when profiling identifies work that the normal topology cannot remove.
[...] always means interpreted Hiccup. n/$ always creates React elements
directly. The crossing is explicit in source, visible to diagnostics, and does
not replace the root, frame, or app-db. Applications that never require the
native namespace include none of its code.
Escalation ladder¶
| Rung | Authoring form | What changes | Use it when |
|---|---|---|---|
| 1. Ordinary Hicasso | Hiccup, h/sub, event vectors |
nothing | default for every screen |
| 2. Tuned Hicasso | same language | view boundaries, keys, read shape, chunking, or virtualization | profiling identifies topology cost |
3. Native return from defview |
a Hicasso view returns n/$ |
skips Hiccup interpretation for that result; retains the view's frame, reads, memo, and lifecycle | Hiccup construction is the measured owner |
| 4. Named native island | n/defcomponent or UIx with n/use-sub and n/use-frame |
hooks and high-rate local mechanics run in a real native component | hooks, vendor behaviour, reconciliation, or per-frame local work dominate |
| 5. Native screen | native namespace, UIx, or hosted JS/TS tree | that screen uses a React-first view language | the product surface is React-shaped by design |
Keep an escape only when the measured interaction improves materially: at least 20%, at least 2 ms at p95, or enough to move a user-visible budget from fail to pass. Otherwise remove it. A small explicit diff is easier to maintain than a permanent second authoring style with no demonstrated benefit.
That rule is about speed, and this ladder is the speed ladder. A crossing taken because a component has no interpreted spelling at all belongs to Interop and is judged by other questions; both descents, and which rule applies to which, are set out in The escape ladder.
Return native elements from a Hicasso view¶
Assume a windowed watchlist has stable keys and each visible row makes one render-ready subscription read:
(ns app.watchlist.row
(:require [re-frame.hicasso :as h]))
(h/defview quote-row [{:keys [sym]}]
(let [{:keys [px chg pct vol up?]}
(h/sub [:quotes/display-row sym])]
[:tr {:class (if up? "quote up" "quote down")
:on-click [:watchlist/select sym]}
[:td.sym sym]
[:td.px px]
[:td.chg chg]
[:td.pct pct]
[:td.vol vol]]))
If event attribution shows Hiccup construction, rather than subscriptions or
React commit, as the remaining cost, the same defview may return an existing
React element:
(ns app.watchlist.row
(:require [re-frame.hicasso :as h]
[re-frame.hicasso.native :as n]))
(h/defview quote-row [{:keys [sym]}]
(let [{:keys [px chg pct vol up?]}
(h/sub [:quotes/display-row sym])]
(n/$ :tr
{:class (if up? "quote up" "quote down")
:on-click (h/event [_]
[:watchlist/select sym])}
(n/$ :td {:class "sym"} sym)
(n/$ :td {:class "px"} px)
(n/$ :td {:class "chg"} chg)
(n/$ :td {:class "pct"} pct)
(n/$ :td {:class "vol"} vol))))
The parent still renders [quote-row {:key sym :sym sym}]. The Hicasso view
keeps its identity, equality memo, frame, subscription reads, lifecycle, and
Xray name. Only its returned subtree bypasses Hiccup interpretation.
The guide's worked measurement moved a 21 ms p95 tick to 13 ms: 38% and 8 ms recovered. Those numbers justify that example but are not a promise for another application; remeasure the actual interaction before keeping the crossing.
Important syntax changes:
- Hiccup selector shorthand has no native equivalent. Write class and id in props.
- Native event props require functions. Use
h/eventin a rung-3 body to create a frame-aware callback; an event vector is rejected. - Native children are ReactNode values. Nest
n/$forms. Convert a Hicasso subtree explicitly withh/as-element.
Retained by the enclosing defview |
Not provided inside the native result |
|---|---|
current frame and h/sub reads |
Hiccup interpretation |
| props ABI, equality memo, and parent's key | event-vector-to-callback conversion |
| lifecycle, HMR behaviour, and Xray name | controlled-input repair and reserved markers |
| deterministic intrinsic server output | structural tree assertions and Hicasso key diagnostics inside the opaque element |
Keep form controls interpreted. A raw React <input> created with n/$ does
not receive Hicasso's same-turn convergence, selection preservation, IME
protection, or ::h/revision handling.
Hooks also remain illegal in a defview body. Hicasso bodies may branch and
loop dynamically, which is incompatible with hook order. A needed hook is a
signal to use a named native island.
n/$ grammar¶
(n/$ head)
(n/$ head child*)
(n/$ head literal-props child*)
(n/$ head (n/props dynamic-props) child*)
Rules:
- An unqualified keyword names an intrinsic React element. A string names an intrinsic or custom element verbatim. Any other head expression must evaluate to a native React component.
- Only
nil, a literal ClojureScript map, a literal#jsobject, or(n/props expression)is classified as the props operand. Every other trailing form is a child. - Keys in a ClojureScript props map normalize to React slots: kebab-case to
camelCase,
:classtoclassName,:fortohtmlFor, withdata-*andaria-*unchanged. String keys pass verbatim. A JS object is not renamed. - Prop values pass by identity. There is no event-vector conversion, class collection join, style-map conversion, keyword value conversion, controlled-field repair, or deep conversion. Supply JS values explicitly where React or a library requires them.
- Children must already be valid ReactNode values. Collections are normally
JavaScript arrays of keyed elements.
:childrenin the props map is rejected because trailing forms are the one child channel. :keyand:refuse ordinary React slots. Two source keys that normalize to the same slot, such as:classand"className", are rejected rather than resolved by map order.
Mark dynamic props with n/props
The macro classifies props syntactically. A runtime map in second position is otherwise a child:
;; Don't
(let [cell-props {:class "px" :dir "ltr"}]
(n/$ :td cell-props px))
;; Do
(let [cell-props {:class "px" :dir "ltr"}]
(n/$ :td (n/props cell-props) px))
n/props adds no runtime wrapper. It only marks the operand. A CLJS map is
converted shallowly under the slot-name rule; a JS object passes through.
Named native islands¶
Use a top-level native component when the remaining owner is hooks, retained vendor behaviour, React reconciliation, or high-rate mechanics. The example below keeps drag motion in local React state and commits one domain fact to app-db when the pointer is released:
(ns app.watchlist.resizer
(:require ["react" :as react]
[re-frame.hicasso.native :as n]))
(n/defcomponent col-resizer
[^js props]
(let [col (.-col props)
{:keys [dispatch]} (n/use-frame)
committed (n/use-sub [:watchlist/col-width col])
[live set-live] (react/useState nil)]
(n/$ :div
{:class "col-resizer"
:role "separator"
:aria-orientation "vertical"
:on-pointer-down
(fn [e]
(.setPointerCapture (.-currentTarget e)
(.-pointerId e))
(set-live committed))
:on-pointer-move
(fn [e]
(set-live
(fn [width]
(some-> width (+ (.-movementX e))))))
:on-pointer-up
(fn [_]
(when live
(dispatch [:watchlist/set-col-width col live]))
(set-live nil))
:on-lost-pointer-capture
(fn [_]
(set-live nil))}
(when live
(n/$ :div
{:class "col-resizer__guide"
:style #js {:transform
(str "translateX("
(- live committed)
"px)")}})))))
The component ABI is one raw JS props object; children are at .-children.
An optional declaration map before the argument vector chooses
{:server :render} or {:server :client-only}. Omission defaults to
Client-only, which is appropriate for a pointer-only widget.
React hooks use their normal rules. n/use-sub is a hook and must be called
unconditionally at the top level. n/use-frame returns
{:frame :dispatch :dispatch-sync :subscribe} for the current frame and is
reference-stable during one frame incarnation.
A frame keyword is only an address. Destroying a frame and recreating it under the same id creates a new incarnation. A bundle retained across that change is silently inert; obtain frame operations from the live island rather than a global stash.
High-rate pointer updates remain local to the island. Dispatch only the fact that the rest of the application needs — the final width.
| Read API | Legal context | Rule |
|---|---|---|
h/sub |
synchronous Hicasso view body | ordinary function call; branches, loops, and helpers are legal |
n/use-sub |
native React component | React hook; top-level and unconditional |
Mount an island¶
Declare a native component as a host when interpreted Hiccup needs to render it:
Native code renders it directly:
Both stay under the existing root and frame. Repeated crossings should receive a named declaration for tooling and tests; the raw escape is for migration and one-off dynamic cases.
An intrinsic-headed n/$ result can render deterministically on the server.
A component-headed island reads its :server declaration. Under
{:server :render} the island is the element type: it renders into the server
response and hydrates against those bytes. Under the Client-only default it
contributes nothing at all — there is no native :fallback option, so markup
that should stand in for the region is written in the enclosing Hiccup, where
it is ordinary Hicasso.
Using UIx
A rung-3 defview may return a UIx element, and a rung-4 island may be a
defui. n/use-sub and n/use-frame work inside UIx components. The
native namespace does not import UIx and is intentionally not a full
component framework. Use UIx when a native region becomes substantial
React-first product code.
Preserve the native marker¶
n/defcomponent records a display name, native-tier marker, and server policy.
Xray and embedding helpers use that marker. Raw React wrappers remove it:
;; Don't — React behaviour works, but Hicasso loses the marker
(def quote-cell
(react/memo quote-cell*))
;; Do
(def quote-cell
(n/memo quote-cell*))
Use n/memo and n/lazy so the props/children ABI and marker survive
memoization or code-split loading. Refs need no helper; function components
receive the React ref slot through props.
Hot reload reallocates a native component. React sees a new element type and
remounts that subtree. Local hook state resets on save by design. State that
must survive code reload belongs in app-db and returns through n/use-sub.
Native screens¶
A canvas editor, diagramming surface, or vendor-grid screen may be React-shaped from its first useful design. Implement that screen natively under the same adapter, root, and frames. This changes only the view implementation for that screen; it does not justify a second state owner or independent React root.
An independent root is an isolation decision, not a performance optimisation.
Verify every crossing¶
The performance chapter owns the working method: reproduce, attribute with Xray, tune topology first, compare an escape, and retain it only if it meets the benefit rule.
After crossing, rerun the contracts that Hicasso can no longer inspect inside the native subtree:
- DOM and interaction parity
- focus and selection
- frame routing
- SSR and hydration
- cleanup and StrictMode behaviour
- the original performance script
Xray can name and time the native boundary and show n/use-sub reads, while
correctly labelling the inner React tree opaque. It does not silently pretend
to inspect native descendants.
When not to go native¶
Do not cross without a reproducible interaction and measured owner. Read placement, unstable props, excessive event volume, and uncontrolled DOM size must be fixed at the Hicasso level first.
Native construction does not solve controlled-input latency, and native inputs lose the Hicasso controlled-field contract. Keep those fields interpreted.
Do not create islands merely for stylistic consistency. A few named escapes are a boundary; islands throughout the application are a change of view-layer strategy.
Troubleshooting¶
| Symptom | Cause | Fix |
|---|---|---|
| A CLJS map is rejected as a React child | Dynamic map was not classified as props | Wrap the expression with (n/props m) |
A vector child is rejected inside n/$ |
Hiccup is not interpreted past the native fence | Convert with h/as-element or keep the subtree interpreted |
| An event vector in a native prop is rejected | Native props require functions | Use h/event in a rung-3 body or dispatch from n/use-frame in an island |
:children in the props map is rejected |
Native grammar has one trailing child channel | Move children after the props operand |
| Two prop keys are rejected as a slot collision | Both normalize to one React slot | Keep one canonical spelling |
n/use-sub or n/use-frame raises :rf.error/no-frame-context |
Component mounted outside a Hicasso frame provider | Mount under the application root or use the test kit's provider |
| Xray shows an anonymous native view | Raw react/memo or React.lazy removed the marker |
Use n/memo or n/lazy |
| Local island state resets after each code save | Hot reload allocates a new component and React remounts it | Expected; move persistent state to app-db |
| Native rewrite does not improve the measurement | Construction was not the actual cost owner | Remove the escape and return to topology/event attribution |
| A controlled field loses caret or composition behaviour | It was moved behind the native fence | Keep the field interpreted or implement the full React contract yourself |