state
Some values belong to the whole app rather than one component: the order Ada is putting together before she submits it, whether the sidebar is open, which currency Linus prefers. @kitr/state gives you a small store for those values that any component can read and update, and that can remember them across page reloads.
It is Zustand, re-exported, plus one helper, createKitrStore, that makes a store safe for server rendering, saves it in the browser when you ask, and connects it to the Redux DevTools in development.
It runs in the browser, and on the server while a page renders. For data a server owns, such as the list of orders or the signed-in user, use Query instead.
This guide builds Acme's order draft: the lines Ada adds before she places an order at app.acme.com.
When to use it
Use a store for client state: values that live in the browser and that no server owns.
- A draft order or shopping cart before checkout.
- UI preferences such as theme, density, currency or a collapsed sidebar.
- A multi-step wizard's progress.
- A selection shared by distant components, such as the invoices ticked in a table and the bulk-action bar above it.
Don't use a store for server data such as Acme's orders, invoices or projects. That belongs in a query (see Query), which caches it, refetches it and updates it after a save. A copy in a store goes stale the moment someone else changes the record.
Install
kitr add stateFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
What kitr add does:
- Installs
@kitr/stateandzustand. - Adds
@kitr/statetopackagesinkitrrc.ts. There is no provider to register. - Writes an empty
app/stores/.gitkeep, so the folder for your stores exists. - Adds a
## kitr: statesection toAGENTS.md: client state only,kitr make:store <name>writesapp/stores/<name>.tswithcreateKitrStore, and server data belongs in queries.
kitr add state asks no questions. The app, fullstack, saas, desktop and mobile kits include it. To preview the changes without writing them:
kitr add state --dry-runFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
kitr remove state removes the packages entry, both dependencies and the AGENTS.md section. It leaves app/stores/ and your stores in place, and lists the files that still import @kitr/state.
Configure
@kitr/state has no config file. Each store takes its options as the first argument of createKitrStore:
// app/stores/preferences.ts
import { createKitrStore } from '@kitr/state'
interface PreferencesState {
currency: 'USD' | 'EUR'
setCurrency: (currency: 'USD' | 'EUR') => void
}
export const [usePreferences, PreferencesProvider, preferencesStore] =
createKitrStore<PreferencesState>(
{
name: 'preferences',
persist: { version: 1 },
devtools: true,
},
(set) => ({
currency: 'USD',
setCurrency: (currency) => set({ currency }),
}),
)
| Option | Default | Description |
|---|---|---|
name |
required | The store's name. It labels the store in the Redux DevTools and builds the storage key kitr:<name>. |
persist |
false |
true saves the whole state to localStorage. An object saves it with any of Zustand's persist options. Only the browser reads or writes storage. |
persist.name |
'kitr:<name>' |
The storage key. |
persist.storage |
localStorage, as JSON |
Where to save. Use createJSONStorage(() => sessionStorage) from zustand/middleware for one tab only. |
persist.partialize |
the whole state | A function that picks what to save. |
persist.version |
0 |
The saved shape's version. Saved data with another version goes through migrate, or is dropped without one. |
persist.migrate |
none | A function (saved, version) => state that upgrades old saved data. |
devtools |
true |
Connects the store to the Redux DevTools extension in development. Set false to leave it out. Production builds never connect. |
The second argument is a Zustand state creator: a function that gets set, get and the store, and returns the initial state with its actions.
createKitrStore returns three things, in this order:
| Value | Description |
|---|---|
useStore |
The hook. useStore(selector) returns a slice; useStore() returns the whole state. |
StoreProvider |
A component that gives its subtree a store. Needed on the server; optional in the browser. Takes initialState. |
store |
The browser's store instance, for code outside components. null on the server. |
Name them after the store, as in [useOrderDraft, OrderDraftProvider, orderDraftStore].
Create your first store
Put each store in app/stores/. This one holds the order Ada is building:
// app/stores/order-draft.ts
import { createKitrStore } from '@kitr/state'
export interface DraftLine {
sku: string
name: string
price: number
quantity: number
}
interface OrderDraftState {
customer: string
lines: DraftLine[]
setCustomer: (customer: string) => void
addLine: (line: Omit<DraftLine, 'quantity'>) => void
removeLine: (sku: string) => void
clear: () => void
}
export const [useOrderDraft, OrderDraftProvider, orderDraftStore] =
createKitrStore<OrderDraftState>({ name: 'order-draft', persist: true }, (set) => ({
customer: '',
lines: [],
setCustomer: (customer) => set({ customer }),
addLine: (line) =>
set((state) => {
const existing = state.lines.find((l) => l.sku === line.sku)
if (!existing) return { lines: [...state.lines, { ...line, quantity: 1 }] }
return {
lines: state.lines.map((l) => (l.sku === line.sku ? { ...l, quantity: l.quantity + 1 } : l)),
}
}),
removeLine: (sku) => set((state) => ({ lines: state.lines.filter((l) => l.sku !== sku) })),
clear: () => set({ customer: '', lines: [] }),
}))
Then use it from any two components, however far apart:
// app/routes.ts
r.page('/orders/new', { lazy: () => import('./pages/orders/new') })
// app/pages/orders/new.tsx
import { useOrderDraft } from '#app/stores/order-draft'
function AddLicense() {
const addLine = useOrderDraft((s) => s.addLine)
return (
<button type="button" onClick={() => addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 })}>
Add a seat
</button>
)
}
function DraftCount() {
const count = useOrderDraft((s) => s.lines.reduce((sum, l) => sum + l.quantity, 0))
return <p>{count} items in this order</p>
}
export default function NewOrderPage() {
return (
<main>
<DraftCount />
<AddLicense />
</main>
)
}
Open /orders/new and press Add a seat three times: the count reads 3 items in this order. Reload the page and it still reads 3, because persist: true saved the draft to localStorage under kitr:order-draft.
kitr make:store writes a starter store for you; see Commands.
Read a value in a component
Pass a selector to the hook. The component re-renders only when the selected value changes, so DraftCount ignores a change to customer:
// app/components/orders/draft-customer.tsx
import { useOrderDraft } from '#app/stores/order-draft'
export function DraftCustomer() {
const customer = useOrderDraft((s) => s.customer)
const setCustomer = useOrderDraft((s) => s.setCustomer)
return (
<label>
Customer
<input value={customer} onChange={(e) => setCustomer(e.target.value)} />
</label>
)
}
Actions are part of the state, and they never change, so selecting one never causes a re-render.
Calling the hook with no selector returns the whole state. The component then re-renders on every change to any field, so keep that for small stores or for debugging:
// app/components/orders/draft-debug.tsx
import { useOrderDraft } from '#app/stores/order-draft'
export function DraftDebug() {
const draft = useOrderDraft()
return <pre>{JSON.stringify({ customer: draft.customer, lines: draft.lines }, null, 2)}</pre>
}
Select several values at once
A selector that builds a new object or array on every call, such as (s) => ({ customer: s.customer, count: s.lines.length }), returns a different value each time, and React keeps re-rendering. Either call the hook once per value, or wrap the selector in Zustand's useShallow, which compares the result field by field:
// app/components/orders/draft-header.tsx
import { useShallow } from 'zustand/react/shallow'
import { useOrderDraft } from '#app/stores/order-draft'
export function DraftHeader() {
const { customer, count } = useOrderDraft(
useShallow((s) => ({ customer: s.customer, count: s.lines.length })),
)
return (
<h2>
Draft for {customer || 'a new customer'} ({count} lines)
</h2>
)
}
Derived numbers and strings are safe without it: (s) => s.lines.length returns the same number until the length changes.
Change state with actions
Inside the creator, set merges a partial state into the current one. Pass an object when the new value does not depend on the old one, and a function when it does:
// app/stores/invoice-selection.ts
import { createKitrStore } from '@kitr/state'
interface InvoiceSelectionState {
selected: string[]
toggle: (invoiceId: string) => void
selectAll: (invoiceIds: string[]) => void
clear: () => void
isSelected: (invoiceId: string) => boolean
}
export const [useInvoiceSelection, InvoiceSelectionProvider, invoiceSelectionStore] =
createKitrStore<InvoiceSelectionState>({ name: 'invoice-selection' }, (set, get) => ({
selected: [],
toggle: (id) =>
set((s) => ({
selected: s.selected.includes(id) ? s.selected.filter((x) => x !== id) : [...s.selected, id],
})),
selectAll: (ids) => set({ selected: ids }),
clear: () => set({ selected: [] }),
isSelected: (id) => get().selected.includes(id),
}))
get() reads the current state inside an action. Keep the logic in actions rather than in components, so every caller changes the state the same way.
Don't mutate the state in place (s.selected.push(id)). set compares by reference, so a mutated array looks unchanged and nothing re-renders. Return a new array or object.
Read and change state outside React
The third value is the browser's store instance. Use it in code that is not a component: an event handler set up at startup, a route loader, a mutation's onSuccess:
// app/lib/orders/place-order.ts
import { orderDraftStore } from '#app/stores/order-draft'
export async function placeDraftOrder() {
const draft = orderDraftStore?.getState()
if (!draft || draft.lines.length === 0) return
await fetch('/api/orders', {
method: 'POST',
headers: { 'content-type': 'application/json' },
body: JSON.stringify({ customer: draft.customer, lines: draft.lines }),
})
orderDraftStore?.getState().clear()
}
| Method | What it does |
|---|---|
getState() |
Returns the current state, actions included. |
setState(partial) |
Merges partial (or the result of a function of the state) into the state. |
setState(state, true) |
Replaces the whole state. Include the actions, or they are gone. |
subscribe(listener) |
Calls listener(state, previous) after every change. Returns a function that stops it. |
getInitialState() |
Returns the state the creator first produced. |
subscribe lets code outside React react to changes. This keeps the page title in step with the draft:
// app/lib/orders/draft-title.ts
import { orderDraftStore } from '#app/stores/order-draft'
export const stopDraftTitle = orderDraftStore?.subscribe((state, previous) => {
if (state.lines.length !== previous.lines.length)
document.title = state.lines.length ? `(${state.lines.length}) Acme` : 'Acme'
})
The store is null on the server, which is why every call above uses ?.. Code that only ever runs in the browser, such as a click handler, can assert it with orderDraftStore!.
Save state in the browser
With persist: true, the whole state is saved to localStorage under kitr:<name> after every change, and restored when the page loads. The saved value is JSON with the state and a version:
// In the browser console after adding one seat
JSON.parse(localStorage.getItem('kitr:order-draft')!)
// => { state: { customer: '', lines: [{ sku: 'acme-seat', name: 'Acme seat', price: 12, quantity: 1 }] }, version: 0 }
Functions are not saved, because JSON cannot hold them; the actions come from the creator each time.
Pass an object to control what is saved. partialize picks the fields; here the draft's lines survive a reload but the customer does not:
// app/stores/order-draft.ts
import { createKitrStore } from '@kitr/state'
import type { DraftLine } from './types'
interface OrderDraftState {
customer: string
lines: DraftLine[]
clear: () => void
}
export const [useOrderDraft, OrderDraftProvider, orderDraftStore] =
createKitrStore<OrderDraftState>(
{
name: 'order-draft',
persist: {
partialize: (state) => ({ lines: state.lines }),
},
},
(set) => ({
customer: '',
lines: [],
clear: () => set({ customer: '', lines: [] }),
}),
)
Warning
localStorageis readable by any script on the page and stays on the device after sign-out. Never persist tokens, passwords or personal data in a store. Use Secure storage for secrets.
Change the saved shape
When the state's shape changes, data saved by the old version no longer fits. Bump version, and give migrate a function that upgrades old data. Without migrate, data from another version is ignored and the store starts from its initial state:
// app/stores/preferences.ts
import { createKitrStore } from '@kitr/state'
interface PreferencesState {
currency: 'USD' | 'EUR'
density: 'comfortable' | 'compact'
setDensity: (density: 'comfortable' | 'compact') => void
}
export const [usePreferences, PreferencesProvider, preferencesStore] =
createKitrStore<PreferencesState>(
{
name: 'preferences',
persist: {
version: 2,
migrate: (saved, version) => {
const old = saved as { currency?: 'USD' | 'EUR'; compact?: boolean }
// Version 1 stored a boolean `compact`; version 2 stores `density`.
if (version < 2) return { currency: old.currency ?? 'USD', density: old.compact ? 'compact' : 'comfortable' }
return old as Partial<PreferencesState>
},
},
},
(set) => ({
currency: 'USD',
density: 'comfortable',
setDensity: (density) => set({ density }),
}),
)
Save for one tab only
sessionStorage keeps state for one tab and clears it when the tab closes. It suits a wizard Grace should be able to run in two tabs at once without them mixing:
// app/stores/import-wizard.ts
import { createKitrStore } from '@kitr/state'
import { createJSONStorage } from 'zustand/middleware'
interface ImportWizardState {
step: 1 | 2 | 3
fileName: string | null
next: () => void
back: () => void
}
export const [useImportWizard, ImportWizardProvider, importWizardStore] =
createKitrStore<ImportWizardState>(
{ name: 'import-wizard', persist: { storage: createJSONStorage(() => sessionStorage) } },
(set) => ({
step: 1,
fileName: null,
next: () => set((s) => ({ step: Math.min(3, s.step + 1) as 1 | 2 | 3 })),
back: () => set((s) => ({ step: Math.max(1, s.step - 1) as 1 | 2 | 3 })),
}),
)
Seed a store for a page
StoreProvider takes initialState, which is merged over the creator's state when the provider first mounts. Use it to start a store from data a loader fetched, such as the customer of the order Linus is editing:
// app/routes.ts
import { orderQuery } from '#app/queries/orders'
r.page('/orders/$orderId/edit', {
loader: ({ context, params }) => context.queryClient.ensureQueryData(orderQuery.options(params.orderId)),
lazy: () => import('./pages/orders/orderId/edit'),
})
// app/pages/orders/orderId/edit.tsx
import { OrderDraftProvider } from '#app/stores/order-draft'
import { DraftEditor } from '#app/components/orders/draft-editor'
import { getRouteApi } from '@kitr/router'
const route = getRouteApi('/orders/$orderId/edit')
export default function EditOrderPage() {
const order = route.useLoaderData()
return (
<OrderDraftProvider initialState={{ customer: order.customer }}>
<DraftEditor />
</OrderDraftProvider>
)
}
In the browser the store is shared by the whole app, so initialState writes into that one store with setState when the provider mounts. It overrides values restored from storage, and every component using the store sees it, inside the provider or not. On the server each provider builds its own store with initialState already applied, so the page renders with it.
Render on the server
On the server, one process renders pages for many users at once. A store created at module level would be shared by all of them, and Ada's draft could render in Grace's page. createKitrStore prevents that:
- In the browser, the store is one module-level instance. The hook reads it without any provider.
- On the server, there is no module-level instance. Each
<StoreProvider>creates a fresh store for its render, and storage is never touched.
If a page renders on the server (a web platform in ssr or static mode), wrap the part of the tree that reads the store:
// app/document.tsx
import { HeadContent, Outlet, Scripts } from '@kitr/router'
import { OrderDraftProvider } from '#app/stores/order-draft'
export function RootDocument() {
return (
<html lang="en">
<head>
<HeadContent />
</head>
<body>
<OrderDraftProvider>
<Outlet />
</OrderDraftProvider>
<Scripts />
</body>
</html>
)
}
Without a provider, a server render fails with:
[@kitr/state] Store "order-draft" has no instance on the server. Wrap the tree in its <StoreProvider>.
In a client-only app (spa mode, desktop, mobile) you can skip the provider entirely.
A saved store renders its initial state on the server, because the server cannot read the browser's storage. The browser's first render, the one React hydrates, uses that same state (plus any <StoreProvider initialState>), so React sees no mismatch. The saved state follows straight after, in a second render, with no extra code from you.
Use plain Zustand
@kitr/state re-exports zustand unchanged, so create, createStore, useStore and the types work as in the Zustand docs. Middleware comes from zustand/middleware. A store made with create is a module-level singleton on the server too, so use it only in client-only apps or for state that is the same for every user:
// app/stores/toasts.ts
import { create } from '@kitr/state'
interface ToastState {
messages: string[]
push: (message: string) => void
}
export const useToasts = create<ToastState>()((set) => ({
messages: [],
push: (message) => set((s) => ({ messages: [...s.messages, message] })),
}))
Commands
| Command | What it does |
|---|---|
kitr make:store <name> |
Writes app/stores/<name>.ts with a createKitrStore store: a count, an increment and a reset action, not persisted. |
kitr make:store order-draftFirst time? Run npm i -g kitr once. Inside a project, the global kitr runs your project's own version.
It writes app/stores/order-draft.ts. The file name is kebab-case; the exports are camel and pascal case:
// app/stores/order-draft.ts
import { createKitrStore } from '@kitr/state'
interface OrderDraftState {
count: number
increment: () => void
reset: () => void
}
export const [useOrderDraftStore, OrderDraftStoreProvider, orderDraftStore] = createKitrStore<OrderDraftState>(
{ name: 'order_draft', persist: false },
(set) => ({
count: 0,
increment: () => set((s) => ({ count: s.count + 1 })),
reset: () => set({ count: 0 }),
}),
)
Replace the state with your own. Pass --force to overwrite an existing file. To change the template for your project, run kitr eject stub store and edit stubs/store.ts.
Testing
In a DOM test (Vitest with happy-dom or jsdom), the store is the browser singleton, so it keeps its state from one test to the next. Reset it in beforeEach with setState, and clear localStorage if the store persists:
// app/stores/order-draft.test.tsx
import { act, render, screen } from '@testing-library/react'
import { beforeEach, describe, expect, it } from 'vitest'
import { orderDraftStore, useOrderDraft } from './order-draft'
function DraftCount() {
const count = useOrderDraft((s) => s.lines.reduce((sum, l) => sum + l.quantity, 0))
return <p>{count} items</p>
}
describe('order draft', () => {
beforeEach(() => {
localStorage.clear()
orderDraftStore!.setState({ customer: '', lines: [] })
})
it('adds a line and bumps its quantity', () => {
const { addLine } = orderDraftStore!.getState()
addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 })
addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 })
expect(orderDraftStore!.getState().lines).toEqual([
{ sku: 'acme-seat', name: 'Acme seat', price: 12, quantity: 2 },
])
})
it('re-renders components that read it', () => {
render(<DraftCount />)
expect(screen.getByText('0 items')).toBeTruthy()
act(() => orderDraftStore!.getState().addLine({ sku: 'acme-seat', name: 'Acme seat', price: 12 }))
expect(screen.getByText('1 items')).toBeTruthy()
})
it('saves the draft under kitr:order-draft', () => {
act(() => orderDraftStore!.getState().setCustomer('Ada'))
expect(JSON.parse(localStorage.getItem('kitr:order-draft')!).state.customer).toBe('Ada')
})
})
Wrap changes made outside React in act() when a component is rendered, so React applies the update before you assert.
To test what the server renders, run the test in the node environment, where there is no singleton, and render through the provider:
// app/stores/order-draft.server.test.tsx
// @vitest-environment node
import { renderToString } from 'react-dom/server'
import { expect, it } from 'vitest'
import { OrderDraftProvider, orderDraftStore, useOrderDraft } from './order-draft'
function Customer() {
return <p>{useOrderDraft((s) => s.customer) || 'nobody'}</p>
}
it('gives every server render its own store', () => {
expect(orderDraftStore).toBeNull()
const ada = renderToString(
<OrderDraftProvider initialState={{ customer: 'Ada' }}>
<Customer />
</OrderDraftProvider>,
)
const grace = renderToString(
<OrderDraftProvider>
<Customer />
</OrderDraftProvider>,
)
expect(ada).toContain('Ada')
expect(grace).toContain('nobody')
})
Devtools
In development, every store connects to the Redux DevTools browser extension under its name. Each set shows up as an action with the state before and after, and you can step back through them. The connection is skipped in production builds, on the server, and for stores created with devtools: false.
Next steps
- API reference
- Zustand docs: everything there works as written, imported from
@kitr/state. - Zustand persist options: every option
persistaccepts. - Query: where server data belongs.
- Forms: form state, validation and server errors, without a store.