cd /news/developer-tools/global-state-in-next-js-app-router-z… · home topics developer-tools article
[ARTICLE · art-46641] src=dev.to ↗ pub= topic=developer-tools verified=true sentiment=· neutral

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

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.

read6 min views1 publishedJul 1, 2026

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.

── more in #developer-tools 4 stories · sorted by recency
── more on @pixova 3 stories trending now
sponsored brought to you by zahid.host 4,200+ EU-deployed projects
reading about agents? ship yours in a single git push.

Run your AI side-project on zahid.host

EU-based hosting, git-push deploys, automatic HTTPS, no cold starts. Free tier with a custom domain — perfect for shipping the agent you just read about.

$git push zahid main
Live at https://your-agent.zahid.host
Get free account → Pricing
from €0/mo · no card required
LIVE [news/global-state-in-next…] indexed:0 read:6min 2026-07-01 ·