Front-end

Zustand vs Redux Toolkit : state management React

React Zustand Redux-Toolkit State-Management Useshallow Middleware-Persist Rtk-Query Tanstack-Query Jotai Immer Redux-Devtools Selectors
Zustand vs Redux Toolkit : state management React

Zustand vs Redux Toolkit : middleware persist/devtools/immer, useShallow, RTK Query vs TanStack Query, migration et separation client/serveur.

Pourquoi une bibliothèque de state ?

Le Context API de React est excellent pour partager un état simple entre composants. Mais à mesure que votre application grandit, vous rencontrez des limites concrètes : re-rendus massifs quand le contexte se met à jour, logique métier difficile à tester, pas de DevTools avancés, mutations accidentelles de l'état.

C'est à ce moment qu'une bibliothèque de state management entre en jeu. En 2025, deux solutions dominent l'écosystème React :

  • Zustand : minimaliste, API ultra-simple, ~8KB, popularité explosive depuis 2022
  • Redux Toolkit (RTK) : standard industriel depuis 2019, DevTools puissants, écosystème mature
Quand passer du Context à une bibliothèque ? Dès que vous avez (1) plusieurs composants non liés qui accèdent au même état, (2) des mises à jour fréquentes qui causent des lenteurs mesurables, ou (3) un état avec des transitions complexes qui nécessitent un historique ou un débogage avancé.
Critère Context API Zustand Redux Toolkit
Taille bundle 0KB (natif) ~8KB ~40KB
Courbe apprentissage Faible Très faible Modérée
DevTools React DevTools basique Redux DevTools (opt.) Redux DevTools complets
Re-rendus sélectifs Difficile (useMemo) Natif (sélecteurs) Natif (useSelector)
Async / API calls Manuel (useEffect) Simple (dans le store) createAsyncThunk
Immuabilité forcée Non Non (flexibilité) Oui (Immer intégré)

Zustand : fondamentaux et installation

Zustand (mot allemand pour "état") est une bibliothèque de state management minimaliste créée par les auteurs de Jotai et React Spring. Sa philosophie : le minimum de code pour le maximum de puissance.

Installation

npm install zustand

Créer un premier store

// stores/compteurStore.ts
import { create } from 'zustand';

// Définir le type du store (TypeScript)
interface CompteurStore {
    // --- État ---
    count:     number;
    pas:       number;

    // --- Actions ---
    incrementer:   () => void;
    decrementer:   () => void;
    reinitialiser: () => void;
    setPas:        (pas: number) => void;
}

// create() retourne un hook React utilisable directement dans les composants
export const useCompteurStore = create<CompteurStore>((set, get) => ({
    // --- État initial ---
    count: 0,
    pas:   1,

    // --- Actions : set() met à jour le store et déclenche les re-rendus ---
    incrementer: () => set(state => ({ count: state.count + state.pas })),
    decrementer: () => set(state => ({ count: state.count - state.pas })),

    // Réinitialiser à 0 sans argument
    reinitialiser: () => set({ count: 0 }),

    // get() permet de lire l'état courant dans une action
    setPas: (pas: number) => {
        const countActuel = get().count;
        console.log(`Changement de pas. Count actuel : ${countActuel}`);
        set({ pas });
    },
}));

Utiliser le store dans un composant

// components/Compteur.tsx
import { useCompteurStore } from '../stores/compteurStore';

function Compteur() {
    // Sélectionner uniquement ce dont le composant a besoin
    // → Ce composant NE re-rend PAS si pas change (il ne le sélectionne pas)
    const count        = useCompteurStore(state => state.count);
    const incrementer  = useCompteurStore(state => state.incrementer);
    const decrementer  = useCompteurStore(state => state.decrementer);
    const reinitialiser = useCompteurStore(state => state.reinitialiser);

    return (
        <div>
            <p>Count : {count}</p>
            <button onClick={decrementer}>−</button>
            <button onClick={incrementer}>+</button>
            <button onClick={reinitialiser}>Reset</button>
        </div>
    );
}

// Composant séparé — ne re-rend QUE si pas change
function ConfigurateurPas() {
    const pas    = useCompteurStore(state => state.pas);
    const setPas = useCompteurStore(state => state.setPas);

    return (
        <div>
            <label>Pas : {pas}</label>
            <input
                type="range" min="1" max="10"
                value={pas}
                onChange={e => setPas(Number(e.target.value))}
            />
        </div>
    );
}
Sélecteurs Zustand : En passant une fonction sélecteur à useCompteurStore(state => state.count), le composant ne se re-rend que si count change. C'est l'avantage majeur sur le Context API où tous les consumers re-rendent à chaque changement.

Zustand : patterns avancés

Store avec appels API asynchrones

// stores/articlesStore.ts
import { create } from 'zustand';

interface Article {
    id:     number;
    titre:  string;
    auteur: string;
}

interface ArticlesStore {
    articles:    Article[];
    chargement:  boolean;
    erreur:      string | null;
    fetchArticles: () => Promise<void>;
    ajouterArticle: (article: Omit<Article, 'id'>) => Promise<void>;
    supprimerArticle: (id: number) => void;
}

