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.
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.
npm install zustand
js
// 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>;
}
js
// 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.
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.
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.
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.
Converting an existing Context to Zustand takes roughly an hour:
useContext(MyContext)
with useMyStore(selector)
useShallow
where selecting multiple fields as objectStores 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.
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.
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.