Zustand in Next.js
Zustand stores created outside components are singletons — fine on the client, but problematic on the server where requests share the same module instance. Use the factory pattern to create a new store per request.
Zustand for Global Client State
When global client-side state is required (for instance, to manage cart items, workspace states, or interactive multi-step checkout layouts), Zustand is the industry standard. It is small, uses clean Hooks, and lacks the boilerplates of Redux. However, initializing Zustand in Next.js requires care. If you export a store singleton directly, concurrent server-side requests will share that same store instance, leaking user data. In this lesson, we will construct a request-safe, React-bound Zustand store, initialize it with server data, and configure persistent browser storage safely.
1. The Server-Request Leak Challenge
In a traditional single-page application, you initialize and export your store directly:
// Bad in Next.js: Shared across all server requests
import { create } from 'zustand';
export const useStore = create((set) => ({
items: [],
addItem: (item) => set((state) => ({ items: [...state.items, item] })),
}));
When this code runs on Next.js servers, the module is instantiated once. If User A hits the server and adds an item, that item is written to memory. When User B hits the server a second later, they will receive User A's cached state payload!
To solve this, we must instantiate a new store instance for every request and pass it down using React Context.
2. The Context-Bound Store Pattern
To isolate our stores per request, we use a two-step structure:
- Store Creator Factory: A function that constructs a new store instance on demand.
- React Context Provider: A wrapper component that instantiates the store once and exposes it to children.
Let's build a request-safe course enrollment store.
3. Defining the Store Factory and Context
We define our store configuration using Zustand's vanilla createStore API (not the hook-based create API) so we can instantiate stores inside React components dynamically:
// store/course-store.ts
import { createStore } from 'zustand/vanilla';
export interface CourseState {
selectedModuleId: number | null;
completedLessons: number[];
selectModule: (id: number) => void;
toggleLesson: (id: number) => void;
}
export type CourseStore = ReturnType<typeof createCourseStore>;
export const createCourseStore = (initProps?: { completedLessons?: number[] }) => {
return createStore<CourseState>()((set) => ({
selectedModuleId: null,
completedLessons: initProps?.completedLessons || [],
selectModule: (id) => set({ selectedModuleId: id }),
toggleLesson: (id) =>
set((state) => ({
completedLessons: state.completedLessons.includes(id)
? state.completedLessons.filter((lId) => lId !== id)
: [...state.completedLessons, id],
})),
}));
};
4. Creating the React Store Provider
Now, create a Client Component Context Provider that instantiates the store. We use a React useRef hook to ensure the store constructor runs exactly once when the component mounts, rather than on every re-render:
// store/CourseStoreProvider.tsx
'use client';
import { createContext, useContext, useRef } from 'react';
import { useStore } from 'zustand';
import { createCourseStore, type CourseState } from './course-store';
type CourseStoreApi = ReturnType<typeof createCourseStore>;
const CourseStoreContext = createContext<CourseStoreApi | undefined>(undefined);
export interface CourseStoreProviderProps {
children: React.ReactNode;
initialCompleted?: number[];
}
export function CourseStoreProvider({
children,
initialCompleted,
}: CourseStoreProviderProps) {
const storeRef = useRef<CourseStoreApi>(undefined);
if (!storeRef.current) {
// Instantiate store factory exactly once per request mount
storeRef.current = createCourseStore({ completedLessons: initialCompleted });
}
return (
<CourseStoreContext.Provider value={storeRef.current}>
{children}
</CourseStoreContext.Provider>
);
}
// Custom hook to consume the context-bound store safely
export function useCourseStore<T>(selector: (store: CourseState) => T): T {
const context = useContext(CourseStoreContext);
if (!context) {
throw new Error('useCourseStore must be used within CourseStoreProvider');
}
// Connect the vanilla store to React's rendering lifecycle
return useStore(context, selector);
}
5. Initializing Stores with Server Data
Now you can query data in an async Server Component, pass it directly as initial properties to the CourseStoreProvider, and render your layouts:
// app/courses/[id]/page.tsx (Server Component)
import { CourseStoreProvider } from '@/store/CourseStoreProvider';
import { CourseView } from '@/components/CourseView';
export default async function CoursePage() {
// Query user completion profiles from database
const userProgress = await fetch('https://api.progrumar.com/progress', {
headers: { 'Authorization': 'Bearer ...' }
}).then(r => r.json());
return (
<CourseStoreProvider initialCompleted={userProgress.completedLessonIds}>
<CourseView />
</CourseStoreProvider>
);
}
6. Consuming Store States in Client Components
Inside any downstream Client Component (like CourseView), import the useCourseStore selector hook to select specific states or triggers:
// components/CourseView.tsx
'use client';
import { useCourseStore } from '@/store/CourseStoreProvider';
export function CourseView() {
const completedCount = useCourseStore((state) => state.completedLessons.length);
const toggleLesson = useCourseStore((state) => state.toggleLesson);
return (
<div className="p-4">
<p>Lessons Completed: {completedCount}</p>
<button onClick={() => toggleLesson(101)}>
Toggle Intro Lesson
</button>
</div>
);
}
7. Gotchas of Hydration Mismatches with Persisted States
If you use Zustand's persist middleware to synchronize store states to localStorage, your client values will vary from the initial server-side HTML pre-render:
- The server renders the initial default state (e.g.
items: []). - The browser loads and mounts the HTML, then runs Zustand's persist hydrates, loading client storage (e.g.
items: [2]). - This causes a hydration warning because the server layout mismatches the client layout.
Fix: Only render components that consume persisted states after the component mounts:
// components/CartStatus.tsx
'use client';
import { useState, useEffect } from 'react';
import { useCartStore } from '@/store/useCartStore';
export function CartStatus() {
const [mounted, setMounted] = useState(false);
const cartCount = useCartStore((state) => state.items.length);
useEffect(() => {
setMounted(true);
}, []);
if (!mounted) return <div className="h-6 w-12 bg-neutral-100 animate-pulse" />;
return <span>Items: {cartCount}</span>;
}
8. Common Gotchas
-
Attempting to Read Stores in Server Components: You cannot import hooks like
useCourseStoreor call Zustand states inside Server Components. Doing so throws a compilation crash. Server Components do not execute inside browser contexts. -
Missing Provider Wrappers: Calling
useCourseStoreinside a component that isn't wrapped by<CourseStoreProvider>will throw a runtime Null Reference crash.
Key Takeaways
- Standard global store singletons leak data across requests inside Next.js server runtimes.
- Isolate Zustand stores per request using the React Context Provider factory pattern.
- Wrap store instantiations inside
useRefhooks to guarantee they execute once on mount. - Pass server-fetched database variables directly into Providers to populate store states cleanly.
- Defer rendering persisted stores using
mountedstate toggles to avoid hydration mismatches.
Zustand handles complex client UI memory trees. However, using memory stores for sorting, filtering, or search states is an anti-pattern. If a user refreshes their tab, memory states reset. In the next lesson, we will cover URL State with nuqs, learning how to sync search filters directly to the browser URL.