export const useArticlesStore = create<ArticlesStore>((set, get) => ({
    articles:   [],
    chargement: false,
    erreur:     null,

    // Action asynchrone directement dans le store — pas besoin de thunk
    fetchArticles: async () => {
        set({ chargement: true, erreur: null });
        try {
            const response = await fetch('/api/articles');
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            const articles  = await response.json();
            set({ articles, chargement: false });
        } catch (err) {
            set({ erreur: (err as Error).message, chargement: false });
        }
    },

    ajouterArticle: async (articleSansId) => {
        const response = await fetch('/api/articles', {
            method:  'POST',
            headers: { 'Content-Type': 'application/json' },
            body:    JSON.stringify(articleSansId),
        });
        const nouvelArticle = await response.json();
        // Ajouter au store sans re-fetch complet
        set(state => ({ articles: [...state.articles, nouvelArticle] }));
    },

    supprimerArticle: (id: number) => {
        // Optimistic update : supprimer immédiatement, pas d'appel API ici
        set(state => ({
            articles: state.articles.filter(a => a.id !== id),
        }));
    },
}));

// Utilisation dans un composant
function ListeArticles() {
    const { articles, chargement, erreur, fetchArticles, supprimerArticle } =
        useArticlesStore();

    useEffect(() => {
        fetchArticles(); // Déclencher le fetch au montage
    }, [fetchArticles]);

    if (chargement) return <p>Chargement...</p>;
    if (erreur)     return <p>Erreur : {erreur}</p>;

    return (
        <ul>
            {articles.map(a => (
                <li key={a.id}>
                    {a.titre}
                    <button onClick={() => supprimerArticle(a.id)}>×</button>
                </li>
            ))}
        </ul>
    );
}

Middleware : persist (persistance localStorage)

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

interface PreferencesStore {
    theme:   'light' | 'dark';
    langue:  string;
    setTheme:  (theme: 'light' | 'dark') => void;
    setLangue: (langue: string) => void;
}

// persist() enveloppe le store et sauvegarde automatiquement dans localStorage
export const usePreferencesStore = create<PreferencesStore>()(
    persist(
        (set) => ({
            theme:  'light',
            langue: 'fr',
            setTheme:  (theme)  => set({ theme }),
            setLangue: (langue) => set({ langue }),
        }),
        {
            name:    'preferences-utilisateur', // Clé localStorage
            storage: createJSONStorage(() => localStorage),
            // Persister seulement certaines clés (pas les actions)
            partialize: (state) => ({ theme: state.theme, langue: state.langue }),
        }
    )
);
// Les préférences survivent aux rechargements de page automatiquement

Découper un gros store en slices

// Pattern slice : composer plusieurs sous-stores en un seul
import { StateCreator } from 'zustand';

// Slice auth
interface AuthSlice {
    utilisateur: { nom: string } | null;
    connecter:   (nom: string) => void;
    deconnecter: () => void;
}

const creerAuthSlice: StateCreator<AuthSlice & PanierSlice, [], [], AuthSlice> = (set) => ({
    utilisateur: null,
    connecter:   (nom) => set({ utilisateur: { nom } }),
    deconnecter: () => set({ utilisateur: null }),
});

// Slice panier
interface PanierSlice {
    items:    string[];
    ajouter:  (item: string) => void;
}

const creerPanierSlice: StateCreator<AuthSlice & PanierSlice, [], [], PanierSlice> = (set) => ({
    items:   [],
    ajouter: (item) => set(state => ({ items: [...state.items, item] })),
});

// Store combiné
export const useAppStore = create<AuthSlice & PanierSlice>()((...args) => ({
    ...creerAuthSlice(...args),
    ...creerPanierSlice(...args),
}));

Redux Toolkit : fondamentaux

Redux Toolkit (RTK) est la façon officielle et recommandée d'écrire du Redux depuis 2019. Il élimine le boilerplate de Redux classique (action creators, reducers verbeux, immutabilité manuelle) grâce à Immer et des utilitaires intégrés.

Installation

npm install @reduxjs/toolkit react-redux

Créer un slice (unité de base de RTK)

// features/compteur/compteurSlice.ts
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

interface CompteurState {
    valeur: number;
    pas:    number;
}

const etatInitial: CompteurState = {
    valeur: 0,
    pas:    1,
};

// createSlice génère automatiquement les action creators et le reducer
const compteurSlice = createSlice({
    name: 'compteur', // Préfixe des actions : "compteur/incrementer"

    initialState: etatInitial,

    // reducers : Immer permet d'écrire des mutations directes (transformées en immuable)
    reducers: {
        incrementer: (state) => {
            // Immer détecte cette "mutation" et crée un nouvel objet immuable
            state.valeur += state.pas;
        },

        decrementer: (state) => {
            state.valeur -= state.pas;
        },

        // PayloadAction type le payload de l'action
        setPas: (state, action: PayloadAction<number>) => {
            state.pas = action.payload;
        },

        reinitialiser: () => etatInitial, // Retourner l'état initial
    },
});

