{"slug": "global-state-in-next-js-app-router-zustand-over-context-for-most-use-cases", "title": "Global State in Next.js App Router — Zustand Over Context for Most Use Cases", "summary": "A developer at Pixova recommends using Zustand over React Context for global state management in Next.js App Router, citing unnecessary re-renders with Context for frequently updated or complex state. The post demonstrates a practical migration to Zustand in the company's AI logo generator tool, showing how components subscribe only to needed state slices.", "body_md": "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.\n\nFor 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.\n\nHere's the practical comparison and how I set up state management in the generation tool at [pixova.io/blog/free-ai-logo-generator](https://pixova.io/blog/free-ai-logo-generator).\n\nContext 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.\n\n``` js\nconst UserContext = createContext();\n\nfunction UserProvider({ children }) {\n  const [user, setUser] = useState(null);\n  const [preferences, setPreferences] = useState({});\n  const [notifications, setNotifications] = useState([]);\n\n  // If notifications updates, ALL consumers re-render\n  return (\n    <UserContext.Provider value={{ user, preferences, notifications }}>\n      {children}\n    </UserContext.Provider>\n  );\n}\n```\n\nThe fix — splitting into multiple contexts — works but gets unwieldy quickly.\n\n```\nnpm install zustand\njs\n// lib/stores/userStore.ts\nimport { create } from 'zustand';\n\ninterface UserState {\n  user: User | null;\n  preferences: UserPreferences;\n  notifications: Notification[];\n  setUser: (user: User | null) => void;\n  updatePreferences: (prefs: Partial<UserPreferences>) => void;\n  addNotification: (notification: Notification) => void;\n  clearNotifications: () => void;\n}\n\nexport const useUserStore = create<UserState>((set) => ({\n  user: null,\n  preferences: { theme: 'light', language: 'en' },\n  notifications: [],\n\n  setUser: (user) => set({ user }),\n  updatePreferences: (prefs) =>\n    set((state) => ({ preferences: { ...state.preferences, ...prefs } })),\n  addNotification: (notification) =>\n    set((state) => ({ notifications: [...state.notifications, notification] })),\n  clearNotifications: () => set({ notifications: [] }),\n}));\n```\n\nComponents subscribe only to what they need:\n\n```\n// Only re-renders when user changes — not when notifications change\nfunction Header() {\n  const user = useUserStore((state) => state.user);\n  return <header>{user ? <span>{user.name}</span> : <span>Guest</span>}</header>;\n}\n\n// Only re-renders when notifications change\nfunction NotificationBell() {\n  const notifications = useUserStore((state) => state.notifications);\n  const clear = useUserStore((state) => state.clearNotifications);\n  return <button onClick={clear}>{notifications.length} 🔔</button>;\n}\njs\n// lib/stores/generationStore.ts\nimport { create } from 'zustand';\nimport { persist } from 'zustand/middleware';\n\nexport type GenerationStatus = 'idle' | 'pending' | 'complete' | 'error';\n\ninterface GenerationState {\n  prompt: string;\n  aspectRatio: '1:1' | '16:9' | '9:16' | '4:5';\n  status: GenerationStatus;\n  jobId: string | null;\n  outputUrl: string | null;\n  error: string | null;\n  history: { prompt: string; url: string; timestamp: number }[];\n\n  setPrompt: (prompt: string) => void;\n  setAspectRatio: (ratio: GenerationState['aspectRatio']) => void;\n  startGeneration: (jobId: string) => void;\n  completeGeneration: (url: string) => void;\n  setError: (error: string) => void;\n  reset: () => void;\n}\n\nconst initialState = {\n  prompt: '',\n  aspectRatio: '1:1' as const,\n  status: 'idle' as GenerationStatus,\n  jobId: null,\n  outputUrl: null,\n  error: null,\n  history: [],\n};\n\nexport const useGenerationStore = create<GenerationState>()(\n  persist(\n    (set, get) => ({\n      ...initialState,\n\n      setPrompt: (prompt) => set({ prompt }),\n      setAspectRatio: (aspectRatio) => set({ aspectRatio }),\n\n      startGeneration: (jobId) =>\n        set({ status: 'pending', jobId, outputUrl: null, error: null }),\n\n      completeGeneration: (url) => {\n        const { prompt, history } = get();\n        set({\n          status: 'complete',\n          outputUrl: url,\n          history: [\n            { prompt, url, timestamp: Date.now() },\n            ...history.slice(0, 19),\n          ],\n        });\n      },\n\n      setError: (error) => set({ status: 'error', error }),\n      reset: () => set(initialState),\n    }),\n    {\n      name: 'generation-store',\n      partialize: (state) => ({\n        aspectRatio: state.aspectRatio,\n        history: state.history,\n      }),\n    }\n  )\n);\n```\n\nThe `persist`\n\nmiddleware handles localStorage sync automatically. `partialize`\n\ncontrols which fields persist — transient state like current job status doesn't need to survive a page reload.\n\nZustand is client-side only. The boundary pattern:\n\n```\n// app/generate/page.js — Server Component\nexport default async function GeneratePage() {\n  const initialData = await getInitialData();\n  return <GenerateInterface initialData={initialData} />;\n}\n\n// components/GenerateInterface.jsx — Client Component\n'use client';\nimport { useGenerationStore } from '@/lib/stores/generationStore';\n\nexport function GenerateInterface({ initialData }) {\n  const { prompt, setPrompt, status } = useGenerationStore();\n  // Server data from props, client UI state from Zustand\n}\n```\n\nClean separation: server data flows through props, UI state lives in Zustand.\n\nZustand handles derived state cleanly through selectors:\n\n``` js\n// Derived state computed from store, not stored separately\nconst pendingCount = useGenerationStore(\n  (state) => state.history.filter(h => h.status === 'pending').length\n);\n\nconst hasError = useGenerationStore(\n  (state) => state.status === 'error' && state.error !== null\n);\n\n// Memoized selector for objects (prevents re-renders on shallow-equal objects)\nimport { useShallow } from 'zustand/react/shallow';\n\nconst { prompt, aspectRatio } = useGenerationStore(\n  useShallow((state) => ({ prompt: state.prompt, aspectRatio: state.aspectRatio }))\n);\n```\n\nThe `useShallow`\n\nhook prevents re-renders when an object selector returns a new object with the same values — important when selecting multiple fields together.\n\nZustand isn't always better:\n\n**One-time initialization.** Theme from cookie, session from server — set once, never updated. Context's re-render cost is zero because it never changes.\n\n**Library integration.** React Query, React Router, and similar libraries provide their own context-based APIs. Use them as designed.\n\n**Very small trees.** If Provider and all consumers are siblings that render together anyway, Context is fine.\n\nSwitch to Zustand when you're adding `useMemo`\n\n/`useCallback`\n\neverywhere to prevent Context re-renders. If you're not, Context is probably sufficient.\n\nConverting an existing Context to Zustand takes roughly an hour:\n\n`useContext(MyContext)`\n\nwith `useMyStore(selector)`\n\n`useShallow`\n\nwhere selecting multiple fields as objectStores are testable without any component mounting:\n\n``` js\n// __tests__/stores/generationStore.test.ts\nimport { useGenerationStore } from '@/lib/stores/generationStore';\n\n// Reset store state between tests\nbeforeEach(() => {\n  useGenerationStore.getState().reset();\n});\n\ntest('startGeneration updates status and jobId', () => {\n  useGenerationStore.getState().startGeneration('job-123');\n\n  const state = useGenerationStore.getState();\n  expect(state.status).toBe('pending');\n  expect(state.jobId).toBe('job-123');\n  expect(state.outputUrl).toBeNull();\n});\n\ntest('completeGeneration adds to history', () => {\n  useGenerationStore.setState({ prompt: 'a sunset' });\n  useGenerationStore.getState().completeGeneration('https://cdn.example.com/img.webp');\n\n  const state = useGenerationStore.getState();\n  expect(state.status).toBe('complete');\n  expect(state.history).toHaveLength(1);\n  expect(state.history[0].prompt).toBe('a sunset');\n});\n\ntest('history is limited to 20 entries', () => {\n  for (let i = 0; i < 25; i++) {\n    useGenerationStore.setState({ prompt: `prompt ${i}` });\n    useGenerationStore.getState().completeGeneration(`https://example.com/${i}.webp`);\n  }\n\n  expect(useGenerationStore.getState().history).toHaveLength(20);\n});\n```\n\nDirect store access via `getState()`\n\nand `setState()`\n\nmakes unit testing straightforward without mocking or component wrappers.\n\nZustand solves the main practical problem with React Context — unnecessary re-renders — with a simple API that requires minimal boilerplate.\n\nThe core pattern: create typed stores with `create<State>()`\n\n, access state with selector functions that subscribe only to specific slices, use `persist`\n\nmiddleware for localStorage sync, and use `useShallow`\n\nwhen selecting multiple fields as an object.\n\nFor 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.\n\nA 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:\n\n``` js\n// lib/websocket.ts — no React imports needed\nimport { useGenerationStore } from './stores/generationStore';\n\nexport function setupWebSocket(jobId: string) {\n  const ws = new WebSocket(`wss://api.example.com/jobs/${jobId}`);\n\n  ws.onmessage = (event) => {\n    const data = JSON.parse(event.data);\n\n    if (data.status === 'complete') {\n      // Update store directly — no hooks needed\n      useGenerationStore.getState().completeGeneration(data.outputUrl);\n    }\n\n    if (data.status === 'error') {\n      useGenerationStore.getState().setError(data.message);\n    }\n  };\n\n  return ws;\n}\n```\n\nContext has no equivalent — you can't call `useContext`\n\noutside a component. Zustand's direct store access fills a genuine gap when coordinating state updates from non-React code.", "url": "https://wpnews.pro/news/global-state-in-next-js-app-router-zustand-over-context-for-most-use-cases", "canonical_source": "https://dev.to/aon_infotech_3a1b6ff525fc/global-state-in-nextjs-app-router-zustand-over-context-for-most-use-cases-1047", "published_at": "2026-07-01 13:15:14+00:00", "updated_at": "2026-07-01 13:18:35.624940+00:00", "lang": "en", "topics": ["developer-tools", "ai-products"], "entities": ["Pixova", "Zustand", "React Context", "Next.js"], "alternates": {"html": "https://wpnews.pro/news/global-state-in-next-js-app-router-zustand-over-context-for-most-use-cases", "markdown": "https://wpnews.pro/news/global-state-in-next-js-app-router-zustand-over-context-for-most-use-cases.md", "text": "https://wpnews.pro/news/global-state-in-next-js-app-router-zustand-over-context-for-most-use-cases.txt", "jsonld": "https://wpnews.pro/news/global-state-in-next-js-app-router-zustand-over-context-for-most-use-cases.jsonld"}}