Skip to Content
Event handling

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:

def.ts (you provide)
createComponentDefinition({ // … // the payload schema: what `evt` will carry callbacks: { onChange: z.strictObject({ value: z.string().meta({ description: "The current search value" }), }), onKeyDown: keyboardEventSchema, }, });
impl.tsx (you provide)
createComponentImplementation({ // … // fire it with a payload that matches the schema render: ({ onChange }) => ( <Input onChange={(e) => onChange({ value: e.target.value })} /> ), });
entry (the LLM generates)
// react to it; `evt` is the payload the impl passed { "callbacks": { "onChange": [{ "set": "scopes.root.q", "expr": "evt.value" }] } }

In the prompt:

partial prompt (generated from definition)
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:

  1. The impl calls onChange({ value: "abc" }).
  2. The engine binds evt = { value: "abc" } and plans the steps into dependency waves.
  3. Step one writes scopes.root.q = "abc".
  4. Step two reads q, so it waits for that write, then writes the result of searchTasks to scopes.root.results.
  5. Every props or each expression that reads q or results re-evaluates.

What the engine does with a callback

The engine, not the component, runs the entry’s steps. It:

  1. binds the payload as evt;
  2. runs the steps in dependency waves; a step that calls a host function is a barrier, so mutations never race;
  3. evaluates each expr with evt, scopes, the host functions and currentValue, and writes the result to the step’s set path;
  4. if a step has a confirm, asks first; on cancel, that step and every one after it are skipped;
  5. from a step with debounce on, waits for 300 ms of quiet, then runs the rest once, for the latest call;
  6. 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.

Last updated on