// Exporter les action creators générés automatiquement
export const { incrementer, decrementer, setPas, reinitialiser } = compteurSlice.actions;

// Exporter le reducer pour le configureStore
export default compteurSlice.reducer;

Configurer le store Redux

// app/store.ts
import { configureStore } from '@reduxjs/toolkit';
import compteurReducer from '../features/compteur/compteurSlice';
import articlesReducer from '../features/articles/articlesSlice';

export const store = configureStore({
    reducer: {
        compteur: compteurReducer, // state.compteur
        articles: articlesReducer, // state.articles
    },
    // DevTools activés automatiquement en développement
});

// Types pour TypeScript — inférés depuis le store
export type RootState   = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

// Hooks typés — à utiliser à la place de useSelector/useDispatch bruts
import { useDispatch, useSelector, TypedUseSelectorHook } from 'react-redux';

export const useAppDispatch = () => useDispatch<AppDispatch>();
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector;

Brancher le Provider Redux

// main.tsx
import { Provider } from 'react-redux';
import { store }    from './app/store';

createRoot(document.getElementById('root')!).render(
    <Provider store={store}>
        <App />
    </Provider>
);

Utiliser le store dans les composants

// features/compteur/Compteur.tsx
import { useAppSelector, useAppDispatch } from '../../app/store';
import { incrementer, decrementer, reinitialiser } from './compteurSlice';

function Compteur() {
    const dispatch = useAppDispatch();

    // useAppSelector : sélectionner une partie du state
    // Ce composant ne re-rend QUE si state.compteur.valeur change
    const valeur = useAppSelector(state => state.compteur.valeur);
    const pas    = useAppSelector(state => state.compteur.pas);

    return (
        <div>
            <p>Valeur : {valeur} (pas : {pas})</p>
            <button onClick={() => dispatch(decrementer())}>−</button>
            <button onClick={() => dispatch(incrementer())}>+</button>
            <button onClick={() => dispatch(reinitialiser())}>Reset</button>
        </div>
    );
}

Redux Toolkit : slices et thunks

createAsyncThunk : appels API avec RTK

// features/articles/articlesSlice.ts
import { createSlice, createAsyncThunk, PayloadAction } from '@reduxjs/toolkit';

interface Article { id: number; titre: string; auteur: string; }

interface ArticlesState {
    items:      Article[];
    statut:     'idle' | 'chargement' | 'succes' | 'erreur';
    erreur:     string | null;
    articleSelectionne: Article | null;
}

const etatInitial: ArticlesState = {
    items:              [],
    statut:             'idle',
    erreur:             null,
    articleSelectionne: null,
};

// createAsyncThunk gère les états pending/fulfilled/rejected automatiquement
export const fetchArticles = createAsyncThunk(
    'articles/fetchAll', // Nom de l'action (type de base)
    async (_, { rejectWithValue }) => {
        try {
            const response = await fetch('/api/articles');
            if (!response.ok) throw new Error(`HTTP ${response.status}`);
            return await response.json() as Article[];
        } catch (err) {
            // rejectWithValue permet de passer une valeur à l'état "rejected"
            return rejectWithValue((err as Error).message);
        }
    }
);

export const supprimerArticle = createAsyncThunk(
    'articles/supprimer',
    async (id: number, { rejectWithValue }) => {
        try {
            await fetch(`/api/articles/${id}`, { method: 'DELETE' });
            return id; // Retourner l'id pour l'enlever du store
        } catch (err) {
            return rejectWithValue((err as Error).message);
        }
    }
);

const articlesSlice = createSlice({
    name:         'articles',
    initialState: etatInitial,
    reducers: {
        // Action synchrone simple
        selectionnerArticle: (state, action: PayloadAction<Article>) => {
            state.articleSelectionne = action.payload;
        },
        deselectionner: (state) => {
            state.articleSelectionne = null;
        },
    },

    // extraReducers : gérer les actions des thunks
    extraReducers: (builder) => {
        // fetchArticles
        builder
            .addCase(fetchArticles.pending, (state) => {
                state.statut = 'chargement';
                state.erreur = null;
            })
            .addCase(fetchArticles.fulfilled, (state, action) => {
                state.statut = 'succes';
                state.items  = action.payload; // Données retournées par le thunk
            })
            .addCase(fetchArticles.rejected, (state, action) => {
                state.statut = 'erreur';
                state.erreur = action.payload as string;
            });

        // supprimerArticle
        builder
            .addCase(supprimerArticle.fulfilled, (state, action) => {
                // action.payload = l'id retourné par le thunk
                state.items = state.items.filter(a => a.id !== action.payload);
            });
    },
});

export const { selectionnerArticle, deselectionner } = articlesSlice.actions;
export default articlesSlice.reducer;

