Event handling
The definition declares an event’s payload, the
implementation fires it, and the entry says what
happens, with the payload as evt.
Declare, fire, handle
A search box fires onChange when the user types. Here is that event in the
definition, the implementation and the entry:
createComponentDefinition({
// …
// the payload schema: what `evt` will carry
callbacks: {
onChange: z.strictObject({
value: z.string().meta({ description: "The current search value" }),
}),
onKeyDown: keyboardEventSchema,
},
});createComponentImplementation({
// …
// fire it with a payload that matches the schema
render: ({ onChange }) => (
<Input onChange={(e) => onChange({ value: e.target.value })} />
),
});// react to it; `evt` is the payload the impl passed
{ "callbacks": {
"onChange": [{ "set": "scopes.root.q", "expr": "evt.value" }]
} }In the prompt:
Event handlers:
- onChange(evt)
- value: string — The current search value
- onKeyDown(evt: KeyboardEvent)onKeyDown is one line because its schema has a $id, which prints the payload
once for the whole catalog; see Shared events.
Following one keystroke
The same search box, with two steps:
{ "callbacks": { "onChange": [
{ "set": "scopes.root.q", "expr": "evt.value" },
{ "set": "scopes.root.results", "expr": "searchTasks({ q: scopes.root.q })" }
] } }A user types abc:
- The impl calls
onChange({ value: "abc" }). - The engine binds
evt = { value: "abc" }and plans the steps into dependency waves. - Step one writes
scopes.root.q = "abc". - Step two reads
q, so it waits for that write, then writes the result ofsearchTaskstoscopes.root.results. - Every
propsoreachexpression that readsqorresultsre-evaluates.
What the engine does with a callback
The engine, not the component, runs the entry’s steps. It:
- binds the payload as
evt; - runs the steps in dependency waves; a step that calls a host function is a barrier, so mutations never race;
- evaluates each
exprwithevt,scopes, the host functions andcurrentValue, and writes the result to the step’ssetpath; - if a step has a
confirm, asks first; on cancel, that step and every one after it are skipped; - from a step with
debounceon, waits for 300 ms of quiet, then runs the rest once, for the latest call; - yields for a tick between waves, so a write lands before a dependent expression reads it.
A callback the entry did not wire does nothing, so implementations never check.
Common patterns
Side-effect step: no set, so the result is dropped:
{ "expr": "track({ event: 'search' })" }Read-modify-write: currentValue is what the set path holds before the
write:
{ "set": "scopes.root.tags", "expr": "[...currentValue, evt.value]" }With scopes.root.tags seeded to [], each event appends to it.
Confirm first: confirm
is a key on the step:
{ "callbacks": { "onClick": [
{ "confirm": "Delete every completed task?", "expr": "deleteCompleted()" },
{ "set": "scopes.root.tasks", "expr": "listTasks()" }
] } }On decline, the refetch does not run either.
Search as you type: debounce
on the fetch, not on the write:
{ "callbacks": { "onChange": [
{ "set": "scopes.root.q", "expr": "evt.value" },
{ "set": "scopes.root.tasks", "expr": "searchTasks({ q: scopes.root.q })", "debounce": true }
] } }The field updates on every keystroke; the request goes out once, 300 ms after the last one.
Busy while refreshing: a flag around the fetch drives the table’s
loading:
{ "key": "rows", "component": "Table", "loading": "scopes.root.busy", "children": ["head", "body"] }
{ "callbacks": { "onPageChange": [
{ "set": "scopes.root.page", "expr": "evt.page" },
{ "set": "scopes.root.busy", "literal": true },
{ "set": "scopes.root.tasks", "expr": "listTasks({ page: scopes.root.page })" },
{ "set": "scopes.root.busy", "literal": false }
] } }The host call is a barrier, so busy turns false only after the rows land.