# Global State in Next.js App Router — Zustand Over Context for Most Use Cases

> Source: <https://dev.to/aon_infotech_3a1b6ff525fc/global-state-in-nextjs-app-router-zustand-over-context-for-most-use-cases-1047>
> Published: 2026-07-01 13:15:14+00:00

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](https://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.

``` js
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:

``` js
// 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:

``` js
// __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:

``` js
// 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.
