Next.jsZustandState ManagementTypeScriptReact

Zustand dans Next.js App Router : gérez votre état global sans Redux

Découvrez comment utiliser Zustand pour gérer votre état global dans Next.js App Router avec TypeScript. Simple, léger et compatible SSR.

AMAlexis Mouchon8 min de lecture

La gestion de l'état global est un sujet récurrent dans tout projet React. Pendant longtemps, Redux a régné en maître. Mais en 2026, il existe une alternative bien plus légère, bien plus simple, et parfaitement adaptée à Next.js App Router : Zustand.

Dans cet article, on va voir ensemble pourquoi Zustand s'impose comme la solution de state management idéale pour vos projets Next.js, et comment l'intégrer correctement avec l'App Router et TypeScript.


Pourquoi pas Redux (ou Context API) ?

Avant de plonger dans Zustand, honnêteté oblige : parlons des alternatives.

Redux reste pertinent pour des applications très complexes avec des effets de bord nombreux et un historique d'actions à auditer. Mais pour 90 % des projets freelance — un site vitrine avec panier, un SaaS avec préférences utilisateur, un dashboard avec filtres — Redux est over-engineered. Le boilerplate est lourd, la courbe d'apprentissage est réelle, et la DX n'a rien de joyeux.

Context API + useReducer ? C'est natif, certes. Mais le problème bien connu des re-renders en cascade reste présent, et pour un état partagé entre plusieurs parties de l'arbre, ça devient vite ingérable.

Zustand répond à ces deux problèmes en restant minimaliste.


Zustand : le principe en 30 secondes

Zustand (« état » en allemand) est une bibliothèque de state management basée sur un store externe à React. Pas de Provider à wrapper, pas d'actions à dispatcher, pas de reducers à écrire.

On crée un store, on y accède avec un hook, c'est tout.

npm install zustand

Voici un store basique avec TypeScript :

// store/useCartStore.ts
import { create } from 'zustand'

interface CartItem {
  id: string
  name: string
  price: number
  quantity: number
}

interface CartStore {
  items: CartItem[]
  addItem: (item: CartItem) => void
  removeItem: (id: string) => void
  clearCart: () => void
  totalPrice: () => number
}

export const useCartStore = create<CartStore>((set, get) => ({
  items: [],

  addItem: (item) =>
    set((state) => {
      const existing = state.items.find((i) => i.id === item.id)
      if (existing) {
        return {
          items: state.items.map((i) =>
            i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
          ),
        }
      }
      return { items: [...state.items, item] }
    }),

  removeItem: (id) =>
    set((state) => ({
      items: state.items.filter((i) => i.id !== id),
    })),

  clearCart: () => set({ items: [] }),

  totalPrice: () =>
    get().items.reduce((sum, item) => sum + item.price * item.quantity, 0),
}))

Et l'utilisation dans un composant :

// components/CartButton.tsx
'use client'

import { useCartStore } from '@/store/useCartStore'

export function CartButton() {
  const { items, totalPrice } = useCartStore()

  return (
    <button className="relative px-4 py-2 bg-indigo-600 text-white rounded-lg">
      Panier ({items.length}) — {totalPrice().toFixed(2)} €
    </button>
  )
}

Propre, lisible, typé. Pas de boilerplate.


Intégration avec Next.js App Router : attention au SSR

Le vrai défi avec Zustand dans Next.js App Router, c'est la gestion du SSR (Server-Side Rendering). Par défaut, Zustand stocke son état en mémoire côté client. Si vous accédez au store depuis un Server Component ou pendant le rendu serveur, vous aurez des problèmes d'hydratation.

La règle d'or

Tout composant qui utilise un store Zustand doit être un Client Component ('use client').

Le pattern recommandé : le store provider

Pour éviter que l'état partagé entre plusieurs requêtes SSR, il faut utiliser le pattern Store Provider. C'est particulièrement important pour les stores qui contiennent des données spécifiques à l'utilisateur (session, préférences...).

// store/useUserPrefsStore.ts
import { createStore } from 'zustand'

interface UserPrefsState {
  theme: 'light' | 'dark'
  language: 'fr' | 'en'
  setTheme: (theme: 'light' | 'dark') => void
  setLanguage: (lang: 'fr' | 'en') => void
}

export type UserPrefsStore = ReturnType<typeof createUserPrefsStore>

export const createUserPrefsStore = (
  initState?: Partial<Pick<UserPrefsState, 'theme' | 'language'>>
) =>
  createStore<UserPrefsState>()((set) => ({
    theme: initState?.theme ?? 'light',
    language: initState?.language ?? 'fr',
    setTheme: (theme) => set({ theme }),
    setLanguage: (language) => set({ language }),
  }))
