Zustand vs Redux Toolkit : state management React

Front-end 25/03/2026 21:00:00 angularforall.com
React Zustand Redux Toolkit State Management Store
Zustand vs Redux Toolkit : state management React

Comparez Zustand et Redux Toolkit pour gérer l'état global React. Exemples concrets, critères de choix et migration depuis Context API.

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.

Partager