Use a store factory and a client-side provider when a React server-rendered application needs state that is isolated per request and identical during server rendering and client hydration.
When to use this pattern
Use this pattern when a server can handle concurrent requests or when the initial state comes from request data. A module-level store is shared by requests, so one request can observe another request's state. Create the store inside the provider instead, and pass the same serializable initial state to the server-rendered tree and the browser.
React Server Components must not read from or write to the store. Keep store access in the client component that consumes the provider; a server component can provide initial data as props.
1. Create a store factory
Build the store with createStore, not as a module-level singleton. The factory accepts the state for one request and creates actions that update that store.
tsimport { createStore } from 'zustand/vanilla'
export type CounterState = {
count: number
}
export type CounterActions = {
decrementCount: () => void
incrementCount: () => void
}
export type CounterStore = CounterState & CounterActions
export const defaultInitState: CounterState = {
count: 0,
}
export const createCounterStore = (
initState: CounterState = defaultInitState,
) =>
createStore<CounterStore>()((set) => ({
...initState,
decrementCount: () => set((state) => ({ count: state.count - 1 })),
incrementCount: () => set((state) => ({ count: state.count + 1 })),
}))
Each call to createCounterStore() returns a separate vanilla store, so each provider can keep state scoped to its own request or route. See How Zustand works for the returned store API; the component below uses its React binding instead of reading the store directly.
2. Create the provider once per render tree
Create the store in a lazy useState initializer. The initializer runs once for the provider instance, so a client re-render does not replace the store. useStore subscribes a component to the store and selects the requested value. The StoreApi type describes the store API held by the context.
tsx'use client'
import { createContext, useContext, useState, type ReactNode } from 'react'
import { useStore, type StoreApi } from 'zustand'
import { createStore } from 'zustand/vanilla'
type CounterState = {
count: number
}
type CounterStore = CounterState & {
decrementCount: () => void
incrementCount: () => void
}
type CounterStoreApi = StoreApi<CounterStore>
const CounterStoreContext = createContext<CounterStoreApi | undefined>(
undefined,
)
export type CounterStoreProviderProps = {
children: ReactNode
initialState: CounterState
}
export function CounterStoreProvider({
children,
initialState,
}: CounterStoreProviderProps) {
const [store] = useState(() =>
createStore<CounterStore>()((set) => ({
...initialState,
decrementCount: () =>
set((state: CounterStore) => ({ count: state.count - 1 })),
incrementCount: () =>
set((state: CounterStore) => ({ count: state.count + 1 })),
})),
)
const content = children ?? <p>Count: {store.getState().count}</p>
return (
<CounterStoreContext.Provider value={store}>
{content}
</CounterStoreContext.Provider>
)
}
export function useCounterStore<T>(
selector: (state: CounterStore) => T,
): T {
const store = useContext(CounterStoreContext)
if (store === undefined) {
throw new Error(
'useCounterStore must be used within CounterStoreProvider',
)
}
return useStore(store, selector)
}
The provider owns one store for its mounted subtree. Rendering a second provider creates a second store, which is useful when separate parts of an application need separate request or route scopes.
3. Read and update state in a client component
Mark the component that calls the custom hook as a client component. It renders the request's initial count on both server and client, then updates that count when the user clicks a button.
tsx'use client'
import { useCounterStore } from '@/providers/counter-store-provider'
type CounterStore = {
count: number
incrementCount: () => void
decrementCount: () => void
}
export function HomePage() {
const count = useCounterStore((state: CounterStore) => state.count)
const incrementCount = useCounterStore(
(state: CounterStore) => state.incrementCount,
)
const decrementCount = useCounterStore(
(state: CounterStore) => state.decrementCount,
)
return (
<main>
<p>Count: {count}</p>
<button type="button" onClick={incrementCount}>
Increment Count
</button>
<button type="button" onClick={decrementCount}>
Decrement Count
</button>
</main>
)
}
The page initially shows Count: 3 in this example. Clicking Increment Count changes it to 4; clicking Decrement Count changes it to 2 when starting from 3. Each selector subscribes only to the selected value.
4. Pass matching initial state from the server
In the Next.js App Router, place the provider in the server-rendered layout and pass serializable request data to it. The page below represents request-derived data with a fixed value; replace that value with data loaded for the current request, without reading or writing the Zustand store in the server component.
tsximport type { ReactNode } from 'react'
import { CounterStoreProvider } from '@/providers/counter-store-provider'
export default function RootLayout({ children }: { children: ReactNode }) {
const initialState = { count: 3 }
return (
<html lang="en">
<body>
<CounterStoreProvider initialState={initialState}>
{children}
</CounterStoreProvider>
</body>
</html>
)
}
tsximport { HomePage } from '@/components/home-page'
export default function Page() {
return <HomePage />
}
The server output and the client's first render both use count: 3, so React hydrates matching markup. After hydration, the buttons update the client-side store. If the initial state differs between those renders, React can report a hydration error.
For the Pages Router, place CounterStoreProvider around Component in src/pages/_app.tsx. If only one route needs the store, place the provider in that route instead; that gives the route its own store scope.
Options that matter
This pattern has no Zustand configuration options. The values that determine its behaviour are the factory's initState, the provider's initialState, and each selector passed to useCounterStore.
Pitfalls
- Do not define the store as a module-level variable. A global store can leak state between concurrent server requests.
- Do not read or write the store from a React Server Component. Pass request data into a client provider instead.
- Keep the server's initial state and the client's initial state identical. Rendering different data produces hydration errors.
- Keep the provider's store creation inside the lazy
useStateinitializer. Creating it during every render replaces the instance when the provider re-renders.
For persisted stores, asynchronous storage hydrates after the initial render; see Persist store data and Control persisted hydration. For the distinction between initializer updates and the direct vanilla-store API, see How Zustand works and Create a vanilla store.
Was this page helpful?