Skip to content

Events as data

Fresco 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. Fresco 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. Fresco 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. Fresco 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 Fresco 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/fresco-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)
An on* prop — on a native tag, a defhost or a [:>] crossing alike event — a returned vector is dispatched; other returns are ignored
Any other walked prop (for example a foreign render prop) render — pure; return is output; the wrapper carries the supplying view's frame, so intents inside the returned markup dispatch there
:ref React's own contract; not lowered by Fresco
Positions Fresco does not walk Plain function behaviour

The contract is inferred from the spelling at a host exactly as at a native tag. Two exceptions, both declared on the host: a :callbacks override, {:callbacks {:on-render-item :render}}, for a vendor's on*-named render prop; and a declared ReactNode :slots position, which is markup and refuses h/event with :rf.error/fresco-host-unclaimed-callback. See Interop.

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/fresco-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: the return is the render output, and nothing is dispatched from it.

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 rf/capture-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 Fresco view body does not have ambient frame lookup: an ambient rf/subscribe or rf/dispatch written in a body refuses under Fresco's render discipline, because a read there would contribute no edge and a dispatch there would run in the render phase. The two frame doors are core's own, and they are legal inside a body and inside a render callback the body supplied: (rf/current-frame-id) returns the rendering boundary's frame id keyword, and zero-arity (rf/capture-frame) captures a frame api locked to it. Neither is a tracked subscription, and neither reads nor dispatches, which is why the render discipline admits them. The spelling is the one every other adapter writes; Fresco has no frame verb of its own.

The carry spelling is core's capture primitive:

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

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

(rf/capture-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 the determinism check (re-frame.fresco.test.server/render-twice).

Outside any scope at all, both doors raise core's :rf.error/no-frame-context. An enclosing rf/with-frame that names a frame other than the one the boundary renders raises :rf.error/ambient-frame-refused, naming both frames: one body has one frame, and the doors will not answer the wrong one.

The practical rule is:

  • use an intent for an ordinary dispatching event
  • use an effect for application-owned async work
  • use (rf/capture-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/fresco-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/fresco-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) during rendering
(rf/capture-frame) in a body raises :rf.error/ambient-frame-refused naming two frames An enclosing rf/with-frame names a frame the boundary is not rendering Drop the enclosing scope, or scope it to the boundary's own frame
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 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.