Interactivity

Handle actions, forms, and state in OpenUI components.

OpenUI components can be interactive. The Renderer manages form state automatically and exposes callbacks for actions and persistence.

Actions

When a user clicks a button or follow-up, the component calls triggerAction. The Renderer wraps this into an ActionEvent and fires onAction.

<Renderer
  library={myLibrary}
  response={content}
  onAction={(event) => {
    if (event.type === "continue_conversation") {
      // event.humanFriendlyMessage — button label or follow-up text
      // event.formState — field values at time of click
      // event.formName — scoping form name, if any
    }
  }}
/>

ActionEvent

FieldTypeDescription
typestringAction type (see built-in types below).
paramsRecord<string, any>Extra parameters from the component.
humanFriendlyMessagestringDisplay label for the action.
formStateRecord<string, any> | undefinedRaw field state at time of action.
formNamestring | undefinedForm that scoped the action, if any.

Built-in action types

// Dispatched via onAction callback
enum BuiltinActionType {
  ContinueConversation = "continue_conversation",
  OpenUrl = "open_url",
}
  • ContinueConversation: sends the user's intent back to the LLM (@ToAssistant).
  • OpenUrl: opens a URL in a new tab (@OpenUrl).

The following action steps are handled internally by the runtime (not dispatched to onAction):

  • @Run(ref): executes a Mutation or re-fetches a Query
  • @Set($var, value): changes a reactive $variable
  • @Reset($var1, $var2): restores $variables to their declared defaults

In openui-lang, actions are composed with Action([...]):

submitBtn = Button("Create", Action([@Run(createResult), @Run(tickets), @Reset($title)]))

See Reactive State for $variables and Queries & Mutations for @Run.

Inline mode

When inlineMode is enabled in the prompt config, the LLM can respond with either:

  • Code (fenced in triple backticks), for creating or changing the UI
  • Text only, for answering questions without modifying the UI

The parser extracts code from fences automatically. Text outside fences is shown as chat.

Using triggerAction in components

Inside defineComponent, use the useTriggerAction hook:

const MyButton = defineComponent({
  name: "MyButton",
  description: "A clickable button.",
  props: z.object({ label: z.string() }),
  component: ({ props }) => {
    const triggerAction = useTriggerAction();
    return <button onClick={() => triggerAction(props.label)}>{props.label}</button>;
  },
});

triggerAction(userMessage, formName?, action?) accepts optional second and third arguments.


Reactive state ($variables)

In openui-lang, $variables create reactive state that components can read and write. When the LLM generates $days = "7" and passes it to a Select, the runtime creates a two-way binding automatically.

For component authors building custom libraries, the useStateField hook provides this binding:

import { useStateField, reactive } from "@openuidev/react-lang";

const MySelect = defineComponent({
  name: "MySelect",
  props: z.object({
    name: z.string(),
    value: reactive(z.string().optional()), // reactive() marks this prop as accepting $variable binding
    items: z.array(SelectItem.ref),
  }),
  component: ({ props }) => {
    const field = useStateField(props.name, props.value);
    // field.value — current value (reads from $variable or form state)
    // field.setValue(val) — updates the $variable and triggers re-evaluation
    return (
      <select value={field.value ?? ""} onChange={(e) => field.setValue(e.target.value)}>
        {/* ... */}
      </select>
    );
  },
});

useStateField unifies form state and reactive $variable binding. When the prop is a $variable, setValue updates the store and triggers all dependent queries and expressions to re-evaluate.

See Reactive State for the language-level documentation.


Form state

The Renderer tracks field values automatically. Components use useStateField (preferred) or the lower-level useSetFieldValue and useGetFieldValue to read and write state.

Persistence

Use onStateUpdate to persist field state (e.g. to a message in your thread store) and initialState to hydrate it on load.

<Renderer
  library={myLibrary}
  response={content}
  onStateUpdate={(state) => {
    // state is a raw Record<string, any> of all field values
    saveToBackend(state);
  }}
  initialState={loadedState}
/>

onStateUpdate fires on every field change. The state format is opaque, so persist and hydrate it as-is.

Field hooks

Use these inside defineComponent renderers:

HookSignatureDescription
useStateField(name: string, value?: unknown) => { value: unknown, setValue: (v: unknown) => void }Preferred. Unified form state + reactive $variable binding.
useGetFieldValue(formName: string | undefined, name: string) => anyRead a field's current value.
useSetFieldValue(formName: string | undefined, componentType: string | undefined, name: string, value: any, shouldTriggerSaveCallback?: boolean) => voidWrite a field value.
useFormName() => string | undefinedGet the enclosing form's name.
useSetDefaultValue(options: { formName?, componentType, name, existingValue, defaultValue, shouldTriggerSaveCallback? }) => voidSet a default if no value exists.

Validation

Form fields can declare validation rules. The Form component provides a validation context via useFormValidation.

interface FormValidationContextValue {
  errors: Record<string, string | undefined>;
  validateField: (name: string, value: unknown, rules: ParsedRule[]) => boolean;
  registerField: (name: string, rules: ParsedRule[], getValue: () => unknown) => void;
  unregisterField: (name: string) => void;
  validateForm: () => boolean;
  clearFieldError: (name: string) => void;
}

Built-in validators include required, minLength, maxLength, min, max, pattern, and email. Custom validators can be added via builtInValidators.


On this page