createStore
useState, shared by every component.
Describe your state once. Any component reads or changes it by name, with no provider, no reducers and no action types. Server data, persistence, undo, tab sync and offline support are built in, and stay off until you use them.
const { useGlobalState } =
createStore({ todos: [] });
function TodoCount() {
const [todos] = useGlobalState('todos');
return <p>{todos.length} todos</p>;
}
function AddTodo() {
const [todos, setTodos] =
useGlobalState('todos');
const add = (text) =>
setTodos([...todos, { text }]);
return <Form onAdd={add} />;
}2 open of 3 todos
Two separate components, one key. Add or tick a todo and watch the other component update.
#Why createStore
useState is the nicest state API React has. You give a value a name, you read it, you change it. The trouble starts the moment a second component needs the same value. Now you're lifting state up, passing props through three components that don't care about them, wrapping things in providers, or bringing in a library with its own vocabulary of reducers, actions and selectors.
I wanted to keep the useState shape and just make it global. So that's what this is. You describe your state once, give each piece a name, and any component can read or change it by that name:
const [todos, setTodos] = useGlobalState('todos');No provider, no reducers, no action types. If you already know useState, you know most of this library.
#Why it was made
Shared values were never the whole story, though. A real app also needs server data, values derived from other values, settings that survive a reload, maybe undo. Usually every one of those brings its own library, its own cache and its own rules about when components re-render, and you end up gluing four mental models together.
So I put those features behind the same names you already use. A request to your API is a key. A total calculated from the cart is a key. A saved theme is a key. You turn each feature on only when you need it, which means a small app stays small:
| If you need | You add |
|---|---|
| Shared values | Nothing, just createStore and useGlobalState |
| Values derived from others | computed |
| Data from your server | query(), with or without params, or paged |
| Saving with rollback on failure | A second argument to the setter, or keyActions |
| State that survives a reload | persist |
| Undo and redo | history |
| Tabs that stay in sync | syncTabs |
| Working offline | offline |
#Where it fits
It's a good fit when lots of components share state and you're tired of prop drilling and providers, and especially when that state is a mix of local values and data from a server, because both live in one store and you read them the same way. It also helps teams: there's one hook and one way to update things, so code from different people tends to look the same.
#When to reach for something else
Honestly, not everything should be global. If a value only matters to one component, keep it in useState.
There's also one case where this design is slower than the alternatives: when every component re-renders on every change and your selectors read different keys each time. The store tracks which keys each component reads, which makes targeted updates very fast but costs a bit extra in that worst case.
And if you need a server cache shared across several apps or workers outside React, a dedicated server-state library will give you more control.
#Installation
npm i @createstore/createstoreYou'll need React 19 or newer. The hooks are built on useSyncExternalStore, which is what keeps reads consistent during concurrent rendering. Types ship with the package, so TypeScript works without installing anything else, and plain JavaScript works too.
| Requirement | Version |
|---|---|
| React | 19 or newer (tested on 19.3) |
| TypeScript, if you use it | 5.0 or newer |
| Browsers | Anything React supports |
| Server rendering | Supported, see SSR |
#Quick start
This takes about two minutes.
#1. Describe your state
Create the store once, in its own file. Every property becomes a key you can use by name.
// store.ts
import { createStore } from '@createstore/createstore';
type Todo = { id: number; text: string; done: boolean };
export const { useGlobalState } = createStore({
todos: [] as Todo[],
filter: 'all' as 'all' | 'open' | 'done',
});#2. Read and change it in any component
useGlobalState gives you the value and a setter, just like useState does.
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore({ todos: [] as { id: number; text: string; done: boolean }[] });
export function AddTodo() {
const [todos, setTodos] = useGlobalState('todos');
const add = (text: string) => setTodos([...todos, { id: Date.now(), text, done: false }]);
return <button onClick={() => add('Buy milk')}>Add</button>;
}
export function TodoCount() {
const [todos] = useGlobalState('todos');
return <span>{todos.length} todos</span>;
}#3. There is no step 3
Seriously, that's it. TodoCount updates when AddTodo adds something, no matter where the two sit in the tree, and each component only re-renders when a key it actually reads changes.
From here I'd skim Core concepts, since it's short, and then pick up features as you need them: server data, computed values, persistence.
#Learning path
You don't need all of this. The docs are in four levels, and each one ends with a note saying you can stop there:
| Level | What it covers | You need it when |
|---|---|---|
| 1 · Every app | createStore, useGlobalState, setters, computed values, key actions and actions | Always. Most apps never need more |
| 2 · Server data | query(): loading, params, pages, optimistic saves | Your app fetches from a server |
| 3 · When you need them | Persistence, tab sync, offline, undo, events, batching | One feature at a time, as you need it |
| 4 · Server rendering and scale | SSR, hot reloading, lifecycle, TypeScript details, testing | You render on the server, or the app grows |
Everything else (how it works, debugging, the error messages and troubleshooting) is in Help, and the whole API fits on one page: the Cheat sheet.
#Core concepts
There isn't much to learn here, but a few rules are worth knowing up front.
#Keys
Every property of the object you pass to createStore is a key. It can hold anything: a number, an array, an object, null. Components refer to keys by name, and TypeScript checks those names, so useGlobalState('todo') when you meant 'todos' won't compile.
#One hook, three shapes
You'll mostly use the first one. The other two are for when you want less than a whole key, or more than one.
| You write | You get back | Handy when |
|---|---|---|
useGlobalState('todos') | [value, setter, status] | You read and write one key |
useGlobalState('todos', todos => todos.length) | Just the selected value | You only care about part of a key |
useGlobalState(get => get('a') + get('b')) | Whatever you return | You're combining several keys |
#Re-rendering
A component re-renders when something it reads changes, and not otherwise. With useGlobalState('todos') that means when todos changes. With a selector, it means when the selector's result changes, compared one level deep, so returning { a, b } won't cause a re-render while a and b stay the same. A selector that reads several keys tracks exactly the ones it touched on its last run, so if it reads a this time and b next time, it follows along.
#Update immutably
Same rule as useState: a change is noticed when the value is a different object. Make a new array or object rather than mutating the old one.
setTodos([...todos, newTodo]); // new array: components update
todos.push(newTodo); setTodos(todos); // same array: nothing updates#The store outside React
The store doesn't live inside your components, so event handlers, services and tests can use it directly:
import { createStore } from '@createstore/createstore';
const store = createStore({ count: 0 });
store.getState('count'); // 0
store.setState('count', (c) => c + 1); // 1
const off = store.subscribe((change) => console.log(change.key, change.next));
off(); // stop listening#Configuration at a glance
Everything after the initial state is optional. Each option switches on one feature, and none of them are required:
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore(
{ cart: [] as string[], theme: 'light' as 'light' | 'dark' },
{
name: 'shop', // storage, tab sync and devtools name
persist: true, // keep state across reloads
history: true, // undo and redo
syncTabs: true, // keep open tabs in sync
offline: true, // keep server data and queue saves while offline
devtools: true, // Redux DevTools
}
);The API reference has the full list with defaults.
Notice that the two arguments follow different rules. In the first one, your state, you invent every name: cart could just as well be basket. In the second one, the options, the names belong to the library: persist, history, keyActions and the rest have to be spelled exactly as documented, or they won't be recognised. TypeScript points out a misspelled option; plain JavaScript silently ignores it.
#The store name
name is how a store identifies itself anywhere outside memory. It defaults to 'store', which is fine for a single store that doesn't persist or sync.
| Where it shows up | With name: 'shop' |
|---|---|
| Persisted state | the localStorage key shop:state |
| Offline copy of server data | shop:inline and shop:queries |
| Tab sync | a BroadcastChannel called createStore:shop |
| Redux DevTools | the instance is listed as shop |
| Console warnings and the logger | messages start with [shop] |
| Shared stores | shared: true reuses the store with this name |
If a store persists, syncs tabs or is shared, give it its own name. Two stores with the same name will happily overwrite each other's saved state.
#Reading state
#One key
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore({ user: { name: 'Ada', role: 'admin' } });
function Greeting() {
const [user] = useGlobalState('user');
return <h1>Hello, {user.name}</h1>;
}The tuple has a third item, the key's status. It only really matters for server data; for plain keys it never changes. Don't worry about the cost of grabbing it, either. A component only re-renders on status changes if it actually reads the status.
#Part of a key
Pass a selector as the second argument when you only need a piece of a key. The component then re-renders only when that piece changes:
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore({ user: { name: 'Ada', role: 'admin' } });
function RoleBadge() {
const role = useGlobalState('user', (user) => user.role);
return <span>{role}</span>;
}#Several keys
Pass a function instead of a key name and you can read as much of the store as you like. Use whichever style you find most readable; all three are tracked the same way:
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore({ todos: [] as { done: boolean }[], filter: 'all' });
function Summary() {
// call it
const open = useGlobalState((get) => get('todos').filter((t) => !t.done).length);
// read a property
const total = useGlobalState((state) => state.todos.length);
// destructure it
const label = useGlobalState(({ filter, todos }) => `${filter}: ${todos.length}`);
return <p>{open} of {total} open ({label})</p>;
}#Updating state
#The setter
It works exactly like the one from useState. Pass a new value, or a function that gets the old one:
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore({ count: 0 });
function Counter() {
const [count, setCount] = useGlobalState('count');
return (
<>
<button onClick={() => setCount(0)}>Reset</button>
<button onClick={() => setCount((c) => c + 1)}>{count}</button>
</>
);
}Reach for the function form whenever the new value depends on the old one. It always gets the latest value, even when several updates happen back to back.
Setters never change between renders, so you can put them in dependency arrays without a second thought. They also return a promise, which resolves once any server save has finished. For an ordinary update it resolves straight away and you can just ignore it.
#Three ways to write
These all do the same thing and take the same arguments. Use whichever suits the place you're in.
| Where you are | What to write |
|---|---|
| In a component | const [, setTodos] = useGlobalState('todos') |
| Anywhere | store.setState('todos', next) |
| Anywhere, as a function you can pass around | store.set.todos(next) |
#A few rules
Computed keys are read-only, so change whatever they read instead. You can't update state from inside a selector or a computed function, because those should only calculate; the store throws a clear error if one tries. And writing to a key that doesn't exist fails immediately, with the key's name in the message, rather than silently creating it.
#Computed values
A computed value is a key whose value is worked out from other keys. It updates on its own and stays cached until something it reads changes.
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore(
{ items: [] as { price: number; qty: number }[], taxRate: 0.2 },
{
computed: [
{ subtotal: (get) => get('items').reduce((sum, i) => sum + i.price * i.qty, 0) },
{ total: (get) => get('subtotal') * (1 + get('taxRate')) },
],
}
);
function Total() {
const [total] = useGlobalState('total');
return <strong>{total.toFixed(2)}</strong>;
}#Reading inside a computed value
A computed function gets the same reader a store selector does, so (get) => get('items'), (state) => state.items and ({ items }) => items all work, and all of them are tracked.
#Layers
You'll notice computed is a list. Each entry is a layer, and a computed value can read plain state plus anything from an earlier layer. That's why total sits in the second layer above: it reads subtotal. Splitting things into layers is what lets TypeScript infer every computed type without you writing a single annotation. You can have up to eight, though I've rarely needed more than two or three.
#How they behave
Computed values are lazy. Nothing is calculated until something reads it, and after that it's only recalculated when one of its inputs changes. If two values both depend on the same key and a third depends on both of them, each one still runs once per change, and nothing ever sees a half-updated mix.
Dependencies are dynamic. If a computed value reads a on one run and b on the next, it follows. So does its loading status, and so do any queries it reads, which will start loading when they're needed.
Errors are cached too. If a computed function throws, reading it rethrows the same error until one of its inputs changes, instead of running the failing function again on every render. In a component, the error goes to your error boundary as you'd expect. And if two computed values end up reading each other, you get an error that spells out the loop, something like a → b → a, rather than a stack overflow.
#Key actions
If you find yourself writing the same updater for a key in five places, give it a name. Key actions hang off a key's setter, so a component can call setTodos.add(text) instead. Each one takes the current value and returns the next.
import { createStore } from '@createstore/createstore';
type Todo = { id: number; text: string; done: boolean };
const { useGlobalState } = createStore(
{ todos: [] as Todo[] },
{
keyActions: {
todos: {
add: (todos, text: string) => [...todos, { id: Date.now(), text, done: false }],
toggle: (todos, id: number) => todos.map((t) => (t.id === id ? { ...t, done: !t.done } : t)),
clearDone: (todos) => todos.filter((t) => !t.done),
},
},
}
);
function Todos() {
const [todos, setTodos] = useGlobalState('todos');
return (
<>
<button onClick={() => setTodos.add('New todo')}>Add</button>
<button onClick={() => setTodos.clearDone()}>Clear done</button>
{todos.map((t) => <label key={t.id}><input type="checkbox" checked={t.done} onChange={() => setTodos.toggle(t.id)} />{t.text}</label>)}
</>
);
}Outside components they're right on the store, as in store.todos.add('Buy milk'). If you're wondering how these differ from actions, the next section, Actions vs key actions, covers it.
#Calling them directly on the store
Every state key is also available on the store itself, as its setter with its key actions attached:
import { createStore } from '@createstore/createstore';
type Product = { id: string; price: number };
export const AppStore = createStore(
{ cart: [] as Product[], theme: 'light' as 'light' | 'dark' },
{ keyActions: { cart: { add: (cart, product: Product) => [...cart, product] } } }
);
AppStore.cart.add({ id: 'sku-1', price: 12 }); // a key action
AppStore.cart([]); // the setter itself
AppStore.theme('dark'); // works for keys without key actions tooAppStore.cart is exactly the same function as AppStore.set.cart and as the setter you get from useGlobalState('cart'), so use whichever reads best where you are. In components, the setter from the hook is still the natural choice. Outside them (event handlers, services, timers, actions), AppStore.cart.add(...) saves you from writing .set.
A few names can't work this way, because the store already uses them for its own methods: getState, setState, set, actions, reset, destroy, subscribe, undo, redo, goTo, batch, emit, on, fetchQuery, setQueryData, invalidate, abort, whenOnline, hydrated, the hooks, and createScope, Scope and Root. If one of your keys has such a name, say reset, AppStore.reset() stays the store's method, the store warns you once, and the key is still reachable as AppStore.set.reset(...). Computed keys aren't exposed either, since they're read-only. TypeScript knows all of this, so AppStore.cart.add('oops') or a call on a reserved name is a compile error.
With ssr: true, these direct calls work anywhere in the browser. On the server, use the request's scope instead: scope.cart.add(product). Calling AppStore.cart.add() there throws an error that says exactly that.
#The two ways to write one
A key action is either a plain function or an object with run and, optionally, save. The plain function is just shorthand: add: (todos, text) => ... means exactly the same as add: { run: (todos, text) => ... }. Either way, it becomes a method on the key's setter, so you call it as setTodos.add(text) in a component, or store.todos.add(text) (also reachable as store.set.todos.add(text)) anywhere else. The call returns a promise.
#run
run is the function that works out the new value. You write it; the store calls it.
| Details | |
|---|---|
| Signature | run(current, ...args) => next |
current | The key's value right now. For a query key with select, it's the raw data, not the selected view |
...args | Whatever you passed to the action, so setTodos.add('Buy milk') calls run(todos, 'Buy milk') |
| Returns | The key's new value, which is applied immediately |
| Required | Yes, unless you use the plain-function shorthand, which is run |
| When it runs | Synchronously, the moment you call the action |
Treat run like a reducer: work out the next value from the current one and the arguments, and don't do anything else. No requests, no reading other keys, no changing anything outside. If run throws, nothing changes and the error is thrown straight from the call, so you'll see it at the line that called the action.
#save
save is the optional function that sends the result to your server. You write it; the store calls it after run has updated the screen.
| Details | |
|---|---|
| Signature | save(next) => Promise | anything |
next | The key's value at the moment save runs: the raw stored value, even for a query key with select |
| Returns | A promise, or nothing. What it resolves to is ignored |
| Fails by | Throwing, or returning a promise that rejects |
| Required | No. Without it, the action just changes the value locally |
| When it runs | Right after the call returns, not during it. With offline: true and no connection, it waits until the connection comes back |
If save succeeds, the change stays and the action's promise resolves. If it fails, only this action's change is rolled back (anything else changed in the meantime stays), its undo step is removed, and the action's promise rejects with the same error. That's why save has to actually fail when the server says no: fetch doesn't reject on a 404 or a 500, so check res.ok and throw.
next is read when save runs rather than when you called the action. Normally that's the same value. It matters offline: a save that waited for the connection sends what the key holds when it's finally sent, not a stale copy from when it was queued.
#Key actions that save
Use { run, save } when an action should also save its result. It's optimistic, like the setter's server call: the change shows up right away and rolls back if save fails. save receives the value at the moment it runs, which matters when you're offline, because a queued save sends the latest data instead of whatever was there when it was queued.
import { createStore } from '@createstore/createstore';
type Todo = { id: number; text: string };
const api = { saveTodos: async (todos: Todo[]) => {} };
const { useGlobalState } = createStore(
{ todos: [] as Todo[] },
{
keyActions: {
todos: {
add: { run: (todos, text: string) => [...todos, { id: Date.now(), text }], save: (next) => api.saveTodos(next) },
},
},
onSaveStart: (key, action) => console.log('saving', key, action),
onSaveSuccess: (key, action) => console.log('saved', key, action),
onSaveError: (key, action, error) => console.error('failed', key, action, error),
}
);#onSaveStart, onSaveSuccess and onSaveError
These aren't part of a key action. They're options you pass to createStore, once, and the store calls them for every key action that has a save. Think of them as store-wide listeners for saving, which makes an app-wide "Saving…" indicator or a single error toast very little work.
| Option | Called with | When |
|---|---|---|
onSaveStart(key, action) | The key's name and the action's name, e.g. ('todos', 'add') | Right after run has applied the new value, before save runs. The store already shows the new value |
onSaveSuccess(key, action) | The same two names | After save has succeeded |
onSaveError(key, action, error) | The two names, plus what save threw or rejected with | After the change has been rolled back, so the store already shows the old value again |
#Where they go, and which names are fixed
It's easy to assume these come from keyActions, or that a key action hands them to you. Neither is true. They're options you write yourself, in the second argument to createStore, right next to keyActions:
import { createStore } from '@createstore/createstore';
type Todo = { id: number; text: string };
const server = { save: async (todos: Todo[]) => {} };
const { useGlobalState } = createStore(
// 1st argument: your state. You choose these names.
{ todos: [] as Todo[] },
// 2nd argument: options. The library chooses these names.
{
keyActions: {
todos: {
// inside a key action there are only run and save
add: {
run: (todos, text: string) => [...todos, { id: Date.now(), text }],
save: (next) => server.save(next),
},
},
},
// next to keyActions, not inside it
onSaveStart: (key, action) => console.log('saving', key, action),
onSaveSuccess: (key, action) => console.log('saved', key, action),
onSaveError: (key, action, error) => console.error('failed', key, action, error),
}
);Their names are fixed. You can't call one onSaveBegin and expect it to work. The store only looks for these three exact names, so a misspelled one is never called. TypeScript flags it as an unknown option; plain JavaScript just ignores it. What you do choose is the function: log, count, show a toast, anything.
Each of the three is optional and only does something when you provide it. Leave onSaveStart out and nothing happens when a save starts. You can provide all three, or any one or two of them; if failures are all you care about, onSaveError on its own is fine. And they never need switching on in some other way. Writing the option is what turns it on.
There's nothing to call, either. There's no store.onSaveStart() or setTodos.onSaveStart(). You write them, and the store calls them for you at the right moment.
#Reacting to one save instead of all of them
Because the hooks are written once for the whole store, they hear about every save on every key. That's why they're given the key's and the action's names, so one function can tell saves apart:
onSaveError: (key, action) => {
if (key === 'todos' && action === 'add') showToast("Couldn't add that todo");
},When you only care about one particular call, for example one button on one screen, it's simpler to use the promise that call returns:
setTodos.add('Buy milk')
.then(() => showToast('Saved'))
.catch((error) => showToast(`Couldn't save: ${error.message}`));A rough rule: use the hooks for app-wide things like a "Saving…" indicator, logging or a generic error toast, and use the promise for anything specific to one place.
#The details
A few things worth knowing:
- Every call that starts with
onSaveStartends with exactly one ofonSaveSuccessoronSaveError, so a counter that goes up on start and down on finish always comes back to zero. - They only fire for key actions with a
save. A key action without one, a setter called with a server call (setTodos(next, save)), and your actions don't trigger them. - If
runthrows, the action never starts, so none of them fire. - If one of them throws, the error is logged and ignored. A bug in a hook can't break the store or the save.
- They return nothing, and their return value is ignored.
Here's the kind of thing they're for, a "Saving…" indicator and an error toast for the whole app:
import { createStore } from '@createstore/createstore';
type Todo = { id: number; text: string };
const server = { save: async (todos: Todo[]) => {} };
const store = createStore(
{ todos: [] as Todo[], pendingSaves: 0, lastError: null as string | null },
{
keyActions: {
todos: {
add: { run: (todos, text: string) => [...todos, { id: Date.now(), text }], save: (next) => server.save(next) },
},
},
onSaveStart: () => store.setState('pendingSaves', (n) => n + 1),
onSaveSuccess: () => store.setState('pendingSaves', (n) => n - 1),
onSaveError: (key, action, error) => {
store.setState('pendingSaves', (n) => n - 1);
store.setState('lastError', `Couldn't ${action} ${key}: ${(error as Error).message}`);
},
}
);
function SavingIndicator() {
const [pending] = store.useGlobalState('pendingSaves');
return pending > 0 ? <span>Saving…</span> : null;
}#What happens when you call one, in order
Calling setTodos.add('Buy milk') on a key action with a save:
run(todos, 'Buy milk')runs and its result is applied. The new todo is on screen now.onSaveStart('todos', 'add')is called.- The call returns a promise.
- A moment later
save(next)runs, with the key's current value. - If
savesucceeds,onSaveSuccess('todos', 'add')is called and the promise resolves. - If it fails, the change is rolled back first, then
onSaveError('todos', 'add', error)is called and the promise rejects with that error.
Without a save, it stops after step 1, and the promise resolves straight away.
#Actions
Once some piece of logic touches several keys, or talks to a server, it's nicer to keep it in one place than to scatter it across components. That's what actions are for. (Not sure whether you want an action or a key action? See Actions vs key actions.) You define them once, they get an api, and they return plain functions:
import { createStore } from '@createstore/createstore';
type Item = { id: string; qty: number };
const store = createStore(
{ cart: [] as Item[], step: 'cart' as 'cart' | 'paying' | 'done' },
{
actions: (api) => ({
addItem(id: string) {
api.setState('cart', (cart) => [...cart, { id, qty: 1 }]);
},
async checkout() {
api.setState('step', 'paying');
await fetch('/api/checkout', { method: 'POST', body: JSON.stringify(api.getState('cart')) });
api.batch(() => {
api.setState('cart', []);
api.setState('step', 'done');
});
},
}),
}
);
function CheckoutButton() {
return <button onClick={() => store.actions.checkout()}>Pay</button>;
}#The api
| Member | What it's for |
|---|---|
getState(key), setState(key, value, save?) | Reading and writing keys |
batch(fn) | Grouping updates into one undo step |
emit, on | Events |
fetchQuery, setQueryData, invalidate, abort | The query cache |
whenOnline(task) | Running something now, or once you're back online |
#Actions vs key actions
At first glance these look like two ways of doing the same thing. They aren't. The short version: a key action is a named way to change one key; an action is a piece of your app's logic.
#What a key action is
A key action belongs to exactly one key. It gets that key's current value, returns the next one, and does nothing else. No network calls, no reading other keys, no side effects. Think of it as giving a name to an updater you'd otherwise copy into five components. setCart.add(id) says what it does, and the logic lives in one place.
Because it's that small and that predictable, the store can do a lot for it. Add a save and you get an optimistic update with automatic rollback, saves that wait for the connection when you're offline, and the onSaveStart, onSaveSuccess and onSaveError hooks. Each call is also exactly one undo step.
#What an action is
An action is just a function you keep next to your store. It can do whatever the job needs: read any number of keys, await a request, write several keys, batch them into one undo step, emit an event, invalidate a query. Checking out, signing in, importing a file, syncing a draft. Anything with more than one step is an action.
The trade-off is that the store can't reason about an action the way it can about a key action. It doesn't know what an action is going to touch, so there's no automatic rollback for the action as a whole. If you want one, you build it from setters with a server call, or from key actions with save.
#Side by side
| Key action | Action | |
|---|---|---|
| Belongs to | One key | The whole store |
| Receives | That key's current value, plus your arguments | An api with getState, setState, batch, emit and the query cache |
| Returns | The key's next value | Anything you like, including a promise |
| Can read other keys | No | Yes |
| Async work and server calls | Only through save | Yes, freely |
| Optimistic save with rollback | Built in, with { run, save } | Only if you use a setter with a server call inside it |
| Offline: waits for the connection | Yes, when offline: true and it has a save | Use whenOnline yourself |
| Undo | One step per call | One step per setState, unless you wrap them in batch |
| Called as | setCart.add(id) in a component, or store.cart.add(id) anywhere | store.actions.checkout() |
| Testing | Plain function in, value out | Needs a store, or mocks for what it calls |
#Using both together
Here's a cart that uses each one for what it's good at. Adding and removing items are key actions, because they're small, named changes to one key. Checking out is an action, because it calls the server, changes two keys, and tells the rest of the app it happened.
import { createStore } from '@createstore/createstore';
type Item = { id: string; qty: number };
const server = { checkout: async (items: Item[]) => ({ orderId: 'A-1001' }) };
const store = createStore(
{ cart: [] as Item[], lastOrderId: null as string | null },
{
// small, named changes to one key
keyActions: {
cart: {
add: (cart, id: string) => [...cart, { id, qty: 1 }],
remove: (cart, id: string) => cart.filter((item) => item.id !== id),
},
},
// a workflow: server call, two keys, one undo step, an event
actions: (api) => ({
async checkout() {
const { orderId } = await server.checkout(api.getState('cart'));
api.batch(() => {
api.setState('cart', []);
api.setState('lastOrderId', orderId);
});
api.emit('toast', `Order ${orderId} placed`);
},
}),
}
);
function Cart() {
const [cart, setCart] = store.useGlobalState('cart');
return (
<>
{cart.map((item) => (
<button key={item.id} onClick={() => setCart.remove(item.id)}>Remove {item.id}</button>
))}
<button onClick={() => setCart.add('sku-42')}>Add sku-42</button>
<button onClick={() => store.actions.checkout()}>Check out</button>
</>
);
}#Which one should I use?
Ask yourself one question: does this only change one key, using nothing but that key's current value and the arguments I pass in?
If yes, make it a key action. Toggling a todo, adding to a cart, renaming an item, clearing a list. If it should also be saved to your server, give it a save and you get rollback and offline handling for free.
If no, make it an action. As soon as you need to read a second key, wait for a response before deciding what to write, change more than one key, or notify other parts of the app, you've got a workflow, and that's what actions are for.
And if it's a one-off, you don't need either. Calling the setter directly with an updater is perfectly fine. Key actions and actions are for logic you'd otherwise repeat or that deserves a name.
#Using key actions from an action
An action can call key actions and setters directly on its api, the same way you call them on the store: api.cart.add(item), api.step('done'). So key actions are the building blocks, and actions put them together:
import { createStore } from '@createstore/createstore';
type Item = { id: string; qty: number };
export const { useGlobalState, actions } = createStore(
{ cart: [] as Item[], step: 'cart' as 'cart' | 'done' },
{
keyActions: { cart: { remove: (cart, id: string) => cart.filter((item) => item.id !== id), clear: () => [] } },
actions: (api) => ({
dropOutOfStock(ids: string[]) {
api.batch(() => ids.forEach((id) => api.cart.remove(id))); // the key action, reused
},
async finish() {
await api.cart.clear();
await api.step('done'); // a key's setter, like store.step('done')
},
}),
}
);Everything is typed as it is on the store. A key named like one of the api's own members (batch, emit, getState…) keeps the member; that key's setter is still api.setState(key, …).
#Using it outside React
Anything a component can do, plain code can do as well. That's handy for API clients, event handlers, and tests.
| Member | What it does |
|---|---|
store.getState('key') | Reads one key, computed keys included |
store.getState() | A snapshot of every key |
store.setState(key, value, save?) | Updates a key, same as the hook's setter |
store.set.key(value, save?) | The setter for key, with its key actions attached |
store.subscribe(listener) | Calls listener(change) on every change and returns an unsubscribe function |
store.actions | Your actions |
import { createStore } from '@createstore/createstore';
const store = createStore({ token: null as string | null });
export async function signIn(email: string, password: string) {
const res = await fetch('/api/sign-in', { method: 'POST', body: JSON.stringify({ email, password }) });
const { token } = (await res.json()) as { token: string };
store.setState('token', token);
}#Queries
A query is data from your server, read with the same useGlobalState as everything else. You write a function that fetches the data; wrapping it in query() adds caching, loading states, retries and refreshing. There's one helper and one hook for every kind of server data:
| You want | Declare it as | Read it with |
|---|---|---|
| One thing, like the todo list | todos: query(fetchTodos) | useGlobalState('todos') |
| One thing per id or filter | product: query(fetchProduct) | useGlobalState('product', id) |
| A list that loads in pages | feed: query(fetchFeed, { pages: { first, next } }) | useGlobalState('feed') |
Here's the first kind:
import { createStore, query } from '@createstore/createstore';
type Todo = { id: number; title: string };
// Your own function: it asks your server for the data
const fetchTodos = (): Promise<Todo[]> => fetch('/api/todos').then((r) => r.json());
const { useGlobalState } = createStore({
todos: query(fetchTodos),
});
function TodoList() {
const [todos, , status] = useGlobalState('todos');
if (status.loading) return <p>Loading…</p>;
if (status.error) return <button onClick={status.refetch}>Retry</button>;
return <ul>{todos.map((t) => <li key={t.id}>{t.title}</li>)}</ul>;
}#Your fetch function
The function you give query() is yours: the library doesn't provide it, and it can do anything that ends in a promise of data, like fetch, axios, a GraphQL client or a Firebase call. It receives two arguments:
| Argument | What it is |
|---|---|
params | Whatever you pass as useGlobalState(key, params) or fetchQuery(key, params), or undefined when you pass nothing |
signal | An AbortSignal. Hand it to fetch and cancelled requests really stop; see Cancelling |
A query that takes no params can ignore both, like fetchTodos above. One that does uses the first: query((id: string, signal) => fetch(/api/users/${id}, { signal }).then((r) => r.json())). A paged query's function gets the page first: (page, params, signal).
#When it loads
It loads the first time something shows it, whether that's a component reading the key directly or a computed value or selector that reads it. If ten components show the same query, they share one request.
After that the data counts as fresh for staleTime, which is 30 seconds by default. Once it's stale, the next component that mounts reloads it in the background while still showing the old data, and the same happens when the window gets focus back or the connection returns.
#Lifecycle
Put together, a query's life looks like this:
A component shows the key (or a computed value or selector that reads it)
→ the query is marked as on screen and loads, if it isn't fresh
→ ten components showing it share that one request
→ the response is stored and every reader re-renders
→ after staleTime it's stale: the next mount, focus or reconnect reloads it in the background
→ when nothing shows it any more, it stops refreshingA request you cancel is just cancelled, not failed: there's no error status and you keep the data you had. A local write or an invalidate() makes any response already on its way out of date, so that response is thrown away and the query loads again.
#Status
The third item of useGlobalState says where a query is in one word, state, so a component needs one switch rather than several ifs in the right order:
const [todos, , status] = useGlobalState('todos');
switch (status.state) {
case 'waiting': return <p>Waiting…</p>; // params not known yet, enabled: false, or waitFor
case 'loading': return <p>Loading…</p>; // first load, nothing to show yet
case 'error': return <p>Couldn't load: {String(status.error)}</p>;
case 'ready': return <TodoList todos={todos} />;
}state | When |
|---|---|
'waiting' | Its params aren't known yet, enabled is false, or a waitFor query isn't ready. No request is made |
'loading' | Its first load is running, so there's nothing to show yet |
'error' | It failed and there's no data to show |
'ready' | There's data, also while it refreshes in the background (fetching: true) and after a refresh failed (error is set, the old data stays) |
A failed refresh stays 'ready' on purpose: good data on screen shouldn't be replaced by an error page because a background update didn't work; show a small note from status.error instead. For a paged query, 'loading' is only the first page: loading more keeps it 'ready', with isFetchingNextPage: true. Plain keys are always 'ready', and a computed value is 'loading' or 'error' when something it reads is.
The detailed fields are there too, for when you need them:
| Field | Meaning |
|---|---|
state | 'waiting', 'loading', 'error' or 'ready', as above |
loading | First load, nothing to show yet |
fetching | Any request in progress, background refreshes included |
error | The last error, or null |
blocked | Waiting rather than loading: see Dependent queries |
blockedBy, rootError | What it's waiting for, and that query's error if it failed (with waitFor) |
refetch() | Load it again right now; does nothing while blocked |
A paged query adds its page controls to the same object; see Paged queries.
#Loading from the first render
A query with no data yet reports loading: true from its very first render, before its request has even started, so there's no flash of "nothing" between mounting and loading. A query that's waiting (for params, enabled or waitFor) reports blocked: true and loading: false instead.
#Options
Every option about the data goes in query(), next to the function it's about. The hook takes only two options of its own, both about when to load: enabled and waitFor (see Waiting for params and Dependent queries).
| Option | Default | What it does | Works with |
|---|---|---|---|
staleTime | 30 s | How long data counts as fresh | Every query |
retry | 2 | Retries after a failure | Every query |
retryDelay | 500 ms, 1 s, 2 s… | Wait before each retry, or your own attempt => ms | Every query |
gcTime | 5 min | How long data nobody shows stays cached | Queries read with params, and paged ones |
select | none | Expose a transformed value; see below | Queries without pages |
initial | [] | The value before the first response, without params | Queries without pages |
refetchAfterMutation | false | Reload after a successful save | Queries without pages |
pages | none | { first, next, max }; see Paged queries | Paged queries |
import { createStore, query } from '@createstore/createstore';
type Product = { id: string; name: string; price: number };
const fetchProduct = (id: string, signal: AbortSignal): Promise<Product> =>
fetch(`/api/products/${id}`, { signal }).then((r) => r.json());
export const { useGlobalState } = createStore({
product: query(fetchProduct, {
staleTime: 60_000, // fresh for a minute
retry: 3,
retryDelay: (attempt) => 1000 * attempt, // 1 s, 2 s, 3 s
gcTime: 10 * 60_000, // keep products nobody shows for 10 minutes
select: (p) => ({ ...p, label: `${p.name} (${p.price} €)` }),
}),
});#Options in the wrong place
An option in the wrong place is a TypeScript error, and at runtime query() or the hook throws as soon as it's called, saying where the option belongs:
query(): enabled is an option of the hook: useGlobalState(key, params, { enabled }), not of query().
query(): select can't be used with pages. A paged query's value is its list of pages; select is for queries without pages.
query(): pages has no option "staleTime". Inside pages: first, next, max (staleTime goes next to pages, not inside it).
useGlobalState("product", params, options): staleTime is an option of the query: query(fetch, { staleTime }), not of the hook.Typos are caught the same way (there's no option "staleTim"), and so is passing params to a key that isn't a query.
#Transforming data with select
Sometimes the server hands you more than you want to show. select lets components see a transformed version while the store keeps the raw response, so reads get the selected value and everything else (setters, optimistic updates, persistence, undo) works on the raw data. It works the same with params: each entry is selected on its own.
import { createStore, query } from '@createstore/createstore';
type User = { id: number; name: string; active: boolean };
const store = createStore({
activeUsers: query(async () => [] as User[], { select: (users) => users.filter((u) => u.active) }),
});
const shown: User[] = store.getState('activeUsers'); // selected
store.setState('activeUsers', (raw) => [...raw, { id: 9, name: 'Lin', active: false }]); // rawselect only runs again when the raw data changes, so components keep getting the same object until then.
#Writing to query data
Query keys can be written like any other key, either for an optimistic update or to drop in data you already have. A local write makes any request already in flight out of date, so its response is discarded and the query reloads. Older server data never quietly overwrites something you just set.
#Queries with params
When data depends on something, like a product by id, pass that as the second argument. Each distinct value gets its own cache entry, so useGlobalState('product', 123) and useGlobalState('product', 456) are cached, loaded and refreshed separately.
import { createStore, query } from '@createstore/createstore';
type Product = { id: number; name: string };
// Your own function: it gets the id you pass in
const fetchProduct = (id: number, signal: AbortSignal): Promise<Product> =>
fetch(`/api/products/${id}`, { signal }).then((r) => r.json());
const { useGlobalState } = createStore({
product: query(fetchProduct, { staleTime: 60_000 }),
});
function ProductPage({ id }: { id: number }) {
const [product, , { loading, error, refetch }] = useGlobalState('product', id);
if (loading) return <p>Loading…</p>;
if (error) return <button onClick={refetch}>Retry</button>;
return <h2>{product?.name}</h2>;
}#What you get back
The same three things as any key:
| Item | Meaning |
|---|---|
[0] the value | The response for these params (selected, if you set select), or undefined until it has loaded |
[1] the setter | Writes this entry. Give it a server call as its second argument and it's an optimistic update: if the call fails, the entry goes back to what it was, unless something newer has replaced it since |
[2] the status | loading, fetching, error, blocked, refetch(), as for every query |
#With and without params
A query can be read both ways. Without params, by leaving them out (useGlobalState('user')), it's the query's own state key: its function is called with undefined, and it works with everything a state key does, like undo, persistence, key actions, computed values and initial. With params, each params is a cache entry: loaded, refreshed and released on its own, with gcTime deciding how long one nobody shows is kept.
#Waiting for params
Params you pass that are undefined mean the params aren't known yet, so the query waits: no request, no value, and its status says blocked: true rather than loading. That covers the most common case without any options:
const [product] = useGlobalState('product', productId); // waits while productId is undefined
const [projects] = useGlobalState('projects', user?.id); // waits until the user has loadedFor anything else that should hold a query back, pass enabled as the third argument. The query waits while it's false, and loads as soon as it turns true:
const [open] = useGlobalState('isDetailsOpen');
const [details] = useGlobalState('jobDetails', jobId, { enabled: open }); // only when the dialog is open
const [jobs] = useGlobalState('jobs', filters, { enabled: filters.machine !== '' });A component can switch params, or go from params to no params and back, from one render to the next.
A query declared without params takes no params argument at all, not even undefined: TypeScript flags useGlobalState('feed', undefined) for one, and in plain JavaScript you get a one-time hint if a wrapper forwards an empty argument to it (const useFeed = (p) => useGlobalState('feed', p)), since that would wait forever. Leave the argument out to read a query without params.
#What can be a param
Strings, numbers, objects, arrays, Map, Set and Date all work. Two objects with the same contents share a cache entry no matter what order their keys are in, so creating a fresh { term, page } on every render is fine. Functions, symbols and promises can't be turned into a cache key, and you'll get an error saying so if you try.
Params are meant to be small: an id, or a filter object. Params with more than 10,000 values or 1 million characters in total fail with an error saying so, rather than costing time on every render. And when a component passes the same params object as last time (one from store state, a useMemo, or a constant), its cache key isn't worked out again. That's the same identity check React uses for hook dependencies, so, as with those, change a params object by making a new one rather than editing it in place.
#Examples
A dashboard, a master/detail view, search and filters, all in one store:
import { useState } from 'react';
import { createStore, query } from '@createstore/createstore';
type Machine = { id: string }; type Job = { id: string; machine: string; status: string };
type Customer = { id: string; name: string };
const get = <T,>(url: string, signal: AbortSignal): Promise<T> => fetch(url, { signal }).then((r) => r.json());
const { useGlobalState } = createStore({
// a dashboard: queries without params
machines: query((_: undefined, signal) => get<Machine[]>('/api/machines', signal)),
alarms: query((_: undefined, signal) => get<string[]>('/api/alarms', signal)),
// master/detail: a list, and one item by id
customers: query((_: undefined, signal) => get<Customer[]>('/api/customers', signal)),
customer: query((id: string, signal) => get<Customer>(`/api/customers/${id}`, signal)),
// search and filters: object params, one entry per combination
search: query((p: { term: string; page: number }, signal) => get<Job[]>(`/api/search?q=${p.term}&page=${p.page}`, signal)),
jobs: query((f: { machine?: string; status?: string }, signal) => get<Job[]>(`/api/jobs?${new URLSearchParams(f)}`, signal)),
});
function Dashboard() {
const [machines] = useGlobalState('machines');
const [alarms] = useGlobalState('alarms');
const [running] = useGlobalState('jobs', { machine: 'TRULASER', status: 'Running' });
return <p>{machines.length} machines, {alarms.length} alarms, {running?.length ?? 0} running</p>;
}
function Customers() {
const [customers] = useGlobalState('customers');
const [selectedId, setSelectedId] = useState<string>();
const [customer] = useGlobalState('customer', selectedId); // waits until one is selected
return (
<>
{customers.map((c) => <button key={c.id} onClick={() => setSelectedId(c.id)}>{c.name}</button>)}
{customer && <h2>{customer.name}</h2>}
</>
);
}
function Search({ term, page }: { term: string; page: number }) {
const [results, , { loading }] = useGlobalState('search', { term, page });
return loading ? <p>Searching…</p> : <p>{results?.length} results</p>;
}#Dependent queries
When one query needs another's data, use the first query's data as the second one's params. Until it's there, the params are undefined and the second query waits. Pass the first query's status as waitFor, and the second one's status also says what it's waiting for and why:
import { createStore, query } from '@createstore/createstore';
type User = { id: string }; type Project = { id: string; name: string };
const get = <T,>(url: string, signal: AbortSignal): Promise<T> => fetch(url, { signal }).then((r) => r.json());
const { useGlobalState } = createStore({
currentUser: query((_: undefined, signal) => get<User>('/api/me', signal)),
projects: query((userId: string, signal) => get<Project[]>(`/api/users/${userId}/projects`, signal)),
});
function Projects() {
const [user, , userStatus] = useGlobalState('currentUser');
const [projects, , status] = useGlobalState('projects', user?.id, { waitFor: userStatus });
if (status.rootError) return <p>Couldn't load your account, so no projects either.</p>;
if (status.blocked) return <p>Waiting for {status.blockedBy}…</p>; // 'currentUser'
if (status.loading) return <p>Loading projects…</p>;
if (status.error) return <p>Couldn't load projects.</p>;
return <ul>{projects?.map((p) => <li key={p.id}>{p.name}</li>)}</ul>;
}That gives you three states that never get confused:
| Status | Means |
|---|---|
blocked: true | Waiting: params not known yet, enabled: false, or a waitFor query isn't ready. No request is made |
loading: true | Its own request is running |
error | Its own request failed |
While blocked, blockedBy names the query it's waiting for, and rootError holds that query's error if it failed. Both carry through a whole chain, so in company → department → employees, if company fails, employees reports blockedBy: 'company' and company's error, and nothing below company makes a request:
const [company, , companyStatus] = useGlobalState('company', companyId);
const [department, , departmentStatus] = useGlobalState('department', company?.defaultDepartmentId, { waitFor: companyStatus });
const [employees, , status] = useGlobalState('employees', department?.id, { waitFor: departmentStatus });
// status.rootError: company's error, if company failedwaitFor takes one status or a list of them, from any query, paged ones included. A query only blocks what waits for it while it has no data: if a background refresh fails but its data is still there, the queries waiting for it keep going. refetch() does nothing while a query is blocked. Without waitFor, blocked still tells waiting from loading; blockedBy and rootError stay null, because the store can't know where your params came from.
#Working with the cache
Each of these takes the query's name and, optionally, params. Without params, it's about the query's own state key; invalidate and abort also cover every params of it.
| Member | What it does |
|---|---|
store.fetchQuery(name, params?, force?) | Loads data, or returns what's cached and fresh, from outside a component |
store.setQueryData(name, updater, params?) | Writes data, for example after a save returns the updated object |
store.invalidate(name) | Marks it stale, every params included, and reloads what's on screen |
store.abort(name) | Cancels its requests in flight |
import { createStore, query } from '@createstore/createstore';
type User = { id: string; name: string };
const store = createStore({ user: query(async (id: string) => ({ id, name: '' }) as User) });
export async function rename(id: string, name: string) {
const saved = (await fetch(`/api/users/${id}`, { method: 'PATCH', body: JSON.stringify({ name }) }).then((r) => r.json())) as User;
store.setQueryData('user', saved, id); // show it now, no extra request
await store.invalidate('user'); // and refresh every other user on screen
}#Preloading
fetchQuery loads data before any component asks for it. When the component renders, the data is already there and there's no spinner. While data is fresh, fetchQuery just returns it, and calls that overlap share one request, so it's cheap to call as often as you like.
import { createStore, query } from '@createstore/createstore';
type Product = { id: string; name: string };
const store = createStore({
product: query((id: string, signal: AbortSignal): Promise<Product> => fetch(`/api/products/${id}`, { signal }).then((r) => r.json())),
dashboard: query(async () => ({ machines: 12 })),
});
// Route preloading: load in the route's loader, the page shows the data immediately
export async function productLoader(id: string) {
await store.fetchQuery('product', id);
}
// Hover preloading: start loading on hover, ignore errors (the page retries and shows them)
export const onProductHover = (id: string) => void store.fetchQuery('product', id).catch(() => {});
// Background warming: refresh every minute, so it's fresh whenever someone opens it
setInterval(() => void store.fetchQuery('dashboard', undefined, true).catch(() => {}), 60_000);Data preloaded with params that nothing ends up showing is released after gcTime, so hover preloading doesn't fill memory. For server-side preloading, call fetchQuery on the request's scope before rendering; see Data from the server.
#Paged queries
For paged lists and infinite scroll, give query() a pages option. Your function gets the page to load; pages.first says which page comes first, and pages.next works out the next one from what came back. Page numbers, cursor APIs and Firebase-style string cursors all fit the same shape.
import { createStore, query } from '@createstore/createstore';
type Page = { items: { id: string; title: string }[]; nextCursor: string | null };
// Your own function: it gets the page to load
const fetchFeed = (cursor: string | null, _: undefined, signal: AbortSignal): Promise<Page> =>
fetch(`/api/feed?after=${cursor ?? ''}`, { signal }).then((r) => r.json());
const { useGlobalState } = createStore({
feed: query(fetchFeed, {
pages: {
first: null, // the first request has no cursor
next: (lastPage) => lastPage.nextCursor, // null = no more pages
max: 10, // optional: keep the last 10 pages
},
}),
});
function Feed() {
const [pages, , { loading, hasNextPage, fetchNextPage, isFetchingNextPage }] = useGlobalState('feed');
if (loading) return <p>Loading…</p>;
return (
<>
{pages.flatMap((p) => p.items).map((item) => <article key={item.id}>{item.title}</article>)}
{hasNextPage && (
<button onClick={fetchNextPage} disabled={isFetchingNextPage}>
{isFetchingNextPage ? 'Loading…' : 'Load more'}
</button>
)}
</>
);
}#Different kinds of pagination
| Your API uses | pages.first | pages.next |
|---|---|---|
| Page numbers | 1 | (last, pages, page) => (last.length ? page + 1 : undefined) |
| A cursor | null | (last) => last.nextCursor |
| The last document's id (Firebase) | null | (last) => last.at(-1)?.id |
One gotcha with page numbers: use the third argument (page) rather than pages.length. If you set max, old pages get dropped and the length stops matching the page number.
#Options of a paged query
Next to pages, a paged query takes staleTime, retry, retryDelay and gcTime, the same as any query. select, initial and refetchAfterMutation don't apply, because a paged query's value is its list of pages, and using them throws an error that says so. Inside pages there are exactly three options: first, next and max.
#Params
A paged query can take params too, as its function's second argument, and each params is a separate list:
import { createStore, query } from '@createstore/createstore';
type Msg = { id: string; text: string };
const { useGlobalState } = createStore({
chat: query(async (afterId: string | null, { roomId }: { roomId: string }) => [] as Msg[], {
pages: { first: null, next: (last) => last.at(-1)?.id },
}),
});
// in a component: const [pages] = useGlobalState('chat', { roomId: 'general' })#What you get back
useGlobalState('feed') gives the loaded pages, no setter (pages are loaded, not set), and a status with the page controls:
| Item | Meaning |
|---|---|
[0] the pages | The loaded pages, in order, fully typed |
[1] | undefined |
status.hasNextPage | Becomes false once pages.next returns undefined or null |
status.fetchNextPage() | Loads the next page. It's safe to call on every scroll event: it does nothing while a page is loading, when you're at the end, or while the query is blocked, and it never throws |
status.isFetchingNextPage | A next page is on its way |
status.loading, fetching, error | As for every query |
status.refetch() | Starts again from the first page |
Pages you've already loaded aren't refreshed on focus or when they go stale. That's on purpose: a list that reshuffles under someone's thumb while they're scrolling is maddening. Call refetch() or invalidate() when you do want to start over. A paged query isn't state, so it can't be read with getState or from a computed value; trying says so.
#Optimistic updates
Give the setter a server call as its second argument. The new value shows up immediately, and if the call fails, the change is rolled back.
import { createStore, query } from '@createstore/createstore';
type Todo = { id: number; title: string; done: boolean };
const api = { toggle: async (id: number) => {} };
const { useGlobalState } = createStore({ todos: query(async () => [] as Todo[]) });
function TodoItem({ todo }: { todo: Todo }) {
const [, setTodos] = useGlobalState('todos');
const toggle = () =>
setTodos(
(all) => all.map((t) => (t.id === todo.id ? { ...t, done: !t.done } : t)),
() => api.toggle(todo.id) // the server call
).catch(() => alert('Could not save'));
return <input type="checkbox" checked={todo.done} onChange={toggle} />;
}#Why bother
Without it, every click means waiting on the network before anything visible happens. With it, the interface responds instantly and only backs out when the server says no:
Traditional: click → wait for the server → the UI updates
Optimistic: click → the UI updates at once → the server call runs
├─ it succeeds → the change stays
└─ it fails → the change rolls back#Rollback that keeps your other edits
Rolling back isn't as simple as restoring the old value, because other things may have happened in the meantime. So while a save is pending, every write to that key is recorded. If the save fails, only its own change is taken out, and everything else that happened in between is replayed on top. It comes out right regardless of the order the server answers in.
| What happened | What you end up with |
|---|---|
| Add A (save pending), add B locally, then A's save fails | [B]. A is gone, B stays |
| Two saves pending, the second fails first | The first one's change stays |
| A save fails after the key was replaced with a new value | The replacement stays |
Undo history gets the same treatment: a failed save's step disappears, while anything else changed in that same step stays undoable.
#Cancelling and retrying
#Cancelling
Every fetch function gets an AbortSignal, as its second argument (the third for a paged query). Pass it along to fetch and the request actually stops when it's cancelled:
import { createStore, query } from '@createstore/createstore';
const store = createStore({
report: query((_: undefined, signal) => fetch('/api/report', { signal }).then((r) => r.json() as Promise<string[]>)),
});
store.abort('report'); // cancel it nowA cancelled request counts as cancelled, not failed, so there's no error status and your existing data stays put. reset() and destroy() cancel anything still running. What doesn't cancel automatically is unmounting a component, because some other component may still want that result.
#Retrying
Failed requests are retried twice by default, waiting 500 ms and then 1 s (a third retry would wait 2 s). You can change the wait with retryDelay. attempt starts at 1:
query(fetchJobs, { retry: 3, retryDelay: (attempt) => 1000 * attempt }); // 1 s, 2 s, 3 sLeaving retryDelay out, or setting it to true, gives you the default, which is also exported as defaultRetryDelay. If a request is cancelled while it's waiting to retry, it stops right there instead of finishing the wait.
#Persistence
persist keeps state across reloads. With persist: true, plain keys are saved to localStorage and read back before the first render:
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore({ theme: 'light', sidebarOpen: true }, { name: 'app', persist: true });| Setting | Effect |
|---|---|
persist: true or 'local' | Uses localStorage |
persist: 'session' | Uses sessionStorage |
persist: { keys: ['theme'] } | Saves only these keys |
persist: { exclude: ['token'] } | Saves every plain key except these: the safe way to keep a secret out |
persist: { storage: adapter } | Saves anywhere you like, see storage adapters |
persist: { version, migrate } | Lets the saved shape change safely, see migrations |
persist: { debounce: 250 } | Waits until changes stop for 250 ms before saving, see below |
name | Prefix for the storage keys. Give each persisted store its own |
#Saving less often
Changes made inside batch() are saved once, when the batch ends, whatever the debounce setting.
By default every change is saved right away. For state that changes on every keystroke, like a draft, that's a lot of writes, and with a large state each one costs time. persist: { debounce: 250 } waits until the changes stop for 250 ms and then saves once. Nothing is lost by waiting: a pending save is written as soon as the page is hidden, closed or reloaded, and when the store is destroyed.
#Checking data from outside
Saved state, the offline copy and changes from other tabs come from outside the running app: written by an older version, edited by hand, or posted by another script on the same site. Pass validate to check them before they're used:
import { createStore } from '@createstore/createstore';
type Todo = { id: number; title: string };
export const { useGlobalState } = createStore(
{ todos: [] as Todo[], theme: 'light' as 'light' | 'dark' },
{
name: 'app', persist: true, syncTabs: true,
validate: (key, value, from) => {
if (key === 'todos') return Array.isArray(value);
if (key === 'theme') return value === 'light' || value === 'dark';
return true;
},
}
);from says where the value came from: 'storage', 'offline' or 'remote' (another tab). Return false, or throw, and that value is ignored: the key keeps what it had, and you get one warning per key and source. Values the store wrote itself, from your code or the server, aren't checked.
Query keys aren't saved unless you list them in keys, since they'll just reload from the server. Computed values are never saved.
#Migrations
Sooner or later you'll change the shape of something you persist. When that happens, bump version and tell the store how to turn old data into the new shape, otherwise people who saved the old shape will load the wrong thing.
import { createStore } from '@createstore/createstore';
// version 1 saved { userName: 'Bob' }; version 2 calls it name
const { useGlobalState } = createStore(
{ name: '' },
{
name: 'app',
persist: {
version: 2,
migrate(old: { userName?: string; name?: string }, version) {
if (version < 2) return { name: old.userName ?? '' };
return old;
},
},
}
);migrate gets the saved data and the version it was saved under. Anything saved before you added a version counts as version 0. Once it's migrated, the new shape is written back straight away, so migrate doesn't run again on the next load.
If the version changes and there's no migrate, the old data is dropped with a warning in the console. That's deliberate: loading data in the wrong shape tends to cause much stranger bugs than starting fresh. If migrate itself throws, the store starts from its initial state and logs the error.
#Storage adapters
storage takes anything with getItem and setItem, plus removeItem if you have it. They can return plain values or promises, so asynchronous storage like React Native's AsyncStorage, Capacitor Preferences or an IndexedDB wrapper works fine:
import { createStore, type StorageAdapter } from '@createstore/createstore';
// e.g. React Native AsyncStorage, Capacitor Preferences, an IndexedDB wrapper
declare const AsyncStorage: { getItem(k: string): Promise<string | null>; setItem(k: string, v: string): Promise<void> };
const storage: StorageAdapter = {
getItem: (key) => AsyncStorage.getItem(key),
setItem: (key, value) => AsyncStorage.setItem(key, value),
};
export const store = createStore({ token: null as string | null }, { name: 'app', persist: { storage } });
// with async storage, saved values arrive shortly after start
await store.hydrated;With an async adapter the store starts with its initial values and swaps in the saved ones as soon as they arrive. store.hydrated resolves at that point (straight away for synchronous storage), which is useful if you want to hold a splash screen until then. Nothing is written until loading has finished, so the initial state can never overwrite what was saved.
#Syncing tabs
Turn on syncTabs and every open tab of your app stays in sync. Change something in one tab and the others follow:
import { createStore } from '@createstore/createstore';
const { useGlobalState } = createStore({ cart: [] as string[] }, { name: 'shop', syncTabs: true, persist: true });When two tabs write at the same moment, they all settle on the same winner, using a logical clock rather than timestamps. Events stay local unless you broadcast them. Under the hood it's BroadcastChannel, which every current browser has. Values need to be cloneable, so no functions or class instances; if something can't be sent, the store warns once and carries on working locally.
#Choosing what other tabs get
syncTabs: true sends every key. To keep something in its own tab, like an access token, list what's sent or what isn't:
import { createStore } from '@createstore/createstore';
export const { useGlobalState } = createStore(
{ accessToken: '', cart: [] as string[], theme: 'light' },
{ name: 'shop', syncTabs: { exclude: ['accessToken'] } } // or { keys: ['cart', 'theme'] }
);Excluded keys are never sent, and a tab ignores them even if another tab sends them anyway. keys and exclude can't be combined, and a name that isn't a state key is an error when the store is created.
Changes made in the same tick go to other tabs as one message, so a loop of a hundred updates sends one, not a hundred. An event you broadcast always arrives after the state change it follows.
#Offline
offline: true makes your app keep working without a connection:
import { createStore, query } from '@createstore/createstore';
type Note = { id: number; text: string };
const api = { saveNotes: async (notes: Note[]) => {} };
const { useGlobalState } = createStore(
{ notes: query(async () => [] as Note[]) },
{
name: 'notes',
offline: true,
keyActions: {
notes: { add: { run: (notes, text: string) => [...notes, { id: Date.now(), text }], save: (next) => api.saveNotes(next) } },
},
}
);Server data is kept in localStorage, so after a reload with no connection the app still shows the last data it had. Queries don't try to refetch while you're offline. Key action saves wait for the connection to come back and then send the latest value, and any queries on screen refresh once you're online again.
#How much is kept
The offline copy shares localStorage with the rest of your site, which usually holds about 5 MB per site. So one saved copy may take at most 2 million characters by default; set your own limit with offline: { maxSize }. A copy over the limit isn't saved, the older copy is removed so it can't come back stale, and you get one warning saying so. The app keeps working; it just won't have that data offline.
#whenOnline
store.whenOnline(task) runs a task now, or as soon as the connection is back. Because it might run much later, read state inside the task rather than before it:
store.whenOnline(() => api.save(store.getState('notes'))); // sends what's current then
store.whenOnline(() => api.save(notes)); // may send stale data#Undo and redo
history records changes so people can undo and redo them:
import { createStore } from '@createstore/createstore';
const store = createStore(
{ shapes: [] as string[], selected: null as string | null, zoom: 1 },
{ history: { limit: 100, keys: ['shapes'] } } // only shapes are undoable
);
function Toolbar() {
const { canUndo, canRedo } = store.useHistory();
return (
<>
<button disabled={!canUndo} onClick={store.undo}>Undo</button>
<button disabled={!canRedo} onClick={store.redo}>Redo</button>
</>
);
}| Member | What it does |
|---|---|
history: true | Tracks every key and keeps 100 steps |
history: { limit, keys } | Keeps limit steps and tracks only keys |
store.undo(), store.redo() | Steps back or forward |
store.goTo(position) | Jumps straight to any step |
useHistory() | Returns { canUndo, canRedo, position, length } and re-renders when they change |
#What actually gets recorded
Only your own changes. Data loaded from the server is never undone, and an undo never touches keys that changed in between.
I'd strongly suggest listing keys. Without it, opening a modal or typing in a search box creates undo steps, which is almost never what people expect when they press Ctrl+Z. Keys you don't list never add steps and never clear the redo stack.
A batch becomes a single step, so several keys changed together are undone together. And if an optimistic update fails and rolls back, its step is removed from the history as well.
#Events
Some things aren't state at all. "Show a toast." "The export finished." They happen once and there's no value to keep around, so they don't belong in the store. Events cover that. You can declare their payload types with defineEvents:
import { createStore, defineEvents } from '@createstore/createstore';
import { useEffect } from 'react';
type Events = { toast: string; jobUpdated: { id: number; status: string }; signedOut: void };
const store = createStore({}, { events: defineEvents<Events>() });
store.emit('toast', 'Saved'); // payload type checked
store.emit('signedOut'); // void events need no payload
function Toasts() {
useEffect(() => store.on('toast', (message) => console.log(message)), []);
return null;
}on returns an unsubscribe function, which is why it slots straight into useEffect like that.
#Other tabs
By default an event stays in the tab it happened in, which is almost always what you want. Nobody needs the same toast popping up in four tabs. When you do want other tabs to hear it, turn on syncTabs and pass { broadcast: true }:
store.emit('jobUpdated', { id: 7, status: 'done' }, { broadcast: true });If you skip defineEvents, events accept any name and any payload.
#Batching
batch groups several updates into a single undo step. It's for the moments when one user action changes several keys and undoing it should put all of them back:
import { createStore } from '@createstore/createstore';
const store = createStore({ cart: ['book'], step: 'paying' }, { history: true });
store.batch(() => {
store.setState('cart', []);
store.setState('step', 'success');
});
store.undo(); // restores both cart and stepUpdates inside a batch still apply and notify immediately; only the history is grouped. Batches can be nested. If the function throws partway through, whatever it already changed stays and is still saved as one step, so a single undo() takes the whole half-finished thing back.
#Server rendering
Server rendering has one hard rule: every request needs its own state. A store that lives at the top of a module is shared by every request a server handles, so if one request puts the signed-in user in it, the next request sees that user. Most libraries leave this to you, usually with a factory function and a context provider. With createStore you turn on ssr: true, and there's exactly one way to do it:
server: createScope(data) → render <Root scope> → add scope.toScript() to the page
browser: hydrate() → render <Root scope>And one rule to remember about writing state:
on the server → scope.cart.add(product) the request's store
in the browser → AppStore.cart.add(product) the browser's one store#The store
Create the store as usual, at the top of a module, and add ssr: true:
// store.ts
import { createStore } from '@createstore/createstore';
export type User = { id: number; name: string };
export type Product = { id: string; name: string; price: number };
export const AppStore = createStore(
{
user: null as User | null,
cart: [] as Product[],
theme: 'light' as 'light' | 'dark',
},
{
ssr: true,
persist: true,
keyActions: {
cart: { add: (cart: Product[], product: Product) => [...cart, product] },
},
}
);Components don't change at all. They read state the way they always have, and there's no Root, provider or initialState anywhere inside your app:
// Header.tsx
import { AppStore } from './store';
export function Header() {
const [user] = AppStore.useGlobalState('user');
return <p>Welcome {user?.name ?? 'guest'}</p>;
}#On the server
For each request, create a scope with the request's data, change it if you need to, render, and put scope.toScript() in the page:
// server.tsx
import { renderToString } from 'react-dom/server';
import { AppStore } from './store';
import App from './App';
export async function render(request: Request) {
const scope = AppStore.createScope({ user: await getUser(request) });
await scope.cart.add(await getSavedItem(request)); // server changes go through the scope
const html = renderToString(
<AppStore.Root scope={scope}>
<App />
</AppStore.Root>
);
// The snapshot as a data tag: escaped, and it never runs, so it's safe and works with a strict CSP
return `<div id="root">${html}</div>${scope.toScript()}`;
}A scope is a complete, independent store: same keys, same computed values, same key actions, its own state. It has getState, setState, set, key actions like scope.cart.add, fetchQuery, batch, undo and events. It never persists, syncs tabs, keeps an offline cache or connects to devtools, because it only lives for one request.
toScript() writes <script type="application/json" id="createstore:shop">…</script>. The browser never runs a script of that type, so it works under a strict Content Security Policy without a nonce, and the data inside is escaped, so a value like </script> can't end the tag. It needs the store's name, since that's how the browser finds the tag (and how several stores on one page keep theirs apart).
You don't have to clean a scope up. Once the request is done and nothing refers to it, it's garbage-collected like any other object: it starts no timers on the server, and 20,000 requests' worth of scopes leave the heap where it was. Call scope.destroy() when a request ends early, for example when the client disconnects or a timeout fires, because it also cancels the scope's requests that are still running:
request.signal.addEventListener('abort', () => scope.destroy()); // the client went away: stop fetching#In the browser
Render the same Root, with hydrate(). With no argument it finds the data tag the server wrote, reads it once and removes it from the page:
// client.tsx
import { hydrateRoot } from 'react-dom/client';
import { AppStore } from './store';
import App from './App';
const scope = AppStore.hydrate();
hydrateRoot(
document.getElementById('root')!,
<AppStore.Root scope={scope}>
<App />
</AppStore.Root>
);If there's no tag, because the page wasn't server-rendered or toScript() was left out, hydrate() warns and the app starts from its initial state. The browser only ever has one user, so hydrate doesn't create anything new. It fills the app's own store, the one AppStore refers to, and hands it back for Root. Because the browser starts from exactly what the server rendered, hydration matches.
From then on, AppStore works anywhere in the browser, inside components or not. Event handlers, services and timers can call AppStore.cart.add(product), AppStore.setState(...) or AppStore.actions.checkout(), and every component sees the change.
hydrate fills the store once, the way useState treats its initial value. If Root is unmounted and mounted again, say on a route change, or React's StrictMode renders twice, or a hot reload runs the code again, what the user changed since isn't overwritten.
Saved state from persist still loads. Anything in the snapshot takes priority over it, because what the server just sent is newer, and keys the snapshot doesn't include keep their saved values. They're applied right after hydration, so they never cause a mismatch.
#What you can't do on the server
On the server, AppStore itself can't be used outside a Root. A call like AppStore.cart.add(product) in a request handler has no way of knowing which request it's for, so instead of quietly sharing data between requests it throws:
[shop] Cannot call cart.add() during server rendering: on the server every request has its own
store, and this call can't tell which one it's for. Use the request's scope instead:
scope.cart.add(...), with const scope = createScope(...).The same goes for every other store call (AppStore.setState, AppStore.getState, AppStore.snapshot and so on) and for hooks rendered outside a Root. Importing the store still works, and so does destructuring it at the top of a module (const { actions } = AppStore). Only an actual call throws. Inside components nothing changes, because hooks always find the request's Root.
#Data from the server
Whatever you give createScope is the starting value, and that includes query() keys. A query key given data counts as already loaded, and so does one you await scope.fetchQuery(...) before rendering. Both end up in the HTML and in the snapshot, so the browser doesn't fetch them again until they go stale.
snapshot() only includes a query key once it has loaded. A query that hasn't loaded yet still holds its empty initial value, and sending that would make the browser think it already has the data.
Queries with params and paged queries work the same way. Prefetch them into the request's scope and they render with their data:
const scope = AppStore.createScope({ user });
await scope.fetchQuery('product', productId); // a query with params
await scope.fetchQuery('feed'); // a paged query: its first pageThe snapshot carries their data in a $queries field, which hydrate puts back into the browser's cache, as old as it was on the server (see How fresh the data is). The browser renders the same HTML, doesn't fetch them again, and a paged list's next page continues from where the server stopped. Treat $queries as opaque; it's keyed by the query's name and params.
A query you didn't prefetch renders its loading state on the server and loads in the browser after hydration, with no mismatch, because both sides show the same loading state.
#Choosing what to send
snapshot() includes every state key by default. Computed values are never included, because the browser recalculates them. To send only some keys, list them:
import { createStore } from '@createstore/createstore';
export const AppStore = createStore(
{ user: null as { name: string } | null, cart: [] as string[], draft: '' },
{ ssr: { keys: ['user', 'cart'] } } // or { exclude: ['draft'] }: draft stays on the server
);ssr: { keys } or ssr: { exclude } turns on server rendering exactly like ssr: true. The list only decides what goes into the snapshot, and it can name state keys and queries alike: keys: ['user', 'product']. It's the same pair of words persist and syncTabs use: keys lists what's included, exclude what isn't, and you pick one. A name that isn't a state key is an error when the store is created.
#Snapshot versions
Every snapshot carries a $version, 0 by default. Because snapshots use $version, $queries and $ages, those can't be state keys; createStore says so if you try. When you change the shape of your state in a way an old snapshot wouldn't fit, bump it with ssr: { version: 2 }. A browser running the new code that receives a snapshot made for another version, for example from a page cached before the deployment, ignores it with a warning instead of starting from data of the wrong shape. React then renders that page fresh in the browser, which is always correct, if a moment slower.
#What a snapshot can hold
A snapshot travels as JSON, so it can only hold what JSON brings back unchanged: strings, finite numbers, booleans, null, arrays and plain objects. snapshot() checks every value it sends, and if one wouldn't survive the trip it throws right away, naming the key and the exact place:
[shop] snapshot() can't send state key "cart": cart[2].addedAt is a Date (JSON turns it into a string).
Snapshots travel as JSON, so keep that state to strings, numbers, booleans, null, arrays and plain
objects (a Date as an ISO string or a timestamp), or leave it out with ssr: { exclude: [...] }.That's much easier to fix than the alternative, which is the browser quietly starting with different data than the server rendered. Here's what it catches:
| Value | What JSON would do |
|---|---|
Date | Turn it into a string |
Map, Set | Turn it into {} |
| A class instance | Keep only its fields, as a plain object |
BigInt | Fail |
| A function or symbol | Drop it |
NaN, Infinity | Turn it into null |
| A value that refers back to itself | Fail |
undefined in an array | Turn it into null |
A state key that's undefined | Drop it, so the browser starts from the initial value; use null |
An object property that's undefined is fine: after JSON it's simply absent, which reads the same. Keys you leave out with ssr: { keys } or ssr: { exclude } aren't checked, since they aren't sent.
#How fresh the data is
Each query in a snapshot carries its age: how long ago it loaded. The browser counts it as loaded that long ago, so staleTime keeps working across the trip. Data the server loaded an hour ago is stale in the browser and reloads when it's shown; data loaded a moment ago isn't fetched again. It's an age rather than a timestamp because the server's clock and the browser's can disagree by minutes. Only loaded data travels: a query that failed on the server isn't sent, and simply loads in the browser.
#A few more details
- One component.
Rootis the only component, on the server and in the browser. It always takes a scope, and<Root>without one throws an error saying where to get one. - Render Root once. Put one
Rootat the top of the app, on the server and in the browser. ARootinside another one does win for the components inside it, and in the browser that's a fine way to give part of a page its own isolated store:<AppStore.Root scope={AppStore.createScope()}>. Two kinds of nesting are almost always mistakes, so they log a warning: nesting during server rendering, where part of the page would use a different store than the request's, and passing the app's store (fromhydrate()) to aRootinside another one. - Typos. An unknown key in
createScope(...)is a TypeScript error, and at runtime it throws, naming the key. An unknown key in a snapshot only logs a warning, because a snapshot made by an older deployment shouldn't break the page. - Without
ssr: true.Rootandhydratethrow an error asking forssr: true, because only then do hooks look for aRoot.createScope()andsnapshot()work either way. shared: trueis ignored on the server, as before. In the browser it still reuses the store across hot reloads, andhydratedoesn't refill it.
#Other frameworks
Every framework that renders on a server follows the same lifecycle: create state per request, render, send the state along, pick it up in the browser. Only three of the store's pieces are involved, and none of them depends on React:
| Step | Call |
|---|---|
| A store for this request | AppStore.createScope(data) |
| The state to send | scope.snapshot() |
| Picking it up in the browser | AppStore.hydrate(snapshot) |
So the same store works with Next.js, Remix and Astro today, through Root, and an adapter for Vue, Svelte, Solid or anything else only has to hand the scope to its components. createScope, snapshot and hydrate are already framework-independent; the library currently ships the React layer, Root.
#Doing it by hand
If you'd rather not use ssr: true, the classic approach still works: create a store per request in a function and pass it down through context.
import { createStore } from '@createstore/createstore';
import { createContext, useContext } from 'react';
type User = { name: string };
export const makeStore = (user: User | null) => createStore({ user, cart: [] as string[] });
type AppStore = ReturnType<typeof makeStore>;
const StoreContext = createContext<AppStore | null>(null);
export const useStore = () => useContext(StoreContext)!;
export function App({ store }: { store: AppStore }) {
return <StoreContext.Provider value={store}><Header /></StoreContext.Provider>;
}
function Header() {
const [user] = useStore().useGlobalState('user');
return <span>{user?.name ?? 'Guest'}</span>;
}ssr: true does the same thing for you, and also guards the module-level store on the server, which a hand-written factory can't.
#Lifecycle
Three members deal with a store's life rather than its data:
| Member | When you'd use it |
|---|---|
await store.hydrated | To wait until saved state has loaded. It's immediate with localStorage, and useful with async storage, for example to hold a splash screen |
store.reset() | To go back to the initial values declared in createStore (not data from hydrate() or createScope(data)), with no history, an empty query cache and any requests cancelled. Subscriptions stay. Mostly for tests |
store.destroy() | To stop the store for good: tab sync, window listeners, devtools, timers and in-flight requests. Afterwards the store is inert: writes and events are ignored with one warning, and fetchQuery rejects. For tests, hot reloading and short-lived stores |
import { createStore } from '@createstore/createstore';
const store = createStore({ token: null as string | null }, { name: 'app', persist: true });
await store.hydrated; // saved state is in place
store.reset(); // fresh state, same store
store.destroy(); // done with it#TypeScript
Types come from your initial state, so apart from the initial values themselves you'll rarely write an annotation.
import { createStore, query, defineEvents } from '@createstore/createstore';
type Todo = { id: number; title: string };
const store = createStore(
{
todos: query(async () => [] as Todo[], { select: (t) => t.length }),
filter: 'all' as 'all' | 'done',
},
{
computed: [{ label: (get) => `${get('filter')} (${get('todos')})` }],
events: defineEvents<{ toast: string }>(),
}
);
const count: number = store.getState('todos'); // the selected type
const label: string = store.getState('label'); // computed type, inferred
store.setState('filter', 'done'); // only 'all' | 'done' allowed
// store.setState('filter', 'nope'); // compile error
// store.emit('toast', 42); // compile error: payload is a stringIn practice that means key names are checked everywhere you use them: hooks, setters, getState, persist.keys, history.keys and keyActions. Setters only accept the key's type; for a query with select, reads have the selected type while writes take the raw one. Computed types are inferred layer by layer. useGlobalState(key, params) checks the params against your fetch function and types the data, paged queries type their pages and cursors, and an option in the wrong place is a compile error. With defineEvents, event names and payloads are checked, and your actions keep the argument types you gave them.
When TypeScript infers something too narrow, widen it with as on the initial value, like filter: 'all' as 'all' | 'done' or user: null as User | null.
#Testing
store.reset() puts a store back the way it was declared: initial values, no history, an empty query cache, and anything in flight cancelled. Subscribers and event handlers stay connected, so components that are still mounted keep working.
import { afterEach } from 'vitest';
import { store } from './store';
afterEach(() => store.reset());If a test creates its own stores, call store.destroy() at the end so tab sync, window listeners, devtools and timers are all cleaned up. For complete isolation, give each test its own store with store.createScope(): same keys and actions, fresh state, nothing shared. Queries call your real fetch functions, so mock fetch or pass in test versions.
#How it works
You don't need any of this to use the library, but knowing the shape of things explains why the same API holds up in a small app and a big one.
#Keys, computed values, components
Keys plain values, and query keys loaded from your server
↓ read by
Computed values derived from keys and earlier computed values; cached
↓ read by
Components through useGlobalState, which tracks exactly what each one readsQuery keys really are ordinary keys whose values happen to come from a server. Components, computed values, history and persistence all treat them the same as anything else.
#What happens on an update
Every change goes through the same path, whether it came from a setter, setState, a server response, another tab, undo or the devtools:
- The value is stored, but only if it actually changed. Writing the same value again does nothing.
- If it's a local change to a tracked key, history records an undo step.
- Pending work gets sorted out: optimistic edits that another tab or an undo just overrode are dropped, and responses still in flight for that key become out of date.
- Persistence saves the new state, and tab sync sends it to the other tabs.
- Computed values that depend on the key, directly or through other computed values, are marked stale. Nothing is recalculated yet.
- Subscribers are told: first the components showing the key, then computed values whose result actually changed, then the selectors reading them. Everything is recalculated as it's read, which is why nobody sees a half-updated mix.
- Last of all, change listeners run:
onChange,subscribeand the logger.
React then re-renders only the components whose values changed.
#Debugging
| Option | What you get |
|---|---|
devtools: true | A connection to the Redux DevTools extension. Every change is listed, and you can jump between states |
logger: true | Every change and event printed to the console |
onChange(change) | A callback for every change, if you want analytics or your own logging |
store.subscribe(listener) | The same thing, but added and removed at runtime |
#What DevTools and the logger show
Each change shows up with a label saying where it came from:
| Label | Comes from |
|---|---|
set todos | A setter, setState or a key action |
loaded todos | A query response |
rollback todos | A failed save being undone |
history 3 | undo, redo or goTo |
from other tab | Tab sync |
loaded from storage | Async persisted state arriving |
time travel | A jump made from inside Redux DevTools |
reset | store.reset() |
initial state | createScope(data) or hydrate(snapshot) filling a store |
event toast | An event (in DevTools and the logger) |
In Redux DevTools you can pick any state in that list and jump to it. The logger prints [name] label along with the value before and after, for every change and every event. It doesn't log query requests, only the responses that end up changing state.
#Change events
onChange and subscribe receive the same object for every change:
| Field | Meaning |
|---|---|
key | The key that changed |
prev, next | The value before and after |
source | 'local', 'server', 'remote' (another tab), 'history', 'devtools', 'storage' or 'hydrate' (data from createScope or hydrate) |
time | When it happened |
If a listener throws, the error is logged and the listener is skipped. It can't break the store.
#Guardrails
I'd rather a mistake fail loudly and tell you how to fix it than fail silently. These are the errors and warnings you might run into:
| The message mentions | What happened | What to do |
|---|---|---|
| is not in the store | A key name that doesn't exist | Add it to the initial state, or fix the typo |
| is computed and read-only | You wrote to a computed key | Write the keys it reads instead |
| was called while computed … was calculating | setState inside a computed function | Only calculate there; update in an event handler or action |
| was called inside a selector | setState inside a selector | Selectors should only read |
| returns the state itself | A selector returned the state reader | Return the values you need |
| depends on itself through a loop | Computed values reading each other in a circle | Break the loop |
| update loop | A listener keeps updating a key it reacts to | Add a guard to the listener |
| can't be used as a cache key | A function, symbol or promise in query params | Use plain values |
| batch() returned a promise | Async work inside batch | Batch the updates after await separately |
| can't run inside batch() | undo, redo or goTo inside batch | Call them afterwards |
| dropped saved state from version | The version changed without migrate | Add a migrate function |
| needs ssr: true | <Root> or hydrate() on a store without ssr: true | Add ssr: true to the options |
| Cannot call … during server rendering | With ssr: true, a store call like AppStore.cart.add() ran on the server | Use the request's scope: scope.cart.add(...) |
| ran during server rendering outside <Root> | A hook rendered on the server outside <Root> | Render the app inside <Root scope={createScope(...)}> |
| <Root> needs a scope | <Root> without a scope prop | <Root scope={createScope(...)}> on the server, <Root scope={hydrate(snapshot)}> in the browser |
| hydrate() is for the browser | hydrate() called on the server | Use createScope(...) on the server |
| hydrate() ignored a snapshot made for version … | A snapshot from another ssr.version (a warning) | Expected after a deployment; the page renders fresh. See Snapshot versions |
| Query params are too large | Params with more than 10,000 values or 1 million characters | Pass an id or a small filter object |
| more than offline.maxSize | The offline copy is too big to keep (a warning) | Raise offline: { maxSize }, or keep large responses out of the store |
| syncTabs takes keys or exclude, not both | Both lists given | Use one of them |
| snapshot() can't send … | A value JSON wouldn't bring back unchanged, like a Date or Map | Store plain data, or leave the key out with ssr.exclude; see What a snapshot can hold |
| is nested inside another <Root> | Nested Roots during server rendering, or the app's store under another Root (a warning) | Render <Root scope={...}> once, at the top |
| ssr lists … which isn't a state key or query | A typo, or a computed key, in ssr.keys or ssr.exclude | List state keys and query names only |
| initialState has … which is not in the store | A typo in createScope(...) | Fix the key name |
| … can't be a state key: server-rendering snapshots use that name | A state key named $version, $queries or $ages | Rename the key |
| … after destroy(): ignored | The store was used after destroy() (a warning) | Stop using it, or create a new one |
| <Root> got a scope made by a different store | A scope from another store passed to <Root> | Pass this store's own createScope() or hydrate() |
| persist lists … | A computed value, a paged query, or a name that isn't a state key, in keys or exclude | List state keys only |
| persist / ssr / syncTabs takes keys or exclude, not both | Both lists given | Use one of them |
| ssr.serialize is now ssr.keys | The old option name | Rename it to keys (or use exclude) |
| toScript() needs the store's name | toScript() on a store without name | createStore(state, { name: 'shop', ssr: true }) |
| hydrate() found no data tag | hydrate() with no argument, and the page has no tag (a warning) | Add scope.toScript() on the server, or pass the data: hydrate(data) |
| is waiting for params, but … takes none | Params passed (even undefined) to a query declared without params (a hint) | Leave the argument out: useGlobalState('feed') |
| is nested more than 1000 levels deep | State too deep to send in a snapshot | Flatten it, or leave it out with ssr.exclude |
| validate() rejected … | Data from storage, the offline copy or another tab failed validate (a warning) | Expected for bad data; the key keeps its value |
| is also the name of a store method | A state key named like a store method, e.g. reset | Use store.set.reset(...) for that key, or rename it |
#Best practices
None of these are rules, just things I've found work well.
Use useGlobalState in components and getState outside them. getState inside a component reads the value once and then never re-renders, which is a confusing bug to chase.
Derive instead of duplicating. If a value can be calculated from other state, make it a computed value rather than storing it, and the two can never drift apart.
Use key actions for operations on a single key, like setTodos.add, and actions for workflows that touch several keys or talk to a server.
Read state at the moment you use it. In actions and other async code, call getState after the await, not before, or you'll end up writing back stale data.
When one user action changes several keys, wrap it in batch so one undo reverts all of it.
Give every store that persists, syncs tabs or is shared its own name. Persist only what really has to survive a reload; open menus and hover states almost never do. In the same spirit, track only meaningful keys in history.
Keep values plain and immutable. Store data, not DOM nodes, functions or class instances. Values are compared by identity, saved as JSON and sent to other tabs, and plain data is what survives all three.
And make your saves throw when they fail, so optimistic updates know to roll back.
#Troubleshooting
#A component doesn't update
The usual cause is reading with store.getState(...) during render. That reads once and never subscribes, so use useGlobalState instead. The other common one is changing a value in place, like todos.push(x). The array is still the same object, so nothing notices. Create a new array or object.
#A computed value doesn't recalculate
A computed value only tracks what it read through get (or state.x) during its last run. Anything from outside the store, like a module-level variable or Date.now(), isn't tracked. If it should trigger a recalculation, put it in a key.
#Undo doesn't do anything
Check that history is enabled and that the key is in history.keys if you set that. Also remember that only local changes are undoable: changes that came from the server or another tab aren't. And undo() can't run inside batch(); it throws there, so call it afterwards.
#Saved state doesn't come back
A few things cause this. The version may have changed without a migrate, in which case the old data was dropped and there's a warning in the console. Two stores might share a name and be overwriting each other. The key might be a query (not saved unless you list it in persist.keys) or a computed value (never saved). With async storage, saved values arrive shortly after start, so await store.hydrated. And persist: 'session' only keeps data for the current tab.
#Storage is full
If the browser refuses a write, say because the storage quota is used up, the store keeps working, but that change isn't saved. Persist fewer or smaller keys, or switch to an adapter with more room, such as one backed by IndexedDB.
#Tabs don't sync
Make sure syncTabs: true is set and that the stores in each tab have the same name. Values that can't be cloned, like functions or class instances, can't be sent; you'll see a warning once in the console. And remember that events need { broadcast: true } to reach other tabs.
#A query refetches too often
Check whether its params really change on every render. Things like Date.now() or Math.random() will do that, but a new object with the same contents won't, because params are compared by content. Otherwise, staleTime may just be short for that data. Refreshing stale queries when the window regains focus or reconnects is intentional, and a longer staleTime reduces it.
#A query never updates
Data counts as fresh for staleTime. After your app changes something on the server, call store.invalidate(name), or refetch() from the status.
#A paged query keeps loading the same page
pages.next has to return a different page each time. Check that it reads the cursor from the last page it was given, and that it returns undefined or null when there's nothing left.
#An optimistic update never rolls back
The server call has to throw or reject for a rollback to happen. fetch resolves even for 404 and 500 responses, so check res.ok and throw yourself.
#Hydration mismatch with SSR
Most often the browser's store didn't start from the same data the server rendered. Serialize the per-request data into the page and pass it as the client store's initial state. The other cause is initial state that reads browser-only things like window, localStorage or the current time. Keep initial state the same on both sides; saved state from persist is applied after hydration anyway.
#"depends on itself through a loop"
Two or more computed values are reading each other in a circle. The message names the loop, something like total → subtotal → total, so you can see which read to remove.
#An action writes back old data
This one is easy to miss. If you read state, await something, and then write based on what you read, you're writing back data that may have changed while you waited:
// stale: cart was read before the await
const cart = store.getState('cart');
await api.check();
store.setState('cart', [...cart, item]);
// fresh: the updater gets the latest value
await api.check();
store.setState('cart', (cart) => [...cart, item]);#Cheat sheet
The whole API on one page, grouped by level, to read at a glance. Each part has its own section with the details.
// ---- Level 1: every app
const store = createStore({ todos: [], filter: 'all' }, { /* options below */ });
const [todos, setTodos, status] = useGlobalState('todos'); // like useState
const visible = useGlobalState('todos', (t) => t.filter(open)); // a selector
const total = useGlobalState((get) => get('todos').length); // read several keys
setTodos([]); setTodos((t) => [...t, todo]); // values or updaters
store.getState('todos'); store.setState('todos', []); store.subscribe(fn);
computed: [{ open: (get) => get('todos').filter((t) => !t.done) }]
keyActions: { todos: { add: (todos, text) => [...todos, text] } } // store.todos.add('x')
actions: (api) => ({ clear: () => api.todos([]) }) // store.actions.clear()
// ---- Level 2: server data
todos: query(fetchTodos) // useGlobalState('todos')
product: query((id, signal) => api.product(id, signal)) // useGlobalState('product', id)
feed: query(fetchPage, { pages: { first: 1, next } }) // [pages, , { fetchNextPage, hasNextPage }]
query(fn, { staleTime, retry, retryDelay, gcTime, select, initial, refetchAfterMutation })
useGlobalState('projects', user?.id, { enabled, waitFor: userStatus }) // waits while user?.id is undefined
switch (status.state) { case 'waiting': case 'loading': case 'error': case 'ready': }
setTodos(next, () => api.save(next)); // optimistic, rolls back on failure
store.fetchQuery('product', id); store.invalidate('product'); store.abort('product');
// ---- Level 3: when you need them
persist: { exclude: ['token'], version: 2, migrate, debounce: 250, storage }
syncTabs: { exclude: ['token'] } offline: { maxSize } history: { keys, limit }
validate: (key, value, from) => true // checks data from storage, the offline copy, other tabs
store.undo(); store.redo(); useHistory(); store.batch(() => { ... })
events: defineEvents<{ saved: number }>() store.emit('saved', 1); store.on('saved', fn)
// ---- Level 4: server rendering
ssr: { exclude: ['draft'], version: 1 }, name: 'shop'
const scope = AppStore.createScope({ user }); await scope.fetchQuery('product', id);
html + scope.toScript() // server: render <AppStore.Root scope={scope}>
<AppStore.Root scope={AppStore.hydrate()}> // browser
store.reset(); store.destroy();#API reference
#createStore(initialState, options?)
| Option | Type | Default | See |
|---|---|---|---|
name | string | 'store' | The store name |
computed | Layer[] (up to 8) | none | Computed values |
actions | (api) => actions | none | Actions, the api |
keyActions | { key: { name: fn | { run, save } } } | none | Key actions, entries |
events | defineEvents<Events>() | untyped | Events |
persist | true | 'local' | 'session' | PersistOptions | off | Persistence, fields |
history | true | { limit?, keys? } | off | Undo and redo, fields |
syncTabs | boolean | { keys? } | { exclude? } | false | Syncing tabs, which keys |
offline | boolean | { maxSize? } | false | Offline, how much is kept |
validate | (key, value, from) => boolean | none | Checking data from outside |
shared | boolean | false | Shared stores |
ssr | boolean | { keys?, exclude?, version? } | false | Server rendering: one store per request, guaranteed. keys or exclude choose the state keys and queries snapshot() includes |
devtools, logger | boolean | false | Debugging |
onChange | (change) => void | none | Debugging |
onSaveStart, onSaveSuccess, onSaveError | (key, action, error?) => void | none | Key actions |
#The store
| Member | Returns |
|---|---|
useGlobalState(key, params?, { enabled?, waitFor? }) | [value, setter, status]. With params, for a query with params; passed-but-undefined params wait. The value is undefined until it loads |
useGlobalState(pagedKey, params?, { enabled?, waitFor? }) | [pages, undefined, status], the status with page controls |
useGlobalState(key, selector) | The selected value (a function as the second argument is a selector, anything else is params) |
useGlobalState(state => ...) | Whatever the selector returns |
useHistory() | { canUndo, canRedo, position, length } |
getState(), getState(key) | Every value, or one |
setState(key, value, save?) | Promise<void> |
set.key(value, save?), set.key.action(...args) | Promise<void> |
key(value, save?), key.action(...args) | Promise<void>. The same setters, directly on the store; see direct calls |
createScope(data?) | A new, independent store, e.g. one per request; see SSR |
snapshot() | The state to send to the browser, with its $version: plain values, loaded queries only, data of queries with params and paged queries under $queries, query ages under $ages. Throws if a value isn't JSON-safe; see SSR |
hydrate(snapshot?) | Browser only: fills the app's store once and returns it (the store itself, not a new scope). With no argument, reads the tag toScript() wrote; see SSR |
toScript() | The snapshot as an escaped <script type="application/json"> data tag for the HTML; needs the store's name. See On the server |
Root | Component: <Root scope={scope}>. Needs ssr: true; see SSR |
actions | Your actions |
fetchQuery(name, params?, force?) | A promise of the data |
setQueryData(name, updater, params?) | nothing |
invalidate(name) | Promise<void> |
abort(name) | nothing |
emit(event, payload?, { broadcast? }) | nothing |
on(event, handler) | An unsubscribe function |
batch(fn) | Whatever fn returns |
undo(), redo(), goTo(position) | nothing |
subscribe(listener) | An unsubscribe function |
whenOnline(task) | A promise of the task's result |
hydrated | A promise that resolves once saved state has loaded |
reset(), destroy() | nothing |
#query(fetch, options?)
fetch is (params, signal) => Promise<T>: your own function. params is what you pass to useGlobalState(key, params) or fetchQuery(key, params), or undefined. Options:
| Option | Type | Default | What it does |
|---|---|---|---|
staleTime | number (ms) | 30000 | How long a response counts as fresh |
retry | number | 2 | How many times a failed request is retried |
retryDelay | true | (attempt) => number | 500 ms, 1 s, 2 s… | Wait before each retry; attempt starts at 1 |
gcTime | number (ms) | 300000 (5 min) | How long data for params nobody shows stays cached |
select | (data: T) => R | none | What components see; the store keeps the raw data. See select |
initial | T | [] | The value without params, before the first response |
refetchAfterMutation | boolean | false | Reload after a successful save |
#query(fetchPage, { pages })
A paged query. fetchPage is (page, params, signal) => Promise<Page>. Options:
| Option | Type | Default | What it does |
|---|---|---|---|
pages.first | TParam | required | The first request's page: 1, null, a cursor… |
pages.next | (lastPage, pages, page) => TParam | undefined | null | required | The next page; undefined or null means there are no more |
pages.max | number | unlimited | Keep only the newest N pages |
staleTime, retry, retryDelay, gcTime | as above | As for any query |
select, initial and refetchAfterMutation can't be used with pages. Any option in the wrong place throws; see Options in the wrong place.
#useGlobalState options
The optional third argument of useGlobalState(key, params, options):
| Option | Type | Default | What it does |
|---|---|---|---|
enabled | boolean | true | When false, the query is blocked: it doesn't load or count as on screen. See Waiting for params |
waitFor | Status | Status[] | none | Wait until these queries have data; fills in blockedBy and rootError. See Dependent queries |
Params that are passed but undefined also block the query: they mean the params aren't known yet. Leave them out to read a query without params.
#persist options
persist is true, 'local', 'session', or this object:
| Field | Type | Default | What it does |
|---|---|---|---|
storage | 'local' | 'session' | StorageAdapter | 'local' | Where state is saved. See Storage adapters |
keys | Array<keyof State> | every plain key | Which keys are saved |
exclude | Array<keyof State> | none | Every plain key except these is saved (instead of keys) |
version | number | 0 | The shape version of what's saved |
migrate | (persisted, version) => newState | none | Turns data saved under an older version into the current shape. See Migrations |
debounce | number (ms) | 0 | Wait until changes stop before saving; flushed when the page is hidden. See Saving less often |
#StorageAdapter
| Method | Signature | Required |
|---|---|---|
getItem | (key: string) => string | null | Promise<string | null> | Yes |
setItem | (key: string, value: string) => void | Promise<void> | Yes |
removeItem | (key: string) => void | Promise<void> | No |
localStorage and sessionStorage already have this shape, so you can pass them directly.
#history options
history is true (track every key, keep 100 steps) or this object:
| Field | Type | Default | What it does |
|---|---|---|---|
limit | number | 100 | How many undo steps to keep |
keys | Array<keyof State> | every key | Which keys are undoable. Computed keys can't be listed |
#keyActions entries
keyActions maps a state key to named actions. Each action is one of:
| Form | Shape | Notes |
|---|---|---|
| Function | (current, ...args) => next | Shorthand for { run } |
| Object | { run: (current, ...args) => next, save?: (next) => Promise | any } | See run and save |
Each one becomes setter.name(...args), and store.set.key.name(...args), returning Promise<void>.
#The actions api
actions is (api) => yourActions. The api has every key's setter and key actions, as api.cart.add(...) and api.step(value) (see Using key actions from an action), and:
| Member | Signature |
|---|---|
getState | (key) => value |
setState | (key, valueOrUpdater, save?) => Promise<void> |
batch | (fn) => result |
emit | (event, payload?, { broadcast? }) => void |
on | (event, handler) => unsubscribe |
fetchQuery | (name, params?, force?) => Promise<data> |
setQueryData | (name, updater, params?) => void |
invalidate | (name) => Promise<void> |
abort | (name) => void |
whenOnline | (task) => Promise<result> |
#emit options
| Option | Type | Default | What it does |
|---|---|---|---|
broadcast | boolean | false | Also deliver the event to other tabs. Needs syncTabs: true |
#Status
What the third item of useGlobalState(key) holds:
| Field | Type | Meaning |
|---|---|---|
state | 'waiting' | 'loading' | 'error' | 'ready' | Where it is, in one word; see Status |
loading | boolean | First load, no data yet |
fetching | boolean | Any request in progress |
error | unknown | The last error, or null |
blocked | boolean | true exactly when state is 'waiting' |
blockedBy | string | null | The query it's waiting for, through a whole waitFor chain |
rootError | unknown | That query's error, if it failed; otherwise null |
refetch | () => Promise<void> | Load again now; does nothing while blocked |
For plain keys it's always { state: 'ready', loading: false, fetching: false, error: null, blocked: false, blockedBy: null, rootError: null }. For a computed key it combines the statuses of everything the value reads.
#ChangeEvent
What subscribe listeners and onChange receive:
| Field | Type | Meaning |
|---|---|---|
key | string | The key that changed |
prev | unknown | Its value before |
next | unknown | Its value after |
source | 'local' | 'server' | 'remote' | 'history' | 'devtools' | 'storage' | 'hydrate' | Where the change came from |
time | number | When it happened (Date.now()) |
#Helpers
| Export | What it's for |
|---|---|
query(fetch, options?) | Data from your server: without params, with params, or paged; see Queries |
defineEvents<Events>() | Declaring event payload types, see Events |
serializeSnapshot(snapshot) | A snapshot as JSON that's safe inside a <script> tag, see On the server |
defaultRetryDelay(attempt) | The default wait between retries: 500 ms × 2^(attempt − 1) |
#Types
Every type the package exports. Most of the time you won't need to write any of them, because they're inferred.
| Type | Describes |
|---|---|
Store | A store's type, though ReturnType<typeof makeStore> is usually simpler |
StoreOptions | The second argument to createStore |
StoreApi | The api your actions receive |
Values | The types reads return, per key: a query with select has its selected type |
RawValues | The types writes accept, per key: always the raw data |
StateReader | What selectors and computed functions receive: callable as get('key') and readable as state.key |
ComputedLayer | One layer of the computed list |
SetValue | A setter: (valueOrUpdater, save?) => Promise<void> |
KeyAction, KeyActionsMap | One key action, and the whole keyActions object |
QueryDef, PagedQueryDef | What query() returns, without and with pages |
QueryOptions | The options of query() |
PagedQueryOptions, PagesOptions | The options of a paged query, and its pages object |
UseOptions | The options of useGlobalState |
PagedStatus | A paged query's status, with its page controls |
StatusState | 'waiting' | 'loading' | 'error' | 'ready', a status's state |
ApiSetters | The setters and key actions an action's api has |
RetryDelay | true | (attempt: number) => number |
Status | A key's status |
ChangeEvent | What subscribe and onChange receive |
EmitOptions | The options of emit |
EmitArgs | The arguments emit accepts for one event: its payload, then options |
AnyEvents | The event map used when you don't pass events: any name, any payload |
PersistOptions | The persist object |
StorageAdapter | A storage adapter |
Snapshot | What snapshot() returns and hydrate() takes |
NoKeys | An empty object type, used as a default for unused generics |
#Comparison
Here's how createStore compares to libraries you may already use. Zustand, Redux Toolkit and TanStack Query are mature, widely used, and each goes deeper than createStore in its own area. What createStore offers instead is one consistent, useState-shaped API across all of it.
| createStore | useState + Context | Zustand | Redux Toolkit | TanStack Query | |
|---|---|---|---|---|---|
| Shared state without a provider | Yes | No | Yes | No | n/a |
| useState-shaped API | Yes | Yes | No | No | n/a |
| Re-renders only readers of a changed key | Yes | No | With selectors | With selectors | n/a |
| Derived values | Built in | By hand | By hand | Selectors | n/a |
| Server data, caching, retries | Built in | By hand | By hand | RTK Query | Yes |
| Optimistic updates with rollback | Built in | By hand | By hand | By hand | By hand |
| Persistence with migrations | Built in | By hand | Middleware | Add-on | Add-on |
| Undo and redo | Built in | By hand | Add-on | Add-on | n/a |
| Tab sync and offline | Built in | By hand | Add-on | Add-on | Partly |
#FAQ
#Do I need a provider?
No. Components import the store and call useGlobalState. For server rendering you render the app once inside <AppStore.Root scope={...}>, which the store provides, so you still don't write a provider or a context; see SSR.
#Do I need reducers, action types or Context?
No. You change state with a setter, the same way you would with useState. When logic grows, key actions and actions are just plain functions. There's nothing to dispatch and nothing to combine, and even server rendering doesn't need a context of your own; see SSR.
#Is it related to Redux's createStore?
No. Redux has an older function that is also called createStore (Redux Toolkit recommends configureStore instead). This library is separate and unrelated: it is not a Redux store, and its npm package is @createstore/createstore. When you search for it, use the full package name.
#Can I use it without queries or persistence?
Absolutely. Every feature is off until you use it. A store with nothing but plain keys and useGlobalState is a perfectly complete setup.
#Can I have more than one store?
Yes, each createStore call is independent. Most apps are fine with one. Separate stores make sense for unrelated parts of an app, or for a library that ships its own state. Just give any store that persists or syncs tabs its own name.
#Does every component re-render when anything changes?
No. A component re-renders when a key it reads changes, or, if it uses a selector, when the selector's result changes. Re-rendering has the details.
#Where should async logic go?
In actions or event handlers. For loading data, use queries, which take care of loading state, caching, retries and cancellation for you. For saving, use a setter's server call or a key action with save.
#How is a query different from fetching in useEffect?
A query loads once for every component that shows it, caches the result, refreshes stale data when the window regains focus or the connection comes back, retries failures, cancels cleanly, and gives you loading and error status. With useEffect you'd write all of that yourself, in every component that needs it.
#Can I use it without React?
The store itself works anywhere, through getState, setState, subscribe, actions, events and fetchQuery. The hooks need React 19 or newer.
#Does it work with Next.js?
Yes. Add ssr: true. On the server, create a scope per request with createScope(...), render inside <AppStore.Root scope={scope}> and send scope.snapshot() with the page; in the browser, <AppStore.Root scope={AppStore.hydrate(snapshot)}>. See SSR.
#Is my data safe if someone goes offline?
With offline: true, cached server data stays available, and key action saves wait for the connection and then send the latest value. See Offline.
#What happens when a save fails?
The change rolls back, any other edits made in the meantime stay, the promise rejects with the server's error, and onSaveError runs for key actions. See Optimistic updates.
#How do I reset the store between tests?
afterEach(() => store.reset()). There's more in Testing.
#Why does my selector throw "returns the state itself"?
It returned the state reader instead of values. Return what you actually need, like ({ todos }) => todos rather than (state) => state.
#How do I change the saved shape without breaking anyone?
Bump persist.version and add a migrate function. See Migrations.