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.
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:
[::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:
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/eventcaptures 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;
nildispatches nothing. - An
h/eventbody may do imperative browser work such as.preventDefault. The::h/preventwrapper 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:
Ordinary functions remain available¶
Use a normal function when the callback is imperative and does not represent a re-frame event:
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.