createStore

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.

$ npm i @createstore/createstore
Read the quick start API reference
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} />;
}
<TodoCount />reads todos

2 open of 3 todos

<AddTodo />writes 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:

    TSX
    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 needYou add
    Shared valuesNothing, just createStore and useGlobalState
    Values derived from otherscomputed
    Data from your serverquery(), with or without params, or paged
    Saving with rollback on failureA second argument to the setter, or keyActions
    State that survives a reloadpersist
    Undo and redohistory
    Tabs that stay in syncsyncTabs
    Working offlineoffline

    #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

    Terminal
    npm i @createstore/createstore

    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.

    RequirementVersion
    React18 or newer (tested on 19.3)
    TypeScript, if you use it5.0 or newer
    BrowsersAnything React supports
    Server renderingSupported, 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.

    TypeScriptType-checked
    // 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.

    TSXType-checked
    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:

    LevelWhat it coversYou need it when
    1 · Every appcreateStore, useGlobalState, setters, computed values, key actions and actionsAlways. Most apps never need more
    2 · Server dataquery(): loading, params, pages, optimistic savesYour app fetches from a server
    3 · When you need themPersistence, tab sync, offline, undo, events, batchingOne feature at a time, as you need it
    4 · Server rendering and scaleSSR, hot reloading, lifecycle, TypeScript details, testingYou 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 writeYou get backHandy when
    useGlobalState('todos')[value, setter, status]You read and write one key
    useGlobalState('todos', todos => todos.length)Just the selected valueYou only care about part of a key
    useGlobalState(get => get('a') + get('b'))Whatever you returnYou'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.

    TypeScript
    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:

    TypeScriptType-checked
    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:

    TypeScriptType-checked
    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 upWith name: 'shop'
    Persisted statethe localStorage key shop:state
    Offline copy of server datashop:inline and shop:queries
    Tab synca BroadcastChannel called createStore:shop
    Redux DevToolsthe instance is listed as shop
    Console warnings and the loggermessages start with [shop]
    Shared storesshared: 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

    TSXType-checked
    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:

    TSXType-checked
    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:

    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>;
    }

    #Updating state

    #The setter

    It works exactly like the one from useState. Pass a new value, or a function that gets the old one:

    TSXType-checked
    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 areWhat to write
    In a componentconst [, setTodos] = useGlobalState('todos')
    Anywherestore.setState('todos', next)
    Anywhere, as a function you can pass aroundstore.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.

    TSXType-checked
    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.

    TSXType-checked
    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:

    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.

    #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
    Signaturerun(current, ...args) => next
    currentThe key's value right now. For a query key with select, it's the raw data, not the selected view
    ...argsWhatever you passed to the action, so setTodos.add('Buy milk') calls run(todos, 'Buy milk')
    ReturnsThe key's new value, which is applied immediately
    RequiredYes, unless you use the plain-function shorthand, which is run
    When it runsSynchronously, 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
    Signaturesave(next) => Promise | anything
    nextThe key's value at the moment save runs: the raw stored value, even for a query key with select
    ReturnsA promise, or nothing. What it resolves to is ignored
    Fails byThrowing, or returning a promise that rejects
    RequiredNo. Without it, the action just changes the value locally
    When it runsRight 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.

    TypeScriptType-checked
    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.

    OptionCalled withWhen
    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 namesAfter save has succeeded
    onSaveError(key, action, error)The two names, plus what save threw or rejected withAfter 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:

    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.

    #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:

    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:

    TypeScript
    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 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;
    }

    #What happens when you call one, in order

    Calling setTodos.add('Buy milk') on a key action with a save:

    1. run(todos, 'Buy milk') runs and its result is applied. The new todo is on screen now.
    2. onSaveStart('todos', 'add') is called.
    3. The call returns a promise.
    4. A moment later save(next) runs, with the key's current value.
    5. If save succeeds, onSaveSuccess('todos', 'add') is called and the promise resolves.
    6. 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:

    TSXType-checked
    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

    MemberWhat it's for
    getState(key), setState(key, value, save?)Reading and writing keys
    batch(fn)Grouping updates into one undo step
    emit, onEvents
    fetchQuery, setQueryData, invalidate, abortThe 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 actionAction
    Belongs toOne keyThe whole store
    ReceivesThat key's current value, plus your argumentsAn api with getState, setState, batch, emit and the query cache
    ReturnsThe key's next valueAnything you like, including a promise
    Can read other keysNoYes
    Async work and server callsOnly through saveYes, freely
    Optimistic save with rollbackBuilt in, with { run, save }Only if you use a setter with a server call inside it
    Offline: waits for the connectionYes, when offline: true and it has a saveUse whenOnline yourself
    UndoOne step per callOne step per setState, unless you wrap them in batch
    Called assetCart.add(id) in a component, or store.cart.add(id) anywherestore.actions.checkout()
    TestingPlain function in, value outNeeds 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.

    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>
        </>
      );
    }

    #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:

    TypeScriptType-checked
    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.

    MemberWhat 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.actionsYour actions
    TypeScriptType-checked
    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 wantDeclare it asRead it with
    One thing, like the todo listtodos: query(fetchTodos)useGlobalState('todos')
    One thing per id or filterproduct: query(fetchProduct)useGlobalState('product', id)
    A list that loads in pagesfeed: 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>;
    }

    #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:

    ArgumentWhat it is
    paramsWhatever you pass as useGlobalState(key, params) or fetchQuery(key, params), or undefined when you pass nothing
    signalAn 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:

    Flow
    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.

    #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:

    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} />;
    }
    stateWhen
    '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:

    FieldMeaning
    state'waiting', 'loading', 'error' or 'ready', as above
    loadingFirst load, nothing to show yet
    fetchingAny request in progress, background refreshes included
    errorThe last error, or null
    blockedWaiting rather than loading: see Dependent queries
    blockedBy, rootErrorWhat 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).

    OptionDefaultWhat it doesWorks with
    staleTime30 sHow long data counts as freshEvery query
    retry2Retries after a failureEvery query
    retryDelay500 ms, 1 s, 2 s…Wait before each retry, or your own attempt => msEvery query
    gcTime5 minHow long data nobody shows stays cachedQueries read with params, and paged ones
    selectnoneExpose a transformed value; see belowQueries without pages
    initial[]The value before the first response, without paramsQueries without pages
    refetchAfterMutationfalseReload after a successful saveQueries without pages
    pagesnone{ first, next, max }; see Paged queriesPaged queries
    TypeScriptType-checked
    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:

    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.

    #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.

    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.

    #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.

    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>;
    }

    #What you get back

    The same three things as any key:

    ItemMeaning
    [0] the valueThe response for these params (selected, if you set select), or undefined until it has loaded
    [1] the setterWrites 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 statusloading, 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:

    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.

    #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:

    TSXType-checked
    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:

    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:

    StatusMeans
    blocked: trueWaiting: params not known yet, enabled: false, or a waitFor query isn't ready. No request is made
    loading: trueIts own request is running
    errorIts 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:

    TypeScript
    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 failed

    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.

    #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.

    MemberWhat 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
    }

    #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.

    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.

    #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.

    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>
          )}
        </>
      );
    }

    #Different kinds of pagination

    Your API usespages.firstpages.next
    Page numbers1(last, pages, page) => (last.length ? page + 1 : undefined)
    A cursornull(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:

    TypeScriptType-checked
    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:

    ItemMeaning
    [0] the pagesThe loaded pages, in order, fully typed
    [1]undefined
    status.hasNextPageBecomes 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.isFetchingNextPageA next page is on its way
    status.loading, fetching, errorAs 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.

    TSXType-checked
    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:

    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

    #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 happenedWhat 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 firstThe first one's change stays
    A save fails after the key was replaced with a new valueThe 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:

    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.

    #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:

    TypeScript
    query(fetchJobs, { retry: 3, retryDelay: (attempt) => 1000 * attempt }); // 1 s, 2 s, 3 s

    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.

    #Persistence

    persist keeps state across reloads. With persist: true, plain keys are saved to localStorage and read back before the first render:

    TypeScriptType-checked
    import { createStore } from '@createstore/createstore';
    const { useGlobalState } = createStore({ theme: 'light', sidebarOpen: true }, { name: 'app', persist: true });
    SettingEffect
    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
    namePrefix 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:

    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.

    #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.

    TypeScriptType-checked
    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:

    TypeScriptType-checked
    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:

    TypeScriptType-checked
    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:

    TypeScriptType-checked
    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:

    TypeScriptType-checked
    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:

    TypeScript
    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:

    TSXType-checked
    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>
        </>
      );
    }
    MemberWhat it does
    history: trueTracks 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:

    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.

    #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 }:

    TypeScript
    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:

    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

    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

    #The store

    Create the store as usual, at the top of a module, and add ssr: true:

    TypeScriptType-checked
    // 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:

    TSX
    // 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:

    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

    #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:

    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.

    #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:

    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.

    #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:

    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.

    #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:

    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.

    #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:

    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:

    ValueWhat JSON would do
    DateTurn it into a string
    Map, SetTurn it into {}
    A class instanceKeep only its fields, as a plain object
    BigIntFail
    A function or symbolDrop it
    NaN, InfinityTurn it into null
    A value that refers back to itselfFail
    undefined in an arrayTurn it into null
    A state key that's undefinedDrop 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. 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.

    #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:

    StepCall
    A store for this requestAppStore.createScope(data)
    The state to sendscope.snapshot()
    Picking it up in the browserAppStore.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.

    TSXType-checked
    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.

    #Shared stores and hot reloading

    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:

    TypeScriptType-checked
    import { createStore } from '@createstore/createstore';
    
    export const { useGlobalState } = createStore({ draft: '' }, { name: 'editor', shared: true });

    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.

    #Lifecycle

    Three members deal with a store's life rather than its data:

    MemberWhen you'd use it
    await store.hydratedTo 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

    #TypeScript

    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.

    #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.

    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.

    #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

    Flow
    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.

    #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:

    1. The value is stored, but only if it actually changed. Writing the same value again does nothing.
    2. If it's a local change to a tracked key, history records an undo step.
    3. 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.
    4. Persistence saves the new state, and tab sync sends it to the other tabs.
    5. Computed values that depend on the key, directly or through other computed values, are marked stale. Nothing is recalculated yet.
    6. 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.
    7. Last of all, change listeners run: onChange, subscribe and the logger.

    React then re-renders only the components whose values changed.

    #Debugging

    OptionWhat you get
    devtools: trueA connection to the Redux DevTools extension. Every change is listed, and you can jump between states
    logger: trueEvery 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:

    LabelComes from
    set todosA setter, setState or a key action
    loaded todosA query response
    rollback todosA failed save being undone
    history 3undo, redo or goTo
    from other tabTab sync
    loaded from storageAsync persisted state arriving
    time travelA jump made from inside Redux DevTools
    resetstore.reset()
    initial statecreateScope(data) or hydrate(snapshot) filling a store
    event toastAn 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:

    FieldMeaning
    keyThe key that changed
    prev, nextThe value before and after
    source'local', 'server', 'remote' (another tab), 'history', 'devtools', 'storage' or 'hydrate' (data from createScope or hydrate)
    timeWhen 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 mentionsWhat happenedWhat to do
    is not in the storeA key name that doesn't existAdd it to the initial state, or fix the typo
    is computed and read-onlyYou wrote to a computed keyWrite the keys it reads instead
    was called while computed … was calculatingsetState inside a computed functionOnly calculate there; update in an event handler or action
    was called inside a selectorsetState inside a selectorSelectors should only read
    returns the state itselfA selector returned the state readerReturn the values you need
    depends on itself through a loopComputed values reading each other in a circleBreak the loop
    update loopA listener keeps updating a key it reacts toAdd a guard to the listener
    can't be used as a cache keyA function, symbol or promise in query paramsUse plain values
    batch() returned a promiseAsync work inside batchBatch the updates after await separately
    can't run inside batch()undo, redo or goTo inside batchCall them afterwards
    dropped saved state from versionThe version changed without migrateAdd a migrate function
    needs ssr: true<Root> or hydrate() on a store without ssr: trueAdd ssr: true to the options
    Cannot call … during server renderingWith ssr: true, a store call like AppStore.cart.add() ran on the serverUse 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 browserhydrate() called on the serverUse 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 largeParams with more than 10,000 values or 1 million charactersPass an id or a small filter object
    more than offline.maxSizeThe 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 bothBoth lists givenUse one of them
    snapshot() can't send …A value JSON wouldn't bring back unchanged, like a Date or MapStore 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 queryA typo, or a computed key, in ssr.keys or ssr.excludeList state keys and query names only
    initialState has … which is not in the storeA typo in createScope(...)Fix the key name
    … can't be a state key: server-rendering snapshots use that nameA state key named $version, $queries or $agesRename the key
    … after destroy(): ignoredThe store was used after destroy() (a warning)Stop using it, or create a new one
    <Root> got a scope made by a different storeA 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 excludeList state keys only
    persist / ssr / syncTabs takes keys or exclude, not bothBoth lists givenUse one of them
    ssr.serialize is now ssr.keysThe old option nameRename it to keys (or use exclude)
    toScript() needs the store's nametoScript() on a store without namecreateStore(state, { name: 'shop', ssr: true })
    hydrate() found no data taghydrate() 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 noneParams passed (even undefined) to a query declared without params (a hint)Leave the argument out: useGlobalState('feed')
    is nested more than 1000 levels deepState too deep to send in a snapshotFlatten 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 methodA state key named like a store method, e.g. resetUse 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:

    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]);

    #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.

    TypeScript
    // ---- 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?)

    OptionTypeDefaultSee
    namestring'store'The store name
    computedLayer[] (up to 8)noneComputed values
    actions(api) => actionsnoneActions, the api
    keyActions{ key: { name: fn | { run, save } } }noneKey actions, entries
    eventsdefineEvents<Events>()untypedEvents
    persisttrue | 'local' | 'session' | PersistOptionsoffPersistence, fields
    historytrue | { limit?, keys? }offUndo and redo, fields
    syncTabsboolean | { keys? } | { exclude? }falseSyncing tabs, which keys
    offlineboolean | { maxSize? }falseOffline, how much is kept
    validate(key, value, from) => booleannoneChecking data from outside
    sharedbooleanfalseShared stores
    ssrboolean | { keys?, exclude?, version? }falseServer rendering: one store per request, guaranteed. keys or exclude choose the state keys and queries snapshot() includes
    devtools, loggerbooleanfalseDebugging
    onChange(change) => voidnoneDebugging
    onSaveStart, onSaveSuccess, onSaveError(key, action, error?) => voidnoneKey actions

    #The store

    MemberReturns
    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
    RootComponent: <Root scope={scope}>. Needs ssr: true; see SSR
    actionsYour 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
    hydratedA 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:

    OptionTypeDefaultWhat it does
    staleTimenumber (ms)30000How long a response counts as fresh
    retrynumber2How many times a failed request is retried
    retryDelaytrue | (attempt) => number500 ms, 1 s, 2 s…Wait before each retry; attempt starts at 1
    gcTimenumber (ms)300000 (5 min)How long data for params nobody shows stays cached
    select(data: T) => RnoneWhat components see; the store keeps the raw data. See select
    initialT[]The value without params, before the first response
    refetchAfterMutationbooleanfalseReload after a successful save

    #query(fetchPage, { pages })

    A paged query. fetchPage is (page, params, signal) => Promise<Page>. Options:

    OptionTypeDefaultWhat it does
    pages.firstTParamrequiredThe first request's page: 1, null, a cursor…
    pages.next(lastPage, pages, page) => TParam | undefined | nullrequiredThe next page; undefined or null means there are no more
    pages.maxnumberunlimitedKeep only the newest N pages
    staleTime, retry, retryDelay, gcTimeas aboveAs 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):

    OptionTypeDefaultWhat it does
    enabledbooleantrueWhen false, the query is blocked: it doesn't load or count as on screen. See Waiting for params
    waitForStatus | Status[]noneWait 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:

    FieldTypeDefaultWhat it does
    storage'local' | 'session' | StorageAdapter'local'Where state is saved. See Storage adapters
    keysArray<keyof State>every plain keyWhich keys are saved
    excludeArray<keyof State>noneEvery plain key except these is saved (instead of keys)
    versionnumber0The shape version of what's saved
    migrate(persisted, version) => newStatenoneTurns data saved under an older version into the current shape. See Migrations
    debouncenumber (ms)0Wait until changes stop before saving; flushed when the page is hidden. See Saving less often

    #StorageAdapter

    MethodSignatureRequired
    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:

    FieldTypeDefaultWhat it does
    limitnumber100How many undo steps to keep
    keysArray<keyof State>every keyWhich keys are undoable. Computed keys can't be listed

    #keyActions entries

    keyActions maps a state key to named actions. Each action is one of:

    FormShapeNotes
    Function(current, ...args) => nextShorthand 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:

    MemberSignature
    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

    OptionTypeDefaultWhat it does
    broadcastbooleanfalseAlso deliver the event to other tabs. Needs syncTabs: true

    #Status

    What the third item of useGlobalState(key) holds:

    FieldTypeMeaning
    state'waiting' | 'loading' | 'error' | 'ready'Where it is, in one word; see Status
    loadingbooleanFirst load, no data yet
    fetchingbooleanAny request in progress
    errorunknownThe last error, or null
    blockedbooleantrue exactly when state is 'waiting'
    blockedBystring | nullThe query it's waiting for, through a whole waitFor chain
    rootErrorunknownThat 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:

    FieldTypeMeaning
    keystringThe key that changed
    prevunknownIts value before
    nextunknownIts value after
    source'local' | 'server' | 'remote' | 'history' | 'devtools' | 'storage' | 'hydrate'Where the change came from
    timenumberWhen it happened (Date.now())

    #Helpers

    ExportWhat 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.

    TypeDescribes
    StoreA store's type, though ReturnType<typeof makeStore> is usually simpler
    StoreOptionsThe second argument to createStore
    StoreApiThe api your actions receive
    ValuesThe types reads return, per key: a query with select has its selected type
    RawValuesThe types writes accept, per key: always the raw data
    StateReaderWhat selectors and computed functions receive: callable as get('key') and readable as state.key
    ComputedLayerOne layer of the computed list
    SetValueA setter: (valueOrUpdater, save?) => Promise<void>
    KeyAction, KeyActionsMapOne key action, and the whole keyActions object
    QueryDef, PagedQueryDefWhat query() returns, without and with pages
    QueryOptionsThe options of query()
    PagedQueryOptions, PagesOptionsThe options of a paged query, and its pages object
    UseOptionsThe options of useGlobalState
    PagedStatusA paged query's status, with its page controls
    StatusState'waiting' | 'loading' | 'error' | 'ready', a status's state
    ApiSettersThe setters and key actions an action's api has
    RetryDelaytrue | (attempt: number) => number
    StatusA key's status
    ChangeEventWhat subscribe and onChange receive
    EmitOptionsThe options of emit
    EmitArgsThe arguments emit accepts for one event: its payload, then options
    AnyEventsThe event map used when you don't pass events: any name, any payload
    PersistOptionsThe persist object
    StorageAdapterA storage adapter
    SnapshotWhat snapshot() returns and hydrate() takes
    NoKeysAn 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.

    createStoreuseState + ContextZustandRedux ToolkitTanStack Query
    Shared state without a providerYesNoYesNon/a
    useState-shaped APIYesYesNoNon/a
    Re-renders only readers of a changed keyYesNoWith selectorsWith selectorsn/a
    Derived valuesBuilt inBy handBy handSelectorsn/a
    Server data, caching, retriesBuilt inBy handBy handRTK QueryYes
    Optimistic updates with rollbackBuilt inBy handBy handBy handBy hand
    Persistence with migrationsBuilt inBy handMiddlewareAdd-onAdd-on
    Undo and redoBuilt inBy handAdd-onAdd-onn/a
    Tab sync and offlineBuilt inBy handAdd-onAdd-onPartly

    #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.

    #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 18 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.

    ↑ ↓ to moveEnter to openEsc to close