Prototyper UI

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:

ComponentEventsDescription
ButtonpressUser clicks the button
Inputchange, blurValue changes, input loses focus
Textareachange, blurValue changes, textarea loses focus
SelectchangeSelection changes
CheckboxchangeChecked state changes
SwitchchangeToggled on/off
RadioGroupchangeSelected option changes
SliderchangeValue changes
TabschangeActive tab changes
AccordionchangeExpanded item changes
DialogcloseDialog 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 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> | void

ActionExecutionContext

FieldTypeDescription
getState(path: string) => unknownRead a value from state by JSON Pointer path.
setState(path: string, value: unknown) => voidWrite a value to state by JSON Pointer path.
navigate(url: string) => voidNavigate 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

FieldTypeDescription
titlestringDialog title. Supports ${/path} interpolation.
messagestring?Optional dialog body text. Supports interpolation.
confirmLabelstring?Label for the confirm button.
cancelLabelstring?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:

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

VariantShapeAvailable on
Navigate{ navigate: "/path" }onSuccess only
Set state{ set: { "/key": value } }onSuccess, onError
Chain action{ action: "name", params?: {...} }onSuccess, onError

Execution flow:

  1. The primary action handler runs
  2. On success: if onSuccess is defined, the follow-up runs (navigate, set state, or chain action)
  3. On error: if onError is defined, the follow-up runs with the error context. If onError is 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

On this page