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.
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:
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:
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.
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.
You'll need React 18 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.
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.
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.
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.
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 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.
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:
TSXType-checked
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>;
}
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.
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.
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.
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.
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.
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.
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.
Every state key is also available on the store itself, as its setter with its key actions attached:
TypeScriptType-checked
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 too
AppStore.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.
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 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 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.
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.
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
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:
TypeScriptType-checked
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.
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:
TypeScript
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:
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.
Every call that starts with onSaveStart ends with exactly one of onSaveSuccess or onSaveError, 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 run throws, 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:
TSXType-checked
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;
}
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:
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.
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.
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.
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.
TSXType-checked
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>
</>
);
}
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.
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:
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, …).
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:
TSXType-checked
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>;
}
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).
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.
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 refreshing
A 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.
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:
TSX
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
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.
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
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:
Console
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.
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.
TypeScriptType-checked
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 }]); // raw
select only runs again when the raw data changes, so components keep getting the same object until then.
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.
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.
TSXType-checked
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>;
}
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
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.
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:
TypeScript
const [product] = useGlobalState('product', productId); // waits while productId is undefined
const [projects] = useGlobalState('projects', user?.id); // waits until the user has loaded
For 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:
TypeScript
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.
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.
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:
TSXType-checked
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:
waitFor 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.
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
TypeScriptType-checked
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
}
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.
TypeScriptType-checked
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.
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.
TSXType-checked
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>
)}
</>
);
}
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.
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.
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.
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:
Flow
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
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.
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:
TypeScriptType-checked
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 now
A 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.
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:
Leaving 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.
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.
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:
TypeScriptType-checked
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.
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.
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 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:
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.
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.
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.
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.
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.
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:
TypeScript
store.whenOnline(() => api.save(store.getState('notes'))); // sends what's current then
store.whenOnline(() => api.save(notes)); // may send stale data
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.
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:
TSXType-checked
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.
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 }:
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:
TypeScriptType-checked
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 step
Updates 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 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:
Flow
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:
Flow
on the server → scope.cart.add(product) the request's store
in the browser → AppStore.cart.add(product) the browser's one store
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:
TSX
// 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:
TypeScript
request.signal.addEventListener('abort', () => scope.destroy()); // the client went away: stop fetching
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:
TSX
// 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.
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:
Console
[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.
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:
TSX
const scope = AppStore.createScope({ user });
await scope.fetchQuery('product', productId); // a query with params
await scope.fetchQuery('feed'); // a paged query: its first page
The 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.
snapshot() includes every state key by default. Computed values are never included, because the browser recalculates them. To send only some keys, list them:
TypeScriptType-checked
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.
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.
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:
Console
[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.
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.
One component.Root is 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 Root at the top of the app, on the server and in the browser. A Root inside 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 (from hydrate()) to a Root inside 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.Root and hydrate throw an error asking for ssr: true, because only then do hooks look for a Root. createScope() and snapshot() work either way.
shared: true is ignored on the server, as before. In the browser it still reuses the store across hot reloads, and hydrate doesn't refill it.
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.
When your bundler hot-reloads a module during development, any createStore call at the top of that module runs again, and you get a brand new store with all your state gone. shared: true makes it reuse the existing store with the same name instead:
It's mostly a development convenience, for hot reloading and for tools like Storybook that re-run modules. Calling store.destroy() releases the name, so the next createStore gets a fresh store.
On the server, shared is ignored. Every call gets its own store and you'll see a warning, because a single store shared by every request would leak one user's data into another's page. For server rendering, use ssr: true instead.
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
TypeScriptType-checked
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
Types come from your initial state, so apart from the initial values themselves you'll rarely write an annotation.
TypeScriptType-checked
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 string
In 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.
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.
TypeScript
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.
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 reads
Query 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.
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, subscribe and the logger.
React then re-renders only the components whose values changed.
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.
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.
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 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.
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.
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.
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.
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.
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.
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.
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.
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.
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:
TypeScript
// 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]);
[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
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
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:
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.
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.
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.
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.
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.
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.
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.
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.