Actions & Events
Handle user interactions with built-in and custom action handlers
Actions connect user interactions to state changes and side effects. When a user clicks a button, types in an input, or interacts with any component, actions define what happens.
The on Field
Every element can define event handlers via the on field. Keys are event names, values are ActionBinding objects:
{
"type": "Button",
"props": { "label": "Save" },
"on": {
"press": {
"action": "setState",
"params": { "path": "/saved", "value": true }
}
}
}ActionBinding Type
interface ActionBinding {
/** Name of the action handler to invoke. */
action: string
/** Parameters passed to the handler (may contain expressions). */
params?: Record<string, unknown>
/** Optional confirmation dialog shown before execution. */
confirm?: ActionConfirm
/** Follow-up action to run on success. */
onSuccess?: ActionOnSuccess
/** Follow-up action to run on error. */
onError?: ActionOnError
/** If true, call event.preventDefault() before executing the action. */
preventDefault?: boolean
}
type ActionOnSuccess =
| { navigate: string }
| { set: Record<string, unknown> }
| { action: string; params?: Record<string, unknown> }
type ActionOnError =
| { set: Record<string, unknown> }
| { action: string; params?: Record<string, unknown> }Events by Component
Different components emit different events:
| Component | Events | Description |
|---|---|---|
| Button | press | User clicks the button |
| Input | change, blur | Value changes, input loses focus |
| Textarea | change, blur | Value changes, textarea loses focus |
| Select | change | Selection changes |
| Checkbox | change | Checked state changes |
| Switch | change | Toggled on/off |
| RadioGroup | change | Selected option changes |
| Slider | change | Value changes |
| Tabs | change | Active tab changes |
| Accordion | change | Expanded item changes |
| Dialog | close | Dialog is dismissed |
Built-in Actions
Stream UI includes 5 built-in action handlers that cover common state mutations. No custom code is needed for these.
setState
Set a value at a state path:
{
"action": "setState",
"params": { "path": "/form/submitted", "value": true }
}pushState
Append a value to an array at a state path:
{
"action": "pushState",
"params": {
"path": "/todos",
"value": { "id": "3", "title": "New todo", "done": false }
}
}If the value at path is not an array, a new array is created with the value as its only element.
removeState
Remove an element from an array by index:
{
"action": "removeState",
"params": { "path": "/todos", "index": 0 }
}toggleState
Toggle a boolean value at a state path:
{
"action": "toggleState",
"params": { "path": "/sidebar/expanded" }
}navigate
Navigate to a URL. Requires a navigate callback on the Renderer:
{
"action": "navigate",
"params": { "url": "/dashboard" }
}<Renderer
spec={spec}
registry={prototyperComponents}
navigate={(url) => router.push(url)}
/>Custom Action Handlers
Register custom handlers via the handlers prop on Renderer. Custom handlers are merged with the built-in actions (custom handlers take precedence for name conflicts):
import { Renderer } from "@prototyperai/stream-ui"
import { prototyperComponents } from "@prototyperai/stream-ui/components"
import type { ActionHandler } from "@prototyperai/stream-ui/core"
const customHandlers: Record<string, ActionHandler> = {
submitForm: async (params, ctx) => {
const formData = ctx.getState("/form")
await fetch("/api/submit", {
method: "POST",
body: JSON.stringify(formData),
})
ctx.setState("/form/submitted", true)
},
addTodo: (params, ctx) => {
const title = params.title as string
ctx.setState("/todos", [
...((ctx.getState("/todos") as unknown[]) ?? []),
{ id: Date.now().toString(), title, done: false },
])
},
}
<Renderer
spec={spec}
registry={prototyperComponents}
handlers={customHandlers}
/>ActionHandler Signature
type ActionHandler<TParams = Record<string, unknown>> = (
params: TParams,
ctx: ActionExecutionContext,
) => Promise<void> | voidActionExecutionContext
| Field | Type | Description |
|---|---|---|
getState | (path: string) => unknown | Read a value from state by JSON Pointer path. |
setState | (path: string, value: unknown) => void | Write a value to state by JSON Pointer path. |
navigate | (url: string) => void | Navigate to a URL (if provided on the Renderer). |
Confirmation Dialogs
Add a confirm field to show a confirmation dialog before executing the action:
{
"action": "removeState",
"params": { "path": "/todos", "index": 0 },
"confirm": {
"title": "Delete Todo",
"message": "Are you sure you want to delete this item?",
"confirmLabel": "Delete",
"cancelLabel": "Keep"
}
}The confirmation dialog is handled by the onConfirm callback on the Renderer:
<Renderer
spec={spec}
registry={prototyperComponents}
onConfirm={async (confirm) => {
return window.confirm(`${confirm.title}\n${confirm.message}`)
}}
/>onConfirm receives an ActionConfirm object and must return a Promise<boolean>. If it resolves to false, the action is not executed.
ActionConfirm Type
| Field | Type | Description |
|---|---|---|
title | string | Dialog title. Supports ${/path} interpolation. |
message | string? | Optional dialog body text. Supports interpolation. |
confirmLabel | string? | Label for the confirm button. |
cancelLabel | string? | Label for the cancel button. |
preventDefault
Set preventDefault: true on an action binding to call event.preventDefault() before the action handler runs. This is useful for suppressing default browser behavior such as form submission or link navigation:
{
"action": "submitForm",
"preventDefault": true
}A common use case is preventing a <form> element's native submit and handling it entirely through Stream UI actions:
{
"type": "Button",
"props": { "label": "Submit", "type": "submit" },
"on": {
"press": {
"action": "submitForm",
"params": { "formId": "contact" },
"preventDefault": true
}
}
}When preventDefault is omitted or false, the browser's default behavior proceeds normally.
Action Helpers
When building specs in TypeScript, the actionBinding helper provides shorthand constructors for common action binding patterns:
import { actionBinding } from "@prototyperai/stream-ui/core"
// Simple action
actionBinding.simple("submitForm", { formId: "contact" })
// Action with confirmation dialog
actionBinding.withConfirm("deleteItem", {
title: "Delete?",
message: "This cannot be undone.",
})
// Action with success/error handlers
actionBinding.withSuccess("saveData", { navigate: "/dashboard" })
actionBinding.withError("saveData", { set: { "/error": "Save failed" } })
// Built-in action shorthands
actionBinding.setState("/form/submitted", true)
actionBinding.pushState("/todos", { id: "3", title: "New", done: false })
actionBinding.removeState("/todos", 0)
actionBinding.toggleState("/sidebar/expanded")
actionBinding.navigate("/dashboard")See the API Reference for complete signatures.
Action Chaining
Use onSuccess and onError to chain follow-up actions after the primary handler completes. Each accepts one of three variants:
Navigate on success
Redirect the user after a successful action:
{
"action": "submitForm",
"params": { "formId": "contact" },
"onSuccess": { "navigate": "/thank-you" }
}Set state values
Write one or more state values directly (no action handler needed):
{
"action": "submitForm",
"params": { "formId": "contact" },
"onSuccess": { "set": { "/form/status": "success", "/form/submitted": true } },
"onError": { "set": { "/form/status": "error" } }
}Chain another action
Invoke a different action handler with its own parameters:
{
"action": "submitForm",
"params": { "endpoint": "/api/contact" },
"onSuccess": {
"action": "showNotification",
"params": { "message": "Form submitted!" }
},
"onError": {
"action": "logError",
"params": { "source": "contactForm" }
}
}Summary of variants
| Variant | Shape | Available on |
|---|---|---|
| Navigate | { navigate: "/path" } | onSuccess only |
| Set state | { set: { "/key": value } } | onSuccess, onError |
| Chain action | { action: "name", params?: {...} } | onSuccess, onError |
Execution flow:
- The primary action handler runs
- On success: if
onSuccessis defined, the follow-up runs (navigate, set state, or chain action) - On error: if
onErroris defined, the follow-up runs with the error context. IfonErroris not defined, the error is re-thrown.
Dynamic Action Parameters
Action parameters can contain expressions that are resolved before the handler is called:
{
"action": "setState",
"params": {
"path": "/greeting",
"value": { "$template": "Hello, ${/user/name}!" }
}
}State expressions and template interpolation in params:
{
"action": "submitForm",
"params": {
"email": { "$state": "/form/email" },
"message": { "$template": "From ${/form/name}: ${/form/message}" }
}
}In repeat contexts, $item in action params resolves to the absolute state path (not the value), allowing you to target specific array items:
{
"action": "removeState",
"params": {
"path": "/todos",
"index": { "$index": true }
}
}Watch Events
The watch field on elements triggers side-effect actions when state values change:
{
"type": "Card",
"props": {},
"children": ["content"],
"watch": ["/form/category"],
"on": {
"watch:/form/category": {
"action": "setState",
"params": { "path": "/form/subcategory", "value": "" }
}
}
}When /form/category changes, the watch:/form/category event fires and the bound action runs. This is useful for cascading resets, dependent field updates, and similar side effects.
Next Steps
- Visibility — Show/hide elements based on state
- Expressions — Dynamic values in props and params
- Validation — Field validation with built-in validators