// Sélecteurs : fonctions qui extraient des données du state
export const selectTousLesArticles = (state: RootState) => state.articles.items;
export const selectStatut          = (state: RootState) => state.articles.statut;
export const selectErreur          = (state: RootState) => state.articles.erreur;

Utiliser le thunk dans un composant

function ListeArticlesRTK() {
    const dispatch = useAppDispatch();
    const articles = useAppSelector(selectTousLesArticles);
    const statut   = useAppSelector(selectStatut);
    const erreur   = useAppSelector(selectErreur);

    useEffect(() => {
        // Dispatcher le thunk — RTK gère pending/fulfilled/rejected
        if (statut === 'idle') {
            dispatch(fetchArticles());
        }
    }, [statut, dispatch]);

    if (statut === 'chargement') return <p>Chargement...</p>;
    if (statut === 'erreur')     return <p>Erreur : {erreur}</p>;

    return (
        <ul>
            {articles.map(a => (
                <li key={a.id}>
                    {a.titre}
                    <button onClick={() => dispatch(supprimerArticle(a.id))}>×</button>
                </li>
            ))}
        </ul>
    );
}

Comparaison côte à côte : même feature

Pour comparer objectivement, implémentons la même fonctionnalité — un panier e-commerce — avec les deux solutions.

Zustand — panier en ~40 lignes

// stores/panierStore.ts — Solution Zustand complète
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

interface Item { id: number; nom: string; prix: number; quantite: number; }

interface PanierStore {
    items:      Item[];
    ajouter:    (item: Omit<Item, 'quantite'>) => void;
    retirer:    (id: number) => void;
    incrementer:(id: number) => void;
    decrementer:(id: number) => void;
    vider:      () => void;
    total:      () => number; // Action dérivée
}

export const usePanierStore = create<PanierStore>()(
    persist(
        (set, get) => ({
            items: [],
            ajouter: (item) => set(s => {
                const exist = s.items.find(i => i.id === item.id);
                return exist
                    ? { items: s.items.map(i => i.id === item.id ? { ...i, quantite: i.quantite + 1 } : i) }
                    : { items: [...s.items, { ...item, quantite: 1 }] };
            }),
            retirer:     (id) => set(s => ({ items: s.items.filter(i => i.id !== id) })),
            incrementer: (id) => set(s => ({ items: s.items.map(i => i.id === id ? { ...i, quantite: i.quantite + 1 } : i) })),
            decrementer: (id) => set(s => ({ items: s.items.map(i => i.id === id && i.quantite > 1 ? { ...i, quantite: i.quantite - 1 } : i) })),
            vider:  () => set({ items: [] }),
            total:  () => get().items.reduce((acc, i) => acc + i.prix * i.quantite, 0),
        }),
        { name: 'panier' }
    )
);

Redux Toolkit — même panier en ~80 lignes

// features/panier/panierSlice.ts — Solution RTK équivalente
import { createSlice, PayloadAction } from '@reduxjs/toolkit';

const panierSlice = createSlice({
    name: 'panier',
    initialState: { items: [] as Item[] },
    reducers: {
        ajouter: (state, action: PayloadAction<Omit<Item, 'quantite'>>) => {
            const exist = state.items.find(i => i.id === action.payload.id);
            if (exist) {
                exist.quantite += 1; // Immer rend cette mutation sûre
            } else {
                state.items.push({ ...action.payload, quantite: 1 });
            }
        },
        retirer:     (state, action: PayloadAction<number>) => {
            state.items = state.items.filter(i => i.id !== action.payload);
        },
        incrementer: (state, action: PayloadAction<number>) => {
            const item = state.items.find(i => i.id === action.payload);
            if (item) item.quantite += 1;
        },
        decrementer: (state, action: PayloadAction<number>) => {
            const item = state.items.find(i => i.id === action.payload);
            if (item && item.quantite > 1) item.quantite -= 1;
        },
        vider: (state) => { state.items = []; },
    },
});

export const { ajouter, retirer, incrementer, decrementer, vider } = panierSlice.actions;
export default panierSlice.reducer;

// Sélecteur dérivé
export const selectTotal = (state: RootState) =>
    state.panier.items.reduce((acc, i) => acc + i.prix * i.quantite, 0);
Observation : Pour cette feature, Zustand est deux fois moins verbeux. La persistance (persist) est intégrée en une ligne. RTK nécessite un configureStore, un Provider et un selecteur séparé. En revanche, RTK offre un historique complet dans les DevTools et une structure plus prévisible sur de gros projets.

Critères de choix entre les deux

Choisir Zustand si… Choisir Redux Toolkit si…
Projet solo ou petite équipe (<5 devs) Grande équipe avec conventions strictes
MVP, prototype, startup Application enterprise longue durée
Vous voulez être productif en 30 minutes Vous avez besoin du time-travel debugging
State simple sans historique d'actions Audit trail, logging des actions requis
Besoin de persistance localStorage facile Intégration avec RTK Query (cache API)
Bundle size critique (<10KB cible) Équipe déjà familière avec Redux
Flexibilité maximale sur l'architecture Structure conventionnelle et prévisible
Recommandation pour débutants et juniors : Commencez par Zustand. Son API est si simple que vous serez productif en une heure. Apprenez Redux Toolkit ensuite — sa structure stricte sera plus facile à comprendre une fois que vous maîtrisez les concepts de state management.

