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.
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.
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
stateitself, - 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 yourState.ctx.setState(patch)— merge aPartial<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:
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:
| Name | Type | Description |
|---|---|---|
| label | string | Display name — used for a toolbar control, and as the action's identity. |
| type | WindoActionKind | What kind of action it is. Defaults to "button". |
| options | WindoSelectOption[] | Choices for a "select" action — a string or { label, value }. |
| run | (ctx, arg) => void | The effect. Receives the live ctx; the second arg is an active flag, or the picked value for "select". |
| disabled | (ctx) => boolean | Optional predicate; greys out a toolbar control (button or select). |
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.
actions: [ { label: 'Show toast', run: c => c.setState({ tick: c.state.tick + 1 }) }, { label: 'Dismiss all', run: () => toast.dismiss() }, ]
The action kinds
type is one of five values — the set is exported as WINDO_ACTION_KINDS. They split into two families: a toolbar control you operate (button, select), or a binding to the preview stage's pointer events (enter, exit, hover).
| Kind | Fires on | run receives |
|---|---|---|
| button | A toolbar button you press (the default). | active: always true |
| select | A toolbar dropdown; fires when you pick an option. | value: the picked option |
| enter | Pointer enters the preview stage. | active: always true |
| exit | Pointer leaves the preview stage. | active: always true |
| hover | Either edge of a stage hover. | active: true on enter, false on leave |
button and select render UI in the canvas toolbar. The other three bind to pointer events over the stage and fire on their own — there's no control to operate.
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 button, enter, and exit, active is always true.)
Card uses exactly this to mirror a real hover state into the canvas:
state: { hovered: false },
actions: [
{ label: 'Hover', type: 'hover', run: (c, active) => c.setState({ hovered: active }) },
],
// the component then reads ctx.state.hoveredSelect actions
A select action renders a dropdown in the toolbar. It's a pure trigger: declare its options, and when the user picks one, run fires with the chosen value string. The control then snaps back to showing its label — it doesn't display or track a current value, so it never drifts out of sync with the state it drives.
Options are either bare strings (used as both label and value) or explicit { label, value } pairs. Either way run's second argument is the resolved value:
actions: [ { label: 'Size', type: 'select', options: ['sm', 'md', 'lg'], run: (c, v) => c.setState({ size: v }) }, ]
Disabling an action
A toolbar control — a button or a select — can carry a disabled predicate. It's a function of the live ctx, re-evaluated as state changes, and when it returns true the control greys out. Use it to gate an action on the current state — a "Dismiss" that only lights up once something has been shown:
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:
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.
Shared state across components
Everything so far has been local state — it belongs to one windo, and selecting a different component wipes it back to its initial value. That's the right default: a counter on a card has no business surviving into a toast. But some state is genuinely shared. A theme, a chosen language, a "current user" — these describe the whole canvas, and they shouldn't reset every time you click a different component.
That's ctxState: a single global bag, seeded once in windo.config.ts, that persists across selection and is readable and writable from every component.
export default { groups: [/* ... */], ctxState: { language: 'en' }, }
On the render context it surfaces as two fields that mirror the local-state pair:
ctx.ctxState— the current shared bag, aRecord<string, unknown>.ctx.setCtxState(patch)— shallow-merge a patch and re-render every consumer, not just the active windo.
The split is the whole point. Where setState touches one component and resets on selection, setCtxState writes to a value that outlives selection and that any component can read. A language switcher on one windo and a localized label on another talk to the same bag:
// a switcher component writes it { label: 'Español', run: c => c.setCtxState({ language: 'es' }) } // any other component reads it back component: (props, ctx) => <Label text={ctx.ctxState.language === 'es' ? 'Hola' : 'Hello'} />
The same value is editable from the chrome's Shared strip, so you can flip it by hand without an action. Its real power is driving a provider that wraps every opted-in component — paint a theme provider from ctx.ctxState.theme, and toggling that key from any component repaints the provider for whichever component is currently on the canvas.
Light, dark, and the topbar
Colour scheme is its own channel — not part of ctxState, but the built-in light/dark setting the chrome topbar toggles. A component or action can drive it too:
ctx.setColorScheme('light' | 'dark')— set it outright.ctx.toggleTheme()— flip between the two.
Both write the same ctx.colorScheme the topbar controls, so a toggle you build into a component stays in sync with the chrome — press either and both move together.
actions: [ { label: 'Toggle theme', run: c => c.toggleTheme() }, ]
Initial state from the environment
state doesn't have to be a fixed literal. It can also be a function of the environment, resolved once when the component is selected:
state: ctx => ({ hovered: false, scheme: ctx.colorScheme })
The ctx handed to that function is a WindoInitContext — the full render context minus state and setState. Those two are deliberately absent: you're defining the initial state, so it can't read or write itself yet. Everything else is there — ctx.colorScheme, ctx.viewport, ctx.ctxState, and so on.
The timing is worth holding onto: the initializer runs once per selection, against the environment as it stands at that moment. Re-select the component and it re-runs with the then-current values; but it does not track later changes — flip the viewport after selecting and the resolved state stays put. It's a snapshot, not a subscription. (For state that should follow the environment live, read ctx inside component instead.)
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
ctxexposes alongside state.