RIZKYEMHA
โ† Back to Blog๐Ÿ‡ฎ๐Ÿ‡ฉ Indonesia
ZustandNext.jsReactState ManagementNode.js

Why Zustand Needs Context in Next.js

June 5, 2026ยท8 min read
Why Zustand Needs Context in Next.js

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?

ConditionNeed Context?
Pure UI state in client componentsNo
State initialized from server (cookies, session)Yes
State that differs per user/requestYes
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.

โ† All ArticlesShare on X โ†—