Client state (defineStore)
defineStore — client state that is not server state. Selector-only reads, key scoping instead of providers, and SSR seeding that rides the payload the router already writes.
Server state already has a home: a subscription is live server state, and it stays live. What has no home is the rest — which rows are selected, which wizard step you are on, the draft you have not submitted.
Without a primitive for that you reach for zustand or jotai, which is a parallel runtime — the one thing the framework asks you not to bring. So it ships one.
// wizard.store.ts
import { defineStore } from '@voltro/client'
export const wizard = defineStore('wizard', () => ({ step: 0, draft: '' }))const step = wizard.use((s) => s.step) // the global instance
const step = wizard.use((s) => s.step, { key: orderId }) // one instance per order
wizard.set({ step: 2 })
wizard.set((s) => ({ ...s, step: s.step + 1 }))Reads go through a selector — there is no useStore()
A component that holds the whole state re-renders on every change to any field, so an API that hands it over would be used and would be wrong. Reading a slice re-renders only when that slice changes:
const coupon = wizard.use((s) => s.coupon) // set({ note }) does not re-render this
const count = cart.use((s) => s.items.length) // ['a'] → ['b'] does not re-render thisComputed values need equals: shallow
A selector that builds something — an object literal, a mapped or filtered array — returns a fresh reference every call, so the default identity check reports "changed" forever and the component re-renders on every store change:
import { shallow } from '@voltro/client'
const visible = cart.use((s) => s.items.filter((i) => i.visible), { equals: shallow })
const pair = cart.use((s) => ({ a: s.a, b: s.b }), { equals: shallow })You do not have to remember: in dev the framework detects the case and warns once, naming the fix. The selector is also memoised — it does not re-run while the state object is unchanged, so an expensive filter costs nothing on unrelated updates.
Scoping is by key, not by a Provider
A Provider re-renders every consumer when its value identity changes, whether or not that consumer read the field that moved — that is what makes context painful at scale. So an instance is addressed by a key you already have:
wizard.use((s) => s.step, { key: orderId })which is the same model as everywhere else in the framework: useSubscription('orders.list', { orgId }) is keyed by input, not by position in the tree. One read form, an optional key, no provider to forget.
wizard.release(orderId) drops an instance; wizard.keys() lists the live ones.
SSR seeding adds no new channel
Call seedStore anywhere on the server during a render and the value reaches the client's first render:
export const loader = async ({ params }) => {
seedStore(wizard, { step: 2 }, { key: params.orderId })
return { /* … */ }
}There is no dehydrate() to remember and no hydrate() to forget — the seed rides the hydration payload the router already writes, and mount() applies it before the tree exists. A step you can forget is a step somebody will.
On the client seedStore throws. A silent no-op would leave the store empty in the browser and full on the server, and that surfaces as a hydration mismatch that reads like a React bug.
The key is part of the address. Seed with { key: orderId } and read with { key: orderId }. A component reading the global instance while a loader seeded a key gets the initial value — correct, and easy to trip over once.
Surviving a reload
persist writes the state to localStorage (or sessionStorage) on every change and reads it back when the store is defined:
export const filters = defineStore(
'inbox:filters',
() => ({ status: 'open', sort: 'newest', draft: '' }),
{
persist: {
key: 'inbox:filters',
storage: 'local', // 'session' lasts the tab
pick: (s) => ({ status: s.status, sort: s.sort }),
migrate: (stored) => (isFilters(stored) ? stored : undefined),
},
},
)Three details are the whole reason this is in the framework rather than in your codebase, because a hand-rolled version gets all three wrong:
The stored value is merged over initial(), not substituted for it. Ship a new field and every returning user has state without it — undefined where the type promises a string. Merging means an old payload gains the new defaults.
migrate returning undefined discards the value. A stale draft is an annoyance; a half-migrated one is a bug report nobody can reproduce. Discarding is the right answer far more often than guessing, so it is the easy one to write.
Every storage touch is guarded and wrapped. The module is imported by the server render too, and Safari in private mode throws on reading localStorage, not just on writing. A store that throws at import time takes the page with it.
A persisted store on a server-rendered page hydrates against the server value. The server has no localStorage, so it renders initial(); the stored value lands in the commit right after hydration. Without that, every returning user would get a hydration mismatch — a flash and a console error that reads like a React bug. get() is not deferred, only the render: an action reading the draft before the first paint reads the draft.
pick narrows what gets written — persist the filters, not the open/closed state of every panel. And only the global instance persists: a keyed instance is per entity, and writing every key into one bucket grows without bound. Persist a map yourself if you mean to.
Actions that write more than once
An action rarely touches one field. Applying a coupon writes the coupon and the recomputed total; that is one thing the user did, and batch says so:
checkout.batch('applyCoupon', () => {
checkout.set({ coupon })
checkout.set({ total: recompute(coupon) })
})One notification, one entry in the devtools feed named applyCoupon, and one undo step. Without it the same action is three of each — Ctrl-Z walks back through a third of a change at a time, and the feed shows three anonymous writes instead of the thing that happened. React batches the re-renders on its own; it cannot batch the meaning.
If the callback throws, every write it made is rolled back. Nothing was announced yet, so an action that fails halfway cannot leave the half-applied state that is the usual reason people reach for a transaction. A nested batch joins its parent rather than opening a second one.
Async work goes around the batch, not inside it
const quote = await fetchQuote(coupon) // await FIRST
checkout.batch('applyCoupon', () => { // then batch the writes
checkout.set({ coupon, total: quote.total })
})Passing an async function to batch is an error, not a warning. Everything after the first await would run outside the batch — writes escaping one at a time, a rollback covering only the synchronous head, and a devtools entry that lies about what the action did. Holding a batch open across time is not available: it would have to block every other write for the duration.
Undo and redo
Every write passes through one seam, so the previous state is already recorded — undo is a lookup rather than a feature the store had to be designed around:
draft.undo(orderId) // back one write
draft.redo(orderId) // forward again
draft.canUndo(orderId) // for disabling the button
draft.canRedo(orderId)It is a cursor over an intact history, not a stack that consumes entries. So repeated calls walk back through the steps rather than toggling between the last two, and a new write after an undo drops the redo tail — the behaviour every editor has.
An undo never becomes undoable itself, and each keyed instance has its own history.
Devtools: inspect, and travel
The voltro dev overlay has a Stores tab. It lists every defined store with its live state — global and per key — and a feed of every write: which store, which key, the label if you passed one, and the fields that actually changed.
◀ Back and Forward ▶ step through that feed, restoring the state as it was before or after each write. The state a component reads moves with it, so you can walk back to the moment before a bug and watch it happen again.
No extension, no connector, no version to match. Every write already passes through one seam, so the panel is just another subscriber — it sees writes made by code that never heard of devtools, on any machine, including a colleague's.
What a store must never hold
Server data. Copying a subscription's rows into a store gives you a second copy that does not live; the page then renders the stale one, and the bug presents as "reactivity is broken". voltro doctor reports a *.store.ts that reads a subscription.
Read server state where you render it, and keep the store for what is genuinely client-side.