React Context works. It's built-in, requires no dependencies, and handles many state management needs fine. It also causes the specific problem that leads developers to look for alternatives: unnecessary re-renders when the context value changes.
For small amounts of global state — a theme preference, a user session — Context is fine. For anything with more frequent updates or more complex structure, Zustand is meaningfully better and the migration is straightforward.
Here's the practical comparison and how I set up state management in the generation tool at pixova.io/blog/free-ai-logo-generator.
The Context Re-Render Problem
Context triggers a re-render in every component that consumes it whenever the context value changes — even if the specific piece of state the component cares about didn't change.
const UserContext = createContext();
function UserProvider({ children }) {
const [user, setUser] = useState(null);
const [preferences, setPreferences] = useState({});
const [notifications, setNotifications] = useState([]);
// If notifications updates, ALL consumers re-render
return (
<UserContext.Provider value={{ user, preferences, notifications }}>
{children}
</UserContext.Provider>
);
}
The fix — splitting into multiple contexts — works but gets unwieldy quickly.
Zustand — Selector-Based Subscriptions
npm install zustand
// lib/stores/userStore.ts
import { create } from 'zustand';
interface UserState {
user: User | null;
preferences: UserPreferences;
notifications: Notification[];
setUser: (user: User | null) => void;
updatePreferences: (prefs: Partial<UserPreferences>) => void;
addNotification: (notification: Notification) => void;
clearNotifications: () => void;
}
export const useUserStore = create<UserState>((set) => ({
user: null,
preferences: { theme: 'light', language: 'en' },
notifications: [],
setUser: (user) => set({ user }),
updatePreferences: (prefs) =>
set((state) => ({ preferences: { ...state.preferences, ...prefs } })),
addNotification: (notification) =>
set((state) => ({ notifications: [...state.notifications, notification] })),
clearNotifications: () => set({ notifications: [] }),
}));
Components subscribe only to what they need:
// Only re-renders when user changes — not when notifications change
function Header() {
const user = useUserStore((state) => state.user);
return <header>{user ? <span>{user.name}</span> : <span>Guest</span>}</header>;
}
// Only re-renders when notifications change
function NotificationBell() {
const notifications = useUserStore((state) => state.notifications);
const clear = useUserStore((state) => state.clearNotifications);
return <button onClick={clear}>{notifications.length} 🔔</button>;
}
The Full Production Pattern With TypeScript and Persist
// lib/stores/generationStore.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
export type GenerationStatus = 'idle' | 'pending' | 'complete' | 'error';
interface GenerationState {
prompt: string;
aspectRatio: '1:1' | '16:9' | '9:16' | '4:5';
status: GenerationStatus;
jobId: string | null;
outputUrl: string | null;
error: string | null;
history: { prompt: string; url: string; timestamp: number }[];
setPrompt: (prompt: string) => void;
setAspectRatio: (ratio: GenerationState['aspectRatio']) => void;
startGeneration: (jobId: string) => void;
completeGeneration: (url: string) => void;
setError: (error: string) => void;
reset: () => void;
}
const initialState = {
prompt: '',
aspectRatio: '1:1' as const,
status: 'idle' as GenerationStatus,
jobId: null,
outputUrl: null,
error: null,
history: [],
};
export const useGenerationStore = create<GenerationState>()(
persist(
(set, get) => ({
...initialState,
setPrompt: (prompt) => set({ prompt }),
setAspectRatio: (aspectRatio) => set({ aspectRatio }),
startGeneration: (jobId) =>
set({ status: 'pending', jobId, outputUrl: null, error: null }),
completeGeneration: (url) => {
const { prompt, history } = get();
set({
status: 'complete',
outputUrl: url,
history: [
{ prompt, url, timestamp: Date.now() },
...history.slice(0, 19),
],
});
},
setError: (error) => set({ status: 'error', error }),
reset: () => set(initialState),
}),
{
name: 'generation-store',
partialize: (state) => ({
aspectRatio: state.aspectRatio,
history: state.history,
}),
}
)
);
The persist middleware handles localStorage sync automatically. partialize controls which fields persist — transient state like current job status doesn't need to survive a page reload.
Server Component Integration
Zustand is client-side only. The boundary pattern:
// app/generate/page.js — Server Component
export default async function GeneratePage() {
const initialData = await getInitialData();
return <GenerateInterface initialData={initialData} />;
}
// components/GenerateInterface.jsx — Client Component
'use client';
import { useGenerationStore } from '@/lib/stores/generationStore';
export function GenerateInterface({ initialData }) {
const { prompt, setPrompt, status } = useGenerationStore();
// Server data from props, client UI state from Zustand
}
Clean separation: server data flows through props, UI state lives in Zustand.
Computed State With Selectors
Zustand handles derived state cleanly through selectors:
// Derived state computed from store, not stored separately
const pendingCount = useGenerationStore(
(state) => state.history.filter(h => h.status === 'pending').length
);
const hasError = useGenerationStore(
(state) => state.status === 'error' && state.error !== null
);
// Memoized selector for objects (prevents re-renders on shallow-equal objects)
import { useShallow } from 'zustand/react/shallow';
const { prompt, aspectRatio } = useGenerationStore(
useShallow((state) => ({ prompt: state.prompt, aspectRatio: state.aspectRatio }))
);
The useShallow hook prevents re-renders when an object selector returns a new object with the same values — important when selecting multiple fields together.
When Context Is Still Correct
Zustand isn't always better:
One-time initialization. Theme from cookie, session from server — set once, never updated. Context's re-render cost is zero because it never changes.
Library integration. React Query, React Router, and similar libraries provide their own context-based APIs. Use them as designed.
Very small trees. If Provider and all consumers are siblings that render together anyway, Context is fine.
Switch to Zustand when you're adding useMemo/useCallback everywhere to prevent Context re-renders. If you're not, Context is probably sufficient.
Migration Checklist
Converting an existing Context to Zustand takes roughly an hour:
- [ ] Create store matching context value shape
- [ ] Replace
useContext(MyContext)withuseMyStore(selector) - [ ] Remove Provider wrapper from component tree
- [ ] Add
useShallowwhere selecting multiple fields as object - [ ] Delete context file The selector pattern is the main learning curve. Everything else maps directly.
Testing Zustand Stores
Stores are testable without any component mounting:
// __tests__/stores/generationStore.test.ts
import { useGenerationStore } from '@/lib/stores/generationStore';
// Reset store state between tests
beforeEach(() => {
useGenerationStore.getState().reset();
});
test('startGeneration updates status and jobId', () => {
useGenerationStore.getState().startGeneration('job-123');
const state = useGenerationStore.getState();
expect(state.status).toBe('pending');
expect(state.jobId).toBe('job-123');
expect(state.outputUrl).toBeNull();
});
test('completeGeneration adds to history', () => {
useGenerationStore.setState({ prompt: 'a sunset' });
useGenerationStore.getState().completeGeneration('https://cdn.example.com/img.webp');
const state = useGenerationStore.getState();
expect(state.status).toBe('complete');
expect(state.history).toHaveLength(1);
expect(state.history[0].prompt).toBe('a sunset');
});
test('history is limited to 20 entries', () => {
for (let i = 0; i < 25; i++) {
useGenerationStore.setState({ prompt: `prompt ${i}` });
useGenerationStore.getState().completeGeneration(`https://example.com/${i}.webp`);
}
expect(useGenerationStore.getState().history).toHaveLength(20);
});
Direct store access via getState() and setState() makes unit testing straightforward without mocking or component wrappers.
Summary
Zustand solves the main practical problem with React Context — unnecessary re-renders — with a simple API that requires minimal boilerplate.
The core pattern: create typed stores with create<State>(), access state with selector functions that subscribe only to specific slices, use persist middleware for localStorage sync, and use useShallow when selecting multiple fields as an object.
For most Next.js applications with client-side UI state needs, Zustand handles everything Context does while adding selective re-renders, simpler middleware, and better TypeScript ergonomics.
One More Edge Case — Zustand Outside React
A useful property of Zustand stores: they're accessible outside React components entirely. This is useful for imperatively updating state from event handlers, websocket listeners, or other non-component contexts:
// lib/websocket.ts — no React imports needed
import { useGenerationStore } from './stores/generationStore';
export function setupWebSocket(jobId: string) {
const ws = new WebSocket(`wss://api.example.com/jobs/${jobId}`);
ws.onmessage = (event) => {
const data = JSON.parse(event.data);
if (data.status === 'complete') {
// Update store directly — no hooks needed
useGenerationStore.getState().completeGeneration(data.outputUrl);
}
if (data.status === 'error') {
useGenerationStore.getState().setError(data.message);
}
};
return ws;
}
Context has no equivalent — you can't call useContext outside a component. Zustand's direct store access fills a genuine gap when coordinating state updates from non-React code.