Why Zustand Needs Context in Next.js
A question that came up while reading the docs โ and the answer has nothing to do with React.
The first time I read the Zustand documentation for Next.js, something made me stop and think. It said the store should not be defined as a global variable and must be wrapped in a Context Provider. My immediate reaction: why? Isn't Zustand literally designed to be global state management?
I was already fairly comfortable with Zustand in a regular React app. Create a store, export the hook, use it anywhere. Simple. But as soon as I moved into Next.js territory โ especially App Router โ there was suddenly an extra layer that felt confusing. I decided to dig in until I actually understood why.
The root of the problem isn't React
After tracing it back, the question turned out to have nothing to do with React or Zustand specifically. It's about how Node.js โ the runtime powering the Next.js server โ manages memory.
In the browser, each tab has its own JavaScript environment. Open two tabs and they share nothing. But on a Node.js server, the situation is completely different: a single process serves all requests from all users.
Browser: Tab A โ JS env A (isolated)
Tab B โ JS env B (isolated)
Node.js: Request A โ same process
Request B โ same process โ shared!
What makes this even more critical: Node.js performs module caching. Every file that gets imported is executed exactly once when the server first starts. After that, every request shares the same instance of that module.
How data leaks happen
Let me illustrate with a concrete scenario. Imagine we have a Zustand store defined at the module level:
// store.ts โ DANGEROUS in SSR
export const useStore = create(() => ({
user: null,
cart: [],
}))This file is only executed once. The store instance lives for as long as the server lives. Now imagine this happens:
t=0ms โ User A logs in โ set({ user: "Alice", cart: ["item1"] })
โ singleton store updated
t=5ms โ User B makes a request โ getState() โ { user: "Alice" }
โ User B gets User A's data!
This isn't theoretical. It's how Node.js works. And what makes it scary is that bugs like this are very hard to debug โ they're not consistent, they depend entirely on request timing.
Why Context is the solution
Once I understood the problem, the solution made sense on its own. What we need is a way to create a fresh store instance for each request โ not reuse a single instance shared by everyone.
That's where Context comes in. But not for the usual reason we reach for Context in React (avoiding prop drilling). Here, Context is used for one specific reason: its lifecycle is tied to the component render, not to the module.
Request A comes in โ StoreProvider renders โ createStore() โ instance A
HTML sent โ instance A discarded
Request B comes in โ StoreProvider renders โ createStore() โ instance B
HTML sent โ instance B discarded
Every request gets its own locker. When the request is done, the locker is returned โ and its contents are never visible to anyone else.
Implementation per the official docs
One thing I also want to clarify: the official Zustand docs use useState inside the Provider, not useRef like I've seen in some articles.
// src/providers/counter-store-provider.tsx
"use client"
import { type ReactNode, createContext, useState, useContext } from "react"
import { useStore } from "zustand"
import { type CounterStore, createCounterStore } from "@/stores/counter-store"
export const CounterStoreContext = createContext(null)
export const CounterStoreProvider = ({ children }: { children: ReactNode }) => {
// useState with initializer function โ store is created once on mount
const [store] = useState(createCounterStore)
return (
<CounterStoreContext.Provider value={store}>
{children}
</CounterStoreContext.Provider>
)
}Why the docs prefer useState over useRef: both create the store only once, but useState with an initializer function is more idiomatic in React and lazy by default โ createCounterStore is only called on the first render, not on every re-render.
The store itself is separated as a factory function, not a direct hook:
// src/stores/counter-store.ts
import { createStore } from "zustand"
export type CounterStore = {
count: number
incrementCount: () => void
decrementCount: () => void
}
export const createCounterStore = () =>
createStore<CounterStore>()((set) => ({
count: 0,
incrementCount: () => set((state) => ({ count: state.count + 1 })),
decrementCount: () => set((state) => ({ count: state.count - 1 })),
}))When is this actually necessary?
After all that, there's one practical question: when do you actually need to bother with this?
| Condition | Need Context? |
|---|---|
| Pure UI state in client components | No |
| State initialized from server (cookies, session) | Yes |
| State that differs per user/request | Yes |
| Store holds sensitive data (auth, cart) | Yes |
| Global state identical for all users (config, i18n) | Optional |
Note from the Zustand community: If you're only using Zustand in client components and the state doesn't depend on server data, a global store is still fine. The danger is when state touches per-user data and is accessed on the server side.
What I learned
Going through this taught me that many "rules" in Next.js App Router aren't really about React at all โ they're about understanding that Next.js is a server application running on Node.js, with all the architectural consequences that come with it.
Context here isn't a tool for sharing state like it usually is. It's a mechanism for isolating state โ making sure each request has its own space, and no data leaks to another user.
Once that perspective shifted, the Zustand documentation that felt strange before made complete sense. And more than that: I became a lot more careful every time I write a module-level variable inside a Next.js application.