Migrer depuis Context API

Si vous avez déjà un Context API et que vous rencontrez des problèmes de performance ou de complexité, voici comment migrer vers Zustand sans tout réécrire.

// AVANT — Context API
// contexts/AuthContext.tsx
export const AuthContext = createContext<AuthContextType | null>(null);

export function AuthProvider({ children }: { children: ReactNode }) {
    const [utilisateur, setUtilisateur] = useState<Utilisateur | null>(null);
    const [chargement, setChargement]   = useState(false);

    const connecter = async (email: string, mdp: string) => {
        setChargement(true);
        const user = await apiLogin(email, mdp);
        setUtilisateur(user);
        setChargement(false);
    };

    const valeur = useMemo(() => ({ utilisateur, chargement, connecter }), [...]);
    return <AuthContext.Provider value={valeur}>{children}</AuthContext.Provider>;
}
// APRÈS — Migration vers Zustand (même API externe)
// stores/authStore.ts
import { create } from 'zustand';

interface AuthStore {
    utilisateur: Utilisateur | null;
    chargement:  boolean;
    connecter:   (email: string, mdp: string) => Promise<void>;
    deconnecter: () => void;
}

export const useAuthStore = create<AuthStore>((set) => ({
    utilisateur: null,
    chargement:  false,

    connecter: async (email, mdp) => {
        set({ chargement: true });
        const user = await apiLogin(email, mdp);
        set({ utilisateur: user, chargement: false });
    },

    deconnecter: () => set({ utilisateur: null }),
}));

// Hook de compatibilité pour minimiser les changements dans les composants
// Les composants qui utilisaient useAuth() n'ont PAS besoin d'être modifiés
export function useAuth() {
    return useAuthStore();
}

// Supprimer AuthProvider de main.tsx — plus besoin du Provider !
// Zustand est accessible sans Provider grâce à son store global
Migration progressive : Créez le store Zustand en parallèle du Context. Migrez un contexte à la fois (commencez par le plus simple). Utilisez un hook de compatibilité (useAuth()) pour que les composants existants n'aient pas à changer. Supprimez le Context une fois tous les composants migrés.

Checklist state management React

  • J'ai commencé par le Context API et je migre seulement si j'ai un vrai problème mesurable
  • Avec Zustand : j'utilise des sélecteurs précis (state => state.items) pour éviter les re-rendus inutiles
  • Avec RTK : j'utilise useAppSelector et useAppDispatch typés (pas les hooks bruts)
  • Les appels API sont dans le store (Zustand) ou dans un thunk (RTK) — pas dans les composants
  • Avec Zustand + persist : je spécifie partialize pour ne persister que l'état nécessaire
  • Avec RTK : je crée des sélecteurs dans le slice et je les importe dans les composants
  • Les stores Zustand sont dans src/stores/, les slices RTK dans src/features/xxx/
  • J'ai installé les Redux DevTools (browser extension) pour déboguer
  • Je n'ai pas mis de logique UI (navigation, alertes) dans le store — c'est le rôle des composants
  • TanStack Query gère le cache des données serveur — le store ne stocke que l'état UI/client
Conclusion : Zustand et Redux Toolkit sont tous les deux d'excellents choix — ils répondent à des contextes différents. Zustand brille par sa simplicité et sa rapidité de mise en place, idéal pour les projets qui veulent avancer vite sans sacrifice de qualité. Redux Toolkit excelle dans les projets d'équipe qui ont besoin de conventions strictes, d'un historique complet des actions et d'un écosystème riche (RTK Query, middleware, DevTools avancés). Le meilleur outil est celui que votre équipe maîtrise et utilise de façon cohérente.

useShallow — éviter les re-renders avec selectors multi-propriétés

Sans précaution, sélectionner plusieurs propriétés d'un store Zustand cause des re-renders à chaque changement, même si vos valeurs n'ont pas changé. useShallow (Zustand 4.4+) compare les valeurs en surface au lieu de la référence d'objet.

// ❌ Problématique — re-render à CHAQUE changement du store
function UserCard() {
    const { name, email } = useUserStore(state => ({
        name: state.name,
        email: state.email,
    }));
    // Le selector crée un nouvel objet à chaque appel = re-render systématique
    return <div>{name} ({email})</div>;
}

// ✓ Avec useShallow — re-render uniquement si name OU email change
import { useShallow } from 'zustand/react/shallow';

function UserCard() {
    const { name, email } = useUserStore(
        useShallow(state => ({ name: state.name, email: state.email }))
    );
    return <div>{name} ({email})</div>;
}

// ✓ Alternative — selectors atomiques (pas besoin de useShallow)
function UserCard() {
    const name = useUserStore(state => state.name);
    const email = useUserStore(state => state.email);
    return <div>{name} ({email})</div>;
}

Subscribe avec equality function custom

// Comparaison personnalisée — équivalent useShallow mais plus flexible
const user = useUserStore(
    state => state.user,
    (prev, next) => prev.id === next.id // re-render seulement si l'id change
);

La performance de Zustand sur de grosses applications dépend principalement de la discipline avec les selectors. Sans selectors précis, l'avantage perf vs Context API est annulé.

Middleware Zustand — persist, devtools, immer

Zustand expose des middleware pour étendre les stores avec persistance, DevTools, mutation immer-style. Composables entre eux.

persist — sauvegarde automatique localStorage

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

interface AuthStore {
    token: string | null;
    user: User | null;
    login: (token: string, user: User) => void;
    logout: () => void;
}

export const useAuthStore = create<AuthStore>()(
    persist(
        (set) => ({
            token: null,
            user: null,
            login: (token, user) => set({ token, user }),
            logout: () => set({ token: null, user: null }),
        }),
        {
            name: 'auth-storage',
            storage: createJSONStorage(() => sessionStorage),
            partialize: (state) => ({ token: state.token }), // ne persiste QUE le token
            version: 1,
            migrate: (oldState, version) => { /* migration si version change */ },
        }
    )
);

devtools — intégration Redux DevTools

import { devtools } from 'zustand/middleware';

export const useStore = create<State>()(
    devtools(
        (set) => ({ /* ... */ }),
        { name: 'MyStore' } // visible dans Redux DevTools browser extension
    )
);

// Chaque set() apparaît dans la timeline avec time-travel debugging

immer — mutation style Redux Toolkit

import { immer } from 'zustand/middleware/immer';

interface CartStore {
    items: CartItem[];
    addItem: (item: CartItem) => void;
    updateQuantity: (id: string, quantity: number) => void;
}

export const useCartStore = create<CartStore>()(
    immer((set) => ({
        items: [],
        // Mutations directes — immer crée immuabilité en background
        addItem: (item) => set((state) => { state.items.push(item) }),
        updateQuantity: (id, quantity) => set((state) => {
            const item = state.items.find(i => i.id === id);
            if (item) item.quantity = quantity;
        }),
    }))
);

Composition de middleware

// Multiple middleware s'empilent
export const useStore = create<State>()(
    devtools(
        persist(
            immer((set) => ({ /* ... */ })),
            { name: 'app-storage' }
        ),
        { name: 'AppStore' }
    )
);

Cette composition rend Zustand aussi expressif que Redux Toolkit pour les cas avancés, tout en gardant l'API simple. Le bundle reste minimal car chaque middleware est tree-shakable.

Comparaison RTK Query vs TanStack Query

Pour la gestion des données serveur, deux options principales en 2026. Le choix dépend si vous utilisez déjà Redux Toolkit ou non.

CritèreRTK QueryTanStack Query
Intégration storeNative (slice Redux)Indépendante
Bundle size~15 ko (avec RTK)~13 ko gzipped
Code generation OpenAPI✓ (rtk-query-codegen-openapi)✓ (openapi-typescript)
Suspense / use()LimitéeNative (useSuspenseQuery)
Optimistic updatesonQueryStartedonMutate + setQueryData
Cache hierarchiqueVia tagsVia queryKey arrays
Multi-frameworkReact uniquementReact/Vue/Svelte/Solid
Communauté~300k DL/sem~5M DL/sem

Recommandation 2026 : TanStack Query est le choix par défaut pour le data fetching. Plus populaire, multi-framework, meilleur support Suspense, plus de plugins communauté. RTK Query reste pertinent uniquement si vous êtes déjà profondément investi dans Redux Toolkit et voulez l'intégration native au store global.

Mini-projet appliqué — migration Redux legacy vers Zustand

Cas réel : un projet React 4 ans avec Redux legacy (mapStateToProps, mapDispatchToProps, 12 reducers, 80 actions) doit migrer vers Zustand. Voici la stratégie en 6 étapes appliquée sur un dashboard SaaS de 60k lignes.

1. Audit du store existant — inventaire des slices

Pour les patterns de typage du nouveau store, voir le guide React TypeScript.

// Audit rapide : lister tous les reducers Redux legacy
// src/store/reducers.ts
// 12 reducers identifiés :
// - authReducer (login, logout, user)
// - cartReducer (items, total, coupon)
// - notificationsReducer (toasts queue)
// - uiReducer (theme, sidebar, modals)
// - settingsReducer (preferences user)
// - filtersReducer (table filters)
// - ... 6 autres

// Grouper par domaine fonctionnel pour la migration
// Domain 1 (critique) : auth + cart → migrer en premier
// Domain 2 (UI) : ui + notifications → migrer ensuite
// Domain 3 (métier) : filters + settings → migrer en dernier

2. Créer un store Zustand par domaine — coexistence avec Redux

// stores/auth-store.ts
import { create } from 'zustand';
import { persist } from 'zustand/middleware';

type AuthState = {
    user: User | null;
    status: 'idle' | 'loading' | 'authenticated' | 'error';
    login: (email: string, password: string) => Promise<void>;
    logout: () => void;
};

export const useAuthStore = create<AuthState>()(
    persist(
        (set) => ({
            user: null,
            status: 'idle',
            login: async (email, password) => {
                set({ status: 'loading' });
                try {
                    const res = await fetch('/api/auth/login', {
                        method: 'POST',
                        body: JSON.stringify({ email, password }),
                    });
                    const { user } = await res.json();
                    set({ user, status: 'authenticated' });
                } catch (e) {
                    set({ status: 'error' });
                    throw e;
                }
            },
            logout: () => set({ user: null, status: 'idle' }),
        }),
        { name: 'auth-store', partialize: (s) => ({ user: s.user }) }
    )
);

3. Composants migrés progressivement — old + new coexistent

// AVANT — composant connecté à Redux
const Header = connect(
    (state: RootState) => ({ user: state.auth.user }),
    (dispatch) => ({ logout: () => dispatch(logoutAction()) })
)(function Header({ user, logout }) {
    return user ? <button onClick={logout}>Logout</button> : null;
});

// APRÈS — Zustand, ~70 % de boilerplate en moins
function Header() {
    const user = useAuthStore((s) => s.user);
    const logout = useAuthStore((s) => s.logout);
    return user ? <button onClick={logout}>Logout</button> : null;
}

4. Bridge Redux ↔ Zustand pendant la transition

// Quand auth est migré mais cart utilise encore Redux,
// synchroniser pour que les actions Redux puissent lire l'auth Zustand
import { store as reduxStore } from './redux-store';

useAuthStore.subscribe((state) => {
    reduxStore.dispatch({ type: 'AUTH_SYNC', payload: { user: state.user } });
});

// L'inverse aussi possible si nécessaire
reduxStore.subscribe(() => {
    const cartState = reduxStore.getState().cart;
    useUIStore.setState({ cartItemCount: cartState.items.length });
});

5. Tests de non-régression à chaque migration

Pour les patterns de test Zustand stores, voir le guide React Testing Library.

// __tests__/auth-store.test.ts
import { renderHook, act } from '@testing-library/react';
import { useAuthStore } from '../stores/auth-store';

beforeEach(() => {
    useAuthStore.setState({ user: null, status: 'idle' });
});

test('login authentifié change le status', async () => {
    fetchMock.mockResponseOnce(JSON.stringify({ user: { id: 'u1' } }));

    const { result } = renderHook(() => useAuthStore());
    await act(() => result.current.login('a@b.c', 'pass'));

    expect(result.current.status).toBe('authenticated');
    expect(result.current.user).toEqual({ id: 'u1' });
});

6. Suppression finale de Redux — nettoyage

// Étapes finales après migration des 12 slices :
// 1. Supprimer le <Provider store={reduxStore}> à la racine
// 2. npm uninstall react-redux @reduxjs/toolkit
// 3. Supprimer le dossier src/store/redux-legacy/
// 4. Supprimer les imports orphelins (eslint --fix avec import/no-unresolved)
// 5. Vérifier le bundle : -85 ko gzipped sur le projet ciblé
Bilan mesuré de la migration (60k lignes, dashboard SaaS) :
  • Durée : 3 semaines à 1 dev senior, fait progressivement sans bloquer les features
  • Bundle size : -85 ko gzipped (suppression Redux + redux-thunk + reselect + connect)
  • Code retiré : ~1800 lignes de boilerplate (mapStateToProps, action creators, switch reducers)
  • Code ajouté : ~600 lignes pour les 8 stores Zustand (10 actions/store en moyenne)
  • Re-renders : -40 % sur la home page grâce aux selectors granulaires Zustand
Pour la gestion du data fetching (RTK Query → TanStack Query), suivre le guide TanStack Query.

Questions fréquentes

Zustand est minimaliste : un store se crée en 5 lignes, sans actions, reducers ni boilerplate. L'état est muté directement avec Immer intégré. Redux Toolkit (RTK) est plus structuré : slices, actions typées, RTK Query pour les données serveur, DevTools avancés avec time-travel debugging. Zustand pèse ~1 Ko gzippé, RTK ~11 Ko. Zustand n'impose pas de pattern de flux unidirectionnel strict — vous pouvez mettre la logique où vous voulez. RTK impose des conventions que les grandes équipes apprécient pour la cohérence. Zustand supporte nativement Suspense et les Concurrent Features React.

Définissez l'interface du store et créez-le avec `create()` : `interface CounterStore { count: number; increment: () => void; decrement: () => void; reset: () => void } const useCounterStore = create((set) => ({ count: 0, increment: () => set((state) => ({ count: state.count + 1 })), decrement: () => set((state) => ({ count: state.count - 1 })), reset: () => set({ count: 0 }) }))`. Dans le composant : `const { count, increment } = useCounterStore()`. Zustand se connecte aux React DevTools via `create(devtools(...))`. Le middleware `persist` sérialise dans localStorage automatiquement.

Choisissez Redux Toolkit pour : les grandes équipes (10+ développeurs) qui bénéficient des conventions RTK et du time-travel debugging, les applications avec des flux de données complexes impliquant des side effects coordonnés (RTK Query + cache invalidation), la conformité aux audits de code qui reconnaissent Redux comme standard industriel, et les équipes déjà familières avec Redux. Choisissez Zustand pour : les projets de taille petite à moyenne, les équipes qui valorisent la rapidité de développement, les applications mobiles React Native où la taille du bundle compte, et les équipes voulant éviter la courbe d'apprentissage Redux.

Migrez store par store pour limiter les risques. Pour chaque Redux slice, créez un Zustand store équivalent. Les `createSlice` reducers deviennent des fonctions dans le store Zustand : `incrementBy: (amount: number) => set(state => ({ count: state.count + amount }))`. Remplacez `useSelector` par le hook Zustand sélecteur : `useSelector(state => state.counter.count)` devient `useCounterStore(state => state.count)`. Remplacez `useDispatch` + action creators par les méthodes directes du store. RTK Query remplacez-le par TanStack Query (équivalent pour les données serveur). Maintenez les deux solutions en parallèle pendant la migration pour les vieux et nouveaux composants.

Par défaut, Zustand re-render le composant si N'IMPORTE QUELLE propriété du store change. Pour limiter aux propriétés utilisées, passez un selector : `const count = useCounterStore(state => state.count)`. Seul un changement de `state.count` déclenche le re-render. Pour sélectionner plusieurs valeurs, utilisez `useShallow` (Zustand 4.4+) : `const { count, name } = useCounterStore(useShallow(s => ({ count: s.count, name: s.name })))`. Sans `useShallow`, créer un objet à chaque appel = re-render à chaque changement. C'est l'avantage clé de Zustand sur Context API.

Pattern recommandé en 2026 : Zustand pour l'état CLIENT (UI, formulaires, navigation, préférences), TanStack Query pour l'état SERVEUR (données API, cache, sync). Ne mélangez pas les deux. TanStack Query gère le caching, la dédup, le refetch automatique, l'invalidation — réinventer ça dans Zustand est une erreur classique. Zustand garde la session utilisateur, le panier (état temporaire avant checkout), les modales ouvertes. TanStack Query récupère la liste produits, le profil user. Frontière claire = code maintenable.

Jotai est un autre store React minimaliste mais avec une approche différente : atomic state management. Chaque morceau d'état est un "atom" autonome, et les composants s'abonnent atom par atom. Avantages sur Zustand : granularité maximale (zéro re-render inutile par défaut, pas besoin de selectors). Inconvénients : plus de boilerplate pour créer chaque atom, modèle mental moins intuitif. Zustand reste plus populaire (3M+ téléchargements/semaine vs 800k pour Jotai), mais Jotai est supérieur pour les apps avec beaucoup d'états atomiques indépendants (form builders, dashboards configurables).

Middleware persist + partialize. import { persist } from "zustand/middleware". create(persist((set) => ({...}), { name: "my-store", partialize: (s) => ({ user: s.user, prefs: s.prefs }) })). partialize sélectionne uniquement les champs à persister — ignore les états transients (loading, errors, modals open). Storage par défaut : localStorage. Pour sessionStorage : storage: createJSONStorage(() => sessionStorage). Pour IndexedDB (gros volumes) : storage: createJSONStorage(() => indexedDBStorage). Pattern critique pour les apps SaaS : persist uniquement les données qui doivent SURVIVRE au refresh — pas les états UI éphémères. Voir le guide des solutions de stockage pour le choix de storage.

Middleware devtools intégré. import { devtools } from "zustand/middleware". create(devtools((set) => ({...}), { name: "my-store", enabled: process.env.NODE_ENV === "development" })). Active la time-travel dans Redux DevTools (extension Chrome/Firefox). Bonnes pratiques : (1) Nommer chaque set pour voir une action lisible : set({ count: c + 1 }, false, "increment"). (2) Désactiver en prod (enabled: false) pour ne pas exposer le state. (3) Pour les actions async, wrap dans un nom : await fetchData(); set({ data }, false, "fetchData/success"). (4) Combine avec persist : create(devtools(persist(...))) — l'ordre compte. Avec ce setup, Zustand est aussi inspectable que Redux mais sans son boilerplate.

Règle stricte : pas de chevauchement. État CLIENT (Zustand/Redux) : UI ephémère (modal ouvert, sidebar collapsée), formulaires en cours, préférences utilisateur, navigation. État SERVEUR (TanStack Query) : tout ce qui vient d'une API (users, products, orders). Pourquoi : (1) cache automatique, (2) refetch background, (3) deduplication, (4) invalidation après mutation. Anti-pattern fréquent : stocker la liste des users récupérée via fetch dans Zustand → vous réimplémentez à la main ce que TanStack fait gratuitement. Frontière claire = -50 % de bugs de sync. Voir le guide TanStack Query.

Partager