Skip to content

Events as data

Hicasso accepts an event vector directly in an event attribute. The runtime creates the callback and dispatches that vector when the callback runs.

[:button {:on-click [:todo/toggle id]} "✓"]

The Hiccup tree still contains [:todo/toggle id], so tests and tools can inspect and compare the interaction as data. The generated callback also retains the frame of the view that created it, which makes the later browser event safe even though the original render has ended.

Any prop named on- followed by a letter is treated as an event position. CamelCase spellings such as onClick are also accepted for migration. Hicasso does not maintain a fixed roster of DOM event names. An event vector in one of these positions is called an intent.

Read values from the browser event

Most input handlers need .value or .checked from the event target. Hicasso replaces ::h/value and ::h/checked when the callback runs:

[:input {:value    (h/sub [:todo.ui/draft id])
         :on-input [:todo.ui/edit id ::h/value]}]

[:input {:type      :checkbox
         :checked   (h/sub [:todo/done? id])
         :on-change [:todo/set-done id ::h/checked]}]

The dispatched events are ordinary vectors such as [:todo.ui/edit 7 "milk"] and [:todo/set-done 7 true].

Marker replacement occurs only at the top level of the intent vector. Hicasso does not search nested data. When an intent contains no marker, the runtime does not read the DOM event.

The full reserved vocabulary is ::h/value, ::h/checked, ::h/prevent, and ::h/revision. The controlled-input chapter owns the round trip from subscription value to browser event and back.

Prevent browser defaults explicitly

Prevention is explicit, with exactly one exception — and the exception needs nothing written. An intent at :on-submit prevents the browser submission for you, because a form that dispatches and then reloads the page is never what the application meant.

[:form {:on-submit [:todo/submit]}
 [:input {:value    (h/sub [:todo.ui/draft])
          :on-input [:todo.ui/set-draft ::h/value]}]
 [:button {:type :submit} "Add todo"]]

At every other position, wrap the intent when the browser default must be prevented — most often an anchor being used as an application control:

[:a.nav-link
 {:href "#"
  :on-click [::h/prevent [:todo/filter-active]]}
 "Active"]

[::h/prevent INTENT] prevents the default and dispatches the one inner intent. A real navigation link should normally use the routing module rather than this pattern. A modifier-click on a real link must remain available to the browser, which is why Hicasso does not prevent clicks by default — and why submit is the only position that does. No second auto-preventing position will be added.

The exception is the data spelling only

A callback always owns its own event. {:on-submit (h/event [e] …)} is handed the event and is not auto-prevented: call .preventDefault yourself, or leave it out when a real browser submission is intended. That escape is how a form that must really submit opts out. Writing {:on-submit [::h/prevent [:todo/submit]]} still composes and still works; it is simply saying what the data spelling already does.

The wrapper must contain exactly one inner intent vector. A keyword instead of a vector, a second payload, or a nested decorator raises :rf.error/hicasso-malformed-prevent during rendering and names the attribute. Markers remain valid inside the inner intent:

[::h/prevent [:filter/set ::h/value]]

The wrapper is represented in the vector rather than metadata because metadata does not participate in =, printing, or hashes. Structural tests and tools must be able to observe the prevention decision.

One callback form: h/event

When a vector is not enough — a file list, drag payload, value-first foreign callback, or any calculation over the real arguments — use h/event. It expands to an ordinary function. The contract comes from the position where that function is written, not from a second API:

[:input {:type      "file"
         :on-change (h/event [e]
                      [:todo/attach
                       (js/Array.from (.. e -target -files))])}]
Position Contract for h/event (and for an intent at that slot)
Native :on-* event prop event — a returned vector is dispatched; other returns are ignored
defhost :callbacks entry As declared (:event, :handler, or :render)
Other walked prop (for example a foreign render prop) render — pure; return is output; dispatching inside is a loud error naming the position
defhost prop with no callback claim, or a declared ReactNode slot none — refused with :rf.error/hicasso-host-unclaimed-callback
:ref React's own contract; not lowered by Hicasso
Positions Hicasso does not walk Plain function behaviour

Rules that follow:

  • h/event captures the current frame where it is created.
  • The body receives every callback argument in the caller's order.
  • At an event position, a returned vector is dispatched; nil dispatches nothing.
  • An h/event body may do imperative browser work such as .preventDefault. The ::h/prevent wrapper is for the data-only intent form.
  • Ordinary unmarked functions remain legal and cross by identity, so there is no second “identity-preserving” form.
[:div {:on-drop (h/event [e]
                  (.preventDefault e)
                  (when-let [f (aget (.. e -dataTransfer -files) 0)]
                    [:todo/attach-dropped (.-name f)]))}]

Marker-carrying intents assume an event-first invoker: they read the DOM event from argument one. A value-first foreign component has no event there, so a marker-carrying intent raises :rf.error/hicasso-intent-needs-the-event. Use h/event and name the real arguments:

(h/event [date _event]
  [:todo/set-due date])

Ordinary functions remain available

Use a normal function when the callback is imperative and does not represent a re-frame event:

[:canvas
 {:on-pointer-move
  (fn [e]
    (draw! (.-clientX e) (.-clientY e)))}]

Typical cases include pointer geometry, pointer capture, stopPropagation, or an SDK call that is not an application event. Foreign render props and slots also use ordinary functions when the position is pure; dispatching from a render position raises :rf.error/hicasso-dispatch-in-render-position.

Do not hand-roll an ambient dispatch closure:

;; Don't
[:button
 {:on-click (fn [_]
              (rf/dispatch [:todo/toggle id]))}
 "✓"]
;; :rf.error/no-frame-context when the click runs

;; Do
[:button {:on-click [:todo/toggle id]} "✓"]

The browser invokes the callback after the rendering extent has gone, so an ambient rf/dispatch has no frame. Intents and h/event capture that context when the view is rendered.

Keyboard maps

A keyboard event position may contain a map from the DOM .key string to an intent:

[:input {:value       (h/sub [:todo.ui/draft id])
         :on-input    [:todo.ui/edit id ::h/value]
         :on-key-down {"Enter"  [:todo.ui/commit id]
                       "Escape" [:todo.ui/cancel id]}}]

Unlisted keys do nothing. The keys are strings, and keyboard maps are valid only at :on-key-down and :on-key-up. There is no modifier grammar; use h/event when the handler must inspect combinations such as Ctrl+Enter.

Keyboard maps also suppress application shortcuts during IME composition. Enter may be choosing a composition candidate and Escape may be cancelling the composition, so neither should dispatch the application's commit or cancel intent. The runtime performs this check centrally, including legacy browser signals described under Advanced.

Frame-safe callbacks and h/frame

Generated intent callbacks and h/event callbacks retain their view's frame. Application-owned async work should normally move to the event/effect layer, where an fx handler already receives the frame id in its context and :dispatch-later expresses delay as data.

A Hicasso view body does not have ambient frame lookup. Zero-arity (rf/capture-frame) refuses under Hicasso's render discipline. The author- facing frame read is h/frame: a plain function, legal only during a boundary body (or a render callback that boundary supplied), that returns the current frame id keyword. It is not a tracked subscription.

h/frame is exported today as h/hframe

This is the one door this guide spells differently from the code, and the difference is deliberate rather than a slip — Status records the whole of it. The samples below teach h/frame; to run them today, substitute h/hframe at each call site. Nothing else changes: same arity, same return, same legality rule.

The recommendation on record is to retire the verb rather than respell it, leaving rf/current-frame-id and rf/capture-frame as the frame doors they already are. That is why the guide has not simply been rewritten to h/hframe.

The carry spelling is composition with core's capture primitive:

(ns app.map
  (:require [re-frame.core :as rf]
            [re-frame.hicasso :as h]
            [app.sdk :as sdk]))

(h/defview map-panel [{:keys [id]}]
  (let [{:keys [dispatch]} (rf/capture-frame (h/frame))]
    [:div.map
     {:ref (fn [node]
             (when node
               (sdk/on-select
                 node
                 #(dispatch [:map/marker-selected id %]))))}]))

(rf/capture-frame (h/frame)) returns {:frame :dispatch :dispatch-sync :subscribe} bound to that frame. Prefer this at a foreign edge you do not control — an SDK attach ref, a value-first callback, a host slot that retains a closure.

A captured handle remains valid for that frame incarnation. Destroying the frame and creating another under the same id does not revive the old handle; using it raises :rf.error/frame-destroyed and does not reach the successor. Capture during the live render rather than keeping a global stash. Do not put the frame id into markup: on the server it is process-local identity and would break deterministic render-twice checks.

Calling h/frame outside a Hicasso render extent raises :rf.error/hicasso-frame-outside-boundary.

The practical rule is:

  • use an intent for an ordinary dispatching event
  • use an effect for application-owned async work
  • use (rf/capture-frame (h/frame)) for a closure retained by foreign code

A link whose job is navigation belongs to the routing module's route-link surface rather than a custom click handler.

Troubleshooting

Symptom Error or cause Fix
A form dispatches and then reloads the page An h/event or plain-function :on-submit — a callback owns its own event and is never auto-prevented Call .preventDefault in the callback, or use the data spelling {:on-submit [:todo/submit]}, which prevents for you
Rendering reports a malformed prevent wrapper :rf.error/hicasso-malformed-prevent Wrap exactly one inner intent vector; do not nest decorators or add a second payload
A handler receives the literal ::h/value keyword The marker was nested below the vector's top level Keep the marker at top level or calculate the payload with h/event/the event handler
A foreign callback rejects an intent that needs the event :rf.error/hicasso-intent-needs-the-event The callback is value-first. Use h/event and receive its actual arguments
Dispatch from a timer or interval throws :rf.error/no-frame-context Move application async work to an effect. For foreign retention, capture with (rf/capture-frame (h/frame)) during rendering
h/frame raises :rf.error/hicasso-frame-outside-boundary No Hicasso render extent Call it only inside a view body or a render callback that body supplied
Enter commits unfinished IME text A hand-written key handler bypassed the keyboard map Use the keyboard map so composition events are suppressed centrally
An intent fires but no handler runs :rf.error/no-such-handler Require the namespace that registers the handler before mounting
A vector is rejected at a host callback :rf.error/hicasso-intent-at-a-non-event-contract That host position is not declared as an event contract; supply the value its declaration requires
A render prop dispatches :rf.error/hicasso-dispatch-in-render-position Keep render props pure and move the dispatch to an event position
A captured callback reaches a destroyed frame :rf.error/frame-destroyed Recreate the callback from a render attached to the current frame incarnation

When not to use an intent

Use a plain function when you need the callback arguments but no dispatch: pointer coordinates, dataTransfer, DOM measurement, stopPropagation, or an imperative SDK operation.

Use h/event when the arguments are needed to decide which event vector to dispatch. An ordinary function is not an error; the intent form is simply the normal choice for declarative application interactions.

Advanced

IME detection in keyboard maps

IME composition is signalled in more than one way. Modern browsers expose isComposing on the native keyboard event, while some IME/browser combinations use legacy keyCode 229. React's synthetic keyboard event may not preserve the native isComposing value.

The runtime checks the native event and both signals. While composition is active, a keyboard map matches no application intent. Keeping this check in the runtime avoids treating candidate-selection Enter as submit or composition Escape as application cancel, both of which can discard user input.