Skip to content
D
Documentation

Vanilla and scoped stores

concept
2 min readUpdated

For application-wide state, use the hook form returned by create. Use createStore when creation must be separate from React, or when each scope needs its own store instance.

Install the zustand package with npm install zustand. Its zustand/vanilla entry provides the standalone store creator, while its React entry provides the binding hook.

How the parts fit together

mermaid
flowchart LR
  F["store factory"] --> V["vanilla store API"]
  V --> H["React store binding"]
  H --> C["React component"]
  P["React context"] --> H
  R["request boundary"] --> F

createStore returns a standalone store API. Its getState, setState, subscribe, and getInitialState methods let non-React code use the store. Pass that store and a selector to useStore in a component; the component then subscribes to the selected value.

Use a module-level store when the state is genuinely global. Use a factory when initial values come from component props, when two parts of the tree must not share state, or when a server must create one store per request. Context carries the selected store instance to the component subtree; it does not replace the store API.

For the direct bound-hook pattern, including atomic selections and the rendered result, see Selectors and rendering.

Scope a store through context

Use a factory and context when each provider instance needs independent state or receives initial props. Keep the store in useState so a provider re-render does not create a new store.

When this scoped binding needs a custom equality function, install the zustand/traditional peer dependency with npm install zustand react use-sync-external-store and use useStoreWithEqualityFn in the context hook.

tsx
import {
  createContext,
  useContext,
  useState,
  type PropsWithChildren,
  type ReactNode,
} from 'react'
import { createStore } from 'zustand/vanilla'
import { useStoreWithEqualityFn } from 'zustand/traditional'

type CounterProps = {
  count: number
}

type CounterState = CounterProps & {
  increment: () => void
}

const createCounterStore = (props: Partial<CounterProps> = {}) =>
  createStore<CounterState>()((set) => ({
    count: props.count ?? 0,
    increment: () => set((state) => ({ count: state.count + 1 })),
  }))

type CounterStore = ReturnType<typeof createCounterStore>

const CounterContext = createContext<CounterStore | null>(null)

type CounterProviderProps = PropsWithChildren<CounterProps>

export function CounterProvider({
  children,
  ...props
}: CounterProviderProps): ReactNode {
  const [store] = useState(() => createCounterStore(props))
  return (
    <CounterContext.Provider value={store}>
      {children}
    </CounterContext.Provider>
  )
}

function useCounterStore<T>(selector: (state: CounterState) => T): T {
  const store = useContext(CounterContext)
  if (!store) {
    throw new Error('CounterProvider is missing')
  }
  return useStoreWithEqualityFn(store, selector, (left, right) => left === right)
}

export function Counter(): ReactNode {
  const count = useCounterStore((state) => state.count)
  const increment = useCounterStore((state) => state.increment)

  return (
    <button type="button" onClick={increment}>
      Count: {count}
    </button>
  )
}

export function App(): ReactNode {
  return (
    <>
      <CounterProvider count={2}>
        <Counter />
      </CounterProvider>
      <CounterProvider count={10}>
        <Counter />
      </CounterProvider>
    </>
  )
}

The two buttons start at Count: 2 and Count: 10. Clicking one changes only its provider's store. The context wrapper also gives you a single place to reject consumers rendered outside the provider.

Two mounted counters show Count: 2 and Count: 10.

Create one store per request

A server can handle concurrent requests, so do not share request-specific state in a module-level store. Export a factory and call it at the request or provider boundary. On the client, initialize the provider's store once with useState; on the server and client, use matching initial data to avoid hydration differences.

ts
import { createStore } from 'zustand/vanilla'

type RequestState = {
  requestId: string
}

export const createRequestStore = (requestId: string) =>
  createStore<RequestState>()(() => ({ requestId }))

export function loadRequestState(requestId: string): string {
  const store = createRequestStore(requestId)
  return store.getState().requestId
}

Each call to createRequestStore returns a separate instance, so one request's state is not reused by another. React Server Components must not read from or write to the store; keep store access in client components and pass the request's initial data into the client-side provider.

Choosing the boundary

NeedStore shapeBinding
State shared by the applicationOne module-level storecreate and its returned hook
State used by React and non-React codeOne standalone storecreateStore, then useStore
Independent instances in different subtreesFactory-created storescreateStore, context, and useStore
Request-specific or SSR stateOne factory call per requestCreate the store at the request/provider boundary

The lower-level store API is useful here because scoping is about which store instance a component receives. Do not add context merely to share a genuinely global store; use the direct hook for that case.

See Create a vanilla store for store construction details and Handle server-rendered stores for the server-rendering constraints.

Was this page helpful?