// providers/UserPrefsProvider.tsx
'use client'

import { createContext, useContext, useRef, type ReactNode } from 'react'
import { useStore } from 'zustand'
import {
  createUserPrefsStore,
  type UserPrefsStore,
} from '@/store/useUserPrefsStore'

const UserPrefsContext = createContext<UserPrefsStore | null>(null)

interface UserPrefsProviderProps {
  children: ReactNode
  initialTheme?: 'light' | 'dark'
}

export function UserPrefsProvider({
  children,
  initialTheme,
}: UserPrefsProviderProps) {
  const storeRef = useRef<UserPrefsStore>()

  if (!storeRef.current) {
    storeRef.current = createUserPrefsStore({ theme: initialTheme })
  }

  return (
    <UserPrefsContext.Provider value={storeRef.current}>
      {children}
    </UserPrefsContext.Provider>
  )
}

export function useUserPrefs<T>(
  selector: (state: ReturnType<UserPrefsStore['getState']>) => T
): T {
  const store = useContext(UserPrefsContext)
  if (!store) throw new Error('useUserPrefs doit être utilisé dans UserPrefsProvider')
  return useStore(store, selector)
}

Ce pattern garantit qu'une nouvelle instance du store est créée par requête côté serveur, évitant toute fuite de données entre utilisateurs.


Middleware Zustand : persist et devtools

Zustand dispose d'un système de middleware puissant. Les deux plus utilisés :

persist : sauvegarder en localStorage

import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'

interface ThemeStore {
  theme: 'light' | 'dark'
  toggleTheme: () => void
}

export const useThemeStore = create<ThemeStore>()(
  persist(
    (set, get) => ({
      theme: 'light',
      toggleTheme: () =>
        set({ theme: get().theme === 'light' ? 'dark' : 'light' }),
    }),
    {
      name: 'theme-storage',
      storage: createJSONStorage(() => localStorage),
    }
  )
)

devtools : déboguer avec Redux DevTools

import { create } from 'zustand'
import { devtools } from 'zustand/middleware'

export const useCartStore = create<CartStore>()(
  devtools(
    (set, get) => ({
      // ... votre store
    }),
    { name: 'CartStore' }
  )
)

Les deux middlewares se combinent sans problème :

export const useAppStore = create<AppStore>()(
  devtools(
    persist(
      (set, get) => ({ /* ... */ }),
      { name: 'app-storage' }
    ),
    { name: 'AppStore' }
  )
)

Sélecteurs : évitez les re-renders inutiles

Un avantage clé de Zustand par rapport à Context API : les re-renders sont granulaires. Par défaut, le composant ne re-render que si la valeur sélectionnée change.

// ❌ Mauvais : re-render si n'importe quelle partie du store change
const store = useCartStore()

// ✅ Bon : re-render uniquement si items.length change
const itemCount = useCartStore((state) => state.items.length)

// ✅ Bon : sélecteur stable avec useShallow pour les objets/tableaux
import { useShallow } from 'zustand/react/shallow'

const { addItem, removeItem } = useCartStore(
  useShallow((state) => ({
    addItem: state.addItem,
    removeItem: state.removeItem,
  }))
)

useShallow effectue une comparaison superficielle, ce qui évite de re-render quand les actions (fonctions) ne changent pas — ce qui est toujours le cas avec Zustand.


Organisation des stores dans un projet Next.js

Pour un projet de taille moyenne, voici la structure que j'adopte :

src/
├── store/
│   ├── useCartStore.ts        # State du panier
│   ├── useAuthStore.ts        # État d'authentification (client only)
│   ├── useUIStore.ts          # Modales, drawers, toasts
│   └── useFiltersStore.ts     # Filtres d'une page de liste
├── providers/
│   ├── StoreProvider.tsx      # Provider global pour les stores SSR-safe
│   └── index.tsx

Règle simple : un store par domaine fonctionnel. Ne mettez pas tout dans un store global monolithique — vous perdriez les bénéfices des re-renders granulaires.


Conclusion

Zustand est probablement la solution de state management la plus adaptée à l'écosystème Next.js App Router en 2026. Légère (~1 kB gzippé), sans boilerplate, compatible TypeScript et SSR avec le bon pattern, elle couvre l'immense majorité des besoins sans vous noyer dans la configuration.

Pour résumer :

Si vous migrez depuis Redux, commencez par un store isolé (panier, thème, UI) : vous verrez immédiatement la différence en termes de DX.


Vous travaillez sur un projet Next.js et vous vous perdez dans la gestion d'état ou l'architecture frontend ? N'hésitez pas à me contacter — j'interviens en freelance sur ce type de sujets et je serais ravi d'en discuter avec vous.