windo/Concepts/State & actions
State & actions

State & actions

Props describe how a component looks when it mounts. Some components also do things — a toast fires, a card reacts to a pointer, a menu opens. windo gives a windo a small slice of component-local state and a set of actions that drive it, so you can rehearse those behaviours in the canvas instead of only their resting states.

The state object

A windo can carry a state object: an initial bag of key/value pairs that lives across renders and is editable from outside the component. It's optional — windos that only document a resting prop set don't need it. But the moment a component has behaviour worth showing — a counter, an open flag, a "fired" tick — that behaviour belongs in state, not in the props.

The distinction matters. Props are the JSON-editable surface validated by your schema; they answer what does this look like. State answers what is it doing right now. A toast's variant and title are props; the fact that one is currently on screen is state.

TSX
state: { tick: 0 }

One generic, three jobs

state is typed by the second generic on the factory: windo<Props, State>(...). You write it once, and that single type flows everywhere state is touched.

TSXtoast.windo.tsx
export default windo<ToastProps, { tick: number }>(() => ({
// ...
state: { tick: 0 },
}))

That one annotation is deliberate. There are three places state shows up at render time, and a single explicit generic drives all of them:

  • the shape of state itself,
  • the value you read back as ctx.state,
  • the patch you hand to ctx.setState.

Because the type lives on the factory, none of those need a per-call generic. ctx.state.tick is a number; ctx.setState({ tick: 1 }) type-checks; ctx.setState({ tikc: 1 }) does not. The compiler keeps the initial value, the reads, and the writes in lockstep from one source.

Reading and writing through ctx

Every render-time function on a windo receives the live WindoRenderContext as its second argument — component, each action's run, and so on. Two of its fields are the state channel:

  • ctx.state — the current state, typed as your State.
  • ctx.setState(patch) — merge a Partial<State> into it. The merge re-renders the preview, and the chrome echoes the new state as a read-only strip so you can see exactly what the component is reacting to.

setState takes a partial patch, not a full replacement — pass only the keys that changed and the rest are preserved. Here the component reads ctx.state to decide what it renders:

TSXcard.windo.tsx
component: (props, ctx) => (
<Card {...props} eyebrow={ctx.state.hovered ? 'Hovering ✦' : props.eyebrow} />
)

Actions

Reads happen inside the component. Writes happen from actions — out-of-band entry points that mutate state without the user touching the prop editor. A windo's actions array holds them, and each action is a small object:

NameTypeDescription
labelstringDisplay name — used for a toolbar button, and as the action's identity.
onWindoActionTriggerHow the action fires. Defaults to "click".
run(ctx, active) => voidThe effect. Receives the live ctx and an active flag.
disabled(ctx) => booleanOptional predicate; greys out a click action's button.

Toast's two actions are the canonical shape. One bumps the tick — the component turns each new tick into a fired toast — and the other reaches past windo entirely to clear Sonner's stack. run is free to do whatever a render-time function can: call setState, fire an imperative API, log.

TSXtoast.windo.tsx
actions: [
{ label: 'Show toast', run: c => c.setState({ tick: c.state.tick + 1 }) },
{ label: 'Dismiss all', run: () => toast.dismiss() },
]

The four triggers

on is one of four values — the set is exported as WINDO_ACTION_TRIGGERS. They split into two families: a toolbar button, or a binding to the preview stage's pointer events.

TriggerFires onactive flag
clickA toolbar button you press (the default).always true
enterPointer enters the preview stage.always true
exitPointer leaves the preview stage.always true
hoverEither edge of a stage hover.true on enter, false on leave

click is the one that renders UI: it adds a button to the canvas toolbar, and pressing it runs the action. The other three bind to pointer events over the stage and fire on their own — there's no button.

hover is the interesting one. Where enter and exit are two separate one-shot actions, hover is a single action that runs on both edges, distinguished by the second argument to run. That active flag is true on pointer-enter and false on pointer-leave — so one action expresses an entire hover state. (For click, enter, and exit, active is always true.)

Card uses exactly this to mirror a real hover state into the canvas:

TSXcard.windo.tsx
state: { hovered: false },
actions: [
{ label: 'Hover', on: 'hover', run: (c, active) => c.setState({ hovered: active }) },
],
// the component then reads ctx.state.hovered

Disabling an action

A click action can carry a disabled predicate. It's a function of the live ctx, re-evaluated as state changes, and when it returns true the toolbar button greys out. Use it to gate an action on the current state — a "Dismiss" that only lights up once something has been shown:

TSX
actions: [
{ label: 'Show toast', run: c => c.setState({ tick: c.state.tick + 1 }) },
{
  label: 'Dismiss all',
  run: () => toast.dismiss(),
  disabled: c => c.state.tick === 0,
},
]

Logging to the Console

When an action does something you can't see in the rendered output — a network call, a value you want to inspect — write it to the chrome's Console tab with ctx.logger.log(...). It takes the same variadic arguments as console.log, and each call posts one entry to the Console strip:

TSX
actions: [
{
  label: 'Show toast',
  run: c => {
    c.logger.log('fired toast', c.state.tick + 1)
    c.setState({ tick: c.state.tick + 1 })
  },
},
]

That keeps action behaviour observable without reaching for the browser devtools — the log lands in the same chrome that shows the state strip, right next to the component it describes.

Where next

  • Configurable props — the JSON-editable, zod-validated half of a windo, and how it differs from state.
  • Contexts — ambient values and providers that the same ctx exposes alongside state.