Intermediaire 12 min de lecture · 2 502 mots

State Management : Redux Toolkit vs Zustand vs Context API

Estimated reading time: 12 minutes

Introduction

La gestion d’état est cruciale dans les applications React modernes. En 2025, trois solutions dominent : Redux Toolkit (le standard de l’industrie), Zustand (léger et moderne), et Context API (natif React). Découvrons leurs forces et faiblesses.

1. Context API : La solution native

Setup basique

// src/contexts/CartContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';

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

interface CartContextType {
  items: CartItem[];
  addItem: (item: Omit) => void;
  removeItem: (id: number) => void;
  updateQuantity: (id: number, quantity: number) => void;
  total: number;
  itemCount: number;
}

const CartContext = createContext(undefined);

export function CartProvider({ children }: { children: ReactNode }) {
  const [items, setItems] = useState([]);

  const addItem = (item: Omit) => {
    setItems(current => {
      const existing = current.find(i => i.id === item.id);
      if (existing) {
        return current.map(i =>
          i.id === item.id ? { ...i, quantity: i.quantity + 1 } : i
        );
      }
      return [...current, { ...item, quantity: 1 }];
    });
  };

  const removeItem = (id: number) => {
    setItems(current => current.filter(i => i.id !== id));
  };

  const updateQuantity = (id: number, quantity: number) => {
    if (quantity <= 0) {
      removeItem(id);
      return;
    }
    setItems(current =>
      current.map(i => (i.id === id ? { ...i, quantity } : i))
    );
  };

  const total = items.reduce((sum, item) => sum + item.price  item.quantity, 0);
  const itemCount = items.reduce((sum, item) => sum + item.quantity, 0);

  const value = {
    items,
    addItem,
    removeItem,
    updateQuantity,
    total,
    itemCount
  };

  return {children};
}

export function useCart() {
  const context = useContext(CartContext);
  if (!context) {
    throw new Error('useCart must be used within CartProvider');
  }
  return context;
}

Utilisation

// src/App.tsx
import { CartProvider } from './contexts/CartContext';

function App() {
  return (
    
      
); } // src/components/Header.tsx import { useCart } from '@/contexts/CartContext'; function Header() { const { itemCount } = useCart(); return (
# Mon Shop
Panier: {itemCount} articles
); } // src/components/ProductCard.tsx function ProductCard({ product }: { product: Product }) { const { addItem } = useCart(); return (
### {product.name}

{product.price}€

); }

Optimisation : Éviter les re-renders

// ❌ Problème : tous les consommateurs re-render
const CartContext = createContext({ items: [], addItem: () => {} });

// ✅ Solution 1 : Séparer les contexts
const CartItemsContext = createContext([]);
const CartActionsContext = createContext(undefined);

export function CartProvider({ children }: { children: ReactNode }) {
  const [items, setItems] = useState([]);

  const actions = useMemo(() => ({
    addItem: (item: CartItem) => setItems(prev => [...prev, item]),
    removeItem: (id: number) => setItems(prev => prev.filter(i => i.id !== id))
  }), []);

  return (
    
      
        {children}
      
    
  );
}

// ✅ Solution 2 : useContextSelector (avec use-context-selector)
import { createContext } from 'use-context-selector';

const CartContext = createContext(undefined);

function Header() {
  // Ne re-render que si itemCount change
  const itemCount = useContextSelector(CartContext, v => v.itemCount);
  return 
Panier: {itemCount}
; }

Avantages et inconvénients

Avantages :

  • Natif React, pas de dépendance
  • Simple pour des états simples
  • TypeScript friendly
  • Parfait pour le theming, i18n, auth
  • Inconvénients :

  • Performance : re-render tous les consommateurs
  • Pas de DevTools
  • Pas de middleware
  • Devient verbeux pour des états complexes
  • 2. Zustand : Simplicité et performance

    Installation

npm install zustand

Store basique

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

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

interface CartStore {
  items: CartItem[];
  addItem: (item: Omit) => void;
  removeItem: (id: number) => void;
  updateQuantity: (id: number, quantity: number) => void;
  clearCart: () => void;
  total: () => number;
  itemCount: () => number;
}

export const useCartStore = create((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, quantity: 1 }] };
  }),

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

  updateQuantity: (id, quantity) => set((state) => ({
    items: state.items.map(i =>
      i.id === id ? { ...i, quantity } : i
    )
  })),

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

  total: () => {
    const { items } = get();
    return items.reduce((sum, item) => sum + item.price  item.quantity, 0);
  },

  itemCount: () => {
    const { items } = get();
    return items.reduce((sum, item) => sum + item.quantity, 0);
  }
}));

Utilisation

// src/components/Header.tsx
import { useCartStore } from '@/stores/cartStore';

function Header() {
  // Sélecteur : ne re-render que si itemCount change
  const itemCount = useCartStore(state => state.itemCount());

  return (
    
# Mon Shop
Panier: {itemCount} articles
); } // src/components/ProductCard.tsx function ProductCard({ product }: { product: Product }) { // Sélectionner uniquement la fonction const addItem = useCartStore(state => state.addItem); return (
### {product.name}
); } // src/components/Cart.tsx function Cart() { const { items, removeItem, total } = useCartStore(state => ({ items: state.items, removeItem: state.removeItem, total: state.total() })); return (
{items.map(item => (
{item.name} {item.quantity}
))}
Total: {total}€
); }

Middleware : Persistence

// src/stores/cartStore.ts
import { create } from 'zustand';
import { persist, createJSONStorage } from 'zustand/middleware';

export const useCartStore = create()(
  persist(
    (set, get) => ({
      items: [],
      addItem: (item) => set((state) => ({ / ... / })),
      // ... autres actions
    }),
    {
      name: 'cart-storage', // clé localStorage
      storage: createJSONStorage(() => localStorage),
    }
  )
);

Middleware : Immer (mutations immuables)

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

export const useCartStore = create()(
  immer((set) => ({
    items: [],

    // ✅ Mutations directes grâce à Immer
    addItem: (item) => set((state) => {
      const existing = state.items.find(i => i.id === item.id);
      if (existing) {
        existing.quantity += 1;
      } else {
        state.items.push({ ...item, quantity: 1 });
      }
    }),

    updateQuantity: (id, quantity) => set((state) => {
      const item = state.items.find(i => i.id === id);
      if (item) {
        item.quantity = quantity;
      }
    })
  }))
);

Middleware : DevTools

import { devtools } from 'zustand/middleware';

export const useCartStore = create()(
  devtools(
    persist(
      immer((set) => ({
        // ... store
      })),
      { name: 'cart-storage' }
    ),
    { name: 'CartStore' }
  )
);

Slices pattern (organisation)

// src/stores/slices/userSlice.ts
export interface UserSlice {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
}

export const createUserSlice = (set: any): UserSlice => ({
  user: null,
  login: (user) => set({ user }),
  logout: () => set({ user: null })
});

// src/stores/slices/cartSlice.ts
export const createCartSlice = (set: any): CartSlice => ({
  items: [],
  addItem: (item) => set((state: any) => ({ / ... / }))
});

// src/stores/index.ts
import { create } from 'zustand';
import { createUserSlice } from './slices/userSlice';
import { createCartSlice } from './slices/cartSlice';

type StoreState = UserSlice & CartSlice;

export const useStore = create()((...a) => ({
  ...createUserSlice(...a),
  ...createCartSlice(...a)
}));

Avantages et inconvénients

Avantages :

  • Très léger (~1kb)
  • API simple et intuitive
  • Excellent pour la performance (sélecteurs)
  • Middleware riches (persist, devtools, immer)
  • TypeScript excellent
  • Pas de Provider nécessaire
  • Inconvénients :

  • Moins de structure que Redux
  • Communauté plus petite
  • Moins de middleware tiers
  • 3. Redux Toolkit : Le standard de l’industrie

    Installation

    npm install @reduxjs/toolkit react-redux
    

    Store configuration

    // src/store/index.ts
    import { configureStore } from '@reduxjs/toolkit';
    import cartReducer from './slices/cartSlice';
    import userReducer from './slices/userSlice';
    
    export const store = configureStore({
      reducer: {
        cart: cartReducer,
        user: userReducer
      },
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware({
          serializableCheck: false
        })
    });
    
    export type RootState = ReturnType;
    export type AppDispatch = typeof store.dispatch;
    

    Slice avec Redux Toolkit

    // src/store/slices/cartSlice.ts
    import { createSlice, PayloadAction } from '@reduxjs/toolkit';
    
    interface CartItem {
      id: number;
      name: string;
      price: number;
      quantity: number;
    }
    
    interface CartState {
      items: CartItem[];
      loading: boolean;
      error: string | null;
    }
    
    const initialState: CartState = {
      items: [],
      loading: false,
      error: null
    };
    
    const cartSlice = createSlice({
      name: 'cart',
      initialState,
      reducers: {
        addItem: (state, action: PayloadAction>) => {
          const existing = state.items.find(i => i.id === action.payload.id);
          if (existing) {
            existing.quantity += 1;
          } else {
            state.items.push({ ...action.payload, quantity: 1 });
          }
        },
    
        removeItem: (state, action: PayloadAction) => {
          state.items = state.items.filter(i => i.id !== action.payload);
        },
    
        updateQuantity: (state, action: PayloadAction<{ id: number; quantity: number }>) => {
          const item = state.items.find(i => i.id === action.payload.id);
          if (item) {
            item.quantity = action.payload.quantity;
          }
        },
    
        clearCart: (state) => {
          state.items = [];
        }
      }
    });
    
    export const { addItem, removeItem, updateQuantity, clearCart } = cartSlice.actions;
    export default cartSlice.reducer;
    
    // Selectors
    export const selectCartItems = (state: RootState) => state.cart.items;
    export const selectCartTotal = (state: RootState) =>
      state.cart.items.reduce((sum, item) => sum + item.price  item.quantity, 0);
    export const selectItemCount = (state: RootState) =>
      state.cart.items.reduce((sum, item) => sum + item.quantity, 0);
    

    Hooks typés

    // src/store/hooks.ts
    import { TypedUseSelectorHook, useDispatch, useSelector } from 'react-redux';
    import type { RootState, AppDispatch } from './index';
    
    export const useAppDispatch = () => useDispatch();
    export const useAppSelector: TypedUseSelectorHook = useSelector;
    

    Utilisation dans les composants

    // src/App.tsx
    import { Provider } from 'react-redux';
    import { store } from './store';
    
    function App() {
      return (
        
          
    ); } // src/components/Header.tsx import { useAppSelector } from '@/store/hooks'; import { selectItemCount } from '@/store/slices/cartSlice'; function Header() { const itemCount = useAppSelector(selectItemCount); return (
    # Mon Shop
    Panier: {itemCount} articles
    ); } // src/components/ProductCard.tsx import { useAppDispatch } from '@/store/hooks'; import { addItem } from '@/store/slices/cartSlice'; function ProductCard({ product }: { product: Product }) { const dispatch = useAppDispatch(); return (
    ### {product.name}
    ); }

    Async Thunks

    // src/store/slices/productsSlice.ts
    import { createSlice, createAsyncThunk } from '@reduxjs/toolkit';
    
    interface Product {
      id: number;
      name: string;
      price: number;
    }
    
    interface ProductsState {
      items: Product[];
      loading: boolean;
      error: string | null;
    }
    
    const initialState: ProductsState = {
      items: [],
      loading: false,
      error: null
    };
    
    // Async thunk
    export const fetchProducts = createAsyncThunk(
      'products/fetchProducts',
      async (category?: string) => {
        const url = category
          ? /api/products?category=${category}
          : '/api/products';
    
        const response = await fetch(url);
        if (!response.ok) {
          throw new Error('Failed to fetch products');
        }
        return response.json();
      }
    );
    
    const productsSlice = createSlice({
      name: 'products',
      initialState,
      reducers: {},
      extraReducers: (builder) => {
        builder
          .addCase(fetchProducts.pending, (state) => {
            state.loading = true;
            state.error = null;
          })
          .addCase(fetchProducts.fulfilled, (state, action) => {
            state.loading = false;
            state.items = action.payload;
          })
          .addCase(fetchProducts.rejected, (state, action) => {
            state.loading = false;
            state.error = action.error.message || 'An error occurred';
          });
      }
    });
    
    export default productsSlice.reducer;
    

    RTK Query : Data fetching

    // src/store/api/productsApi.ts
    import { createApi, fetchBaseQuery } from '@reduxjs/toolkit/query/react';
    
    interface Product {
      id: number;
      name: string;
      price: number;
    }
    
    export const productsApi = createApi({
      reducerPath: 'productsApi',
      baseQuery: fetchBaseQuery({ baseUrl: '/api' }),
      tagTypes: ['Products'],
      endpoints: (builder) => ({
        getProducts: builder.query({
          query: () => 'products',
          providesTags: ['Products']
        }),
    
        getProduct: builder.query({
          query: (id) => products/${id},
          providesTags: (result, error, id) => [{ type: 'Products', id }]
        }),
    
        createProduct: builder.mutation>({
          query: (body) => ({
            url: 'products',
            method: 'POST',
            body
          }),
          invalidatesTags: ['Products']
        }),
    
        updateProduct: builder.mutation({
          query: ({ id, ...body }) => ({
            url: products/${id},
            method: 'PUT',
            body
          }),
          invalidatesTags: (result, error, { id }) => [{ type: 'Products', id }]
        }),
    
        deleteProduct: builder.mutation({
          query: (id) => ({
            url: products/${id},
            method: 'DELETE'
          }),
          invalidatesTags: ['Products']
        })
      })
    });
    
    export const {
      useGetProductsQuery,
      useGetProductQuery,
      useCreateProductMutation,
      useUpdateProductMutation,
      useDeleteProductMutation
    } = productsApi;
    
    // Ajouter à configureStore
    export const store = configureStore({
      reducer: {
        cart: cartReducer,
        [productsApi.reducerPath]: productsApi.reducer
      },
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware().concat(productsApi.middleware)
    });
    

    Utilisation RTK Query

    // src/components/ProductList.tsx
    import {
      useGetProductsQuery,
      useDeleteProductMutation
    } from '@/store/api/productsApi';
    
    function ProductList() {
      const { data: products, isLoading, error } = useGetProductsQuery();
      const [deleteProduct, { isLoading: isDeleting }] = useDeleteProductMutation();
    
      if (isLoading) return 
    Chargement...
    ; if (error) return
    Erreur
    ; return (
    {products?.map(product => (
    ### {product.name}
    ))}
    ); } // Préchargement optimiste function ProductCard({ productId }: { productId: number }) { const { data: product } = useGetProductQuery(productId); return
    {product?.name}
    ; }

    Redux Persist

    // src/store/index.ts
    import { configureStore } from '@reduxjs/toolkit';
    import {
      persistStore,
      persistReducer,
      FLUSH,
      REHYDRATE,
      PAUSE,
      PERSIST,
      PURGE,
      REGISTER
    } from 'redux-persist';
    import storage from 'redux-persist/lib/storage';
    import cartReducer from './slices/cartSlice';
    
    const persistConfig = {
      key: 'root',
      storage,
      whitelist: ['cart'] // Seulement persister le cart
    };
    
    const persistedCartReducer = persistReducer(persistConfig, cartReducer);
    
    export const store = configureStore({
      reducer: {
        cart: persistedCartReducer,
        user: userReducer
      },
      middleware: (getDefaultMiddleware) =>
        getDefaultMiddleware({
          serializableCheck: {
            ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
          }
        })
    });
    
    export const persistor = persistStore(store);
    
    // src/App.tsx
    import { PersistGate } from 'redux-persist/integration/react';
    import { store, persistor } from './store';
    
    function App() {
      return (
        
          
            
          
        
      );
    }
    

    Avantages et inconvénients

    Avantages :

  • Standard de l’industrie
  • Écosystème massif
  • DevTools puissants
  • RTK Query (data fetching intégré)
  • Excellente documentation
  • Time-travel debugging
  • Middleware riches
  • Inconvénients :

  • Plus verbeux que Zustand
  • Courbe d’apprentissage
  • Boilerplate (réduit avec RTK)
  • Plus lourd (~12kb)
  • 4. Comparaison et benchmarks

    Taille du bundle

    Solution Taille min+gzip Impact
    Context API 0kb Natif React
    Zustand 1.1kb Minimal
    Redux Toolkit 12kb Moyen
    MobX 16kb Élevé
    Recoil 14kb Moyen

    Performance (1000 re-renders)

    // Benchmark code
    function benchmark() {
      console.time('Context API');
      for (let i = 0; i < 1000; i++) {
        // Update context
      }
      console.timeEnd('Context API'); // ~450ms
    
      console.time('Zustand');
      for (let i = 0; i < 1000; i++) {
        // Update zustand
      }
      console.timeEnd('Zustand'); // ~85ms
    
      console.time('Redux Toolkit');
      for (let i = 0; i < 1000; i++) {
        // Dispatch action
      }
      console.timeEnd('Redux Toolkit'); // ~120ms
    }
    
    Solution Temps (ms) Re-renders
    Context API 450 Tous les consommateurs
    Zustand 85 Seulement si sélecteur change
    Redux Toolkit 120 Avec selectors optimisés

    Cas d'usage recommandés

    // ✅ Context API : Pour
    // - Thème
    // - Internationalisation (i18n)
    // - Authentification (simple)
    // - État UI partagé simple
    
    // ✅ Zustand : Pour
    // - Applications petites à moyennes
    // - État global simple
    // - Performance critique
    // - Besoin de simplicité
    
    // ✅ Redux Toolkit : Pour
    // - Applications grandes/complexes
    // - État global complexe
    // - Data fetching centralisé (RTK Query)
    // - Besoin de DevTools avancés
    // - Middleware custom
    // - Time-travel debugging
    

    5. Migration entre solutions

    Context API → Zustand

    // Avant (Context API)
    const CartContext = createContext(undefined);
    
    export function CartProvider({ children }: { children: ReactNode }) {
      const [items, setItems] = useState([]);
      // ...
      return {children};
    }
    
    // Après (Zustand)
    export const useCartStore = create((set) => ({
      items: [],
      addItem: (item) => set((state) => ({ / ... / }))
    }));
    
    // Migration du code
    // Avant
    const { items, addItem } = useCart();
    
    // Après
    const { items, addItem } = useCartStore();
    // Ou avec sélecteur
    const items = useCartStore(state => state.items);
    const addItem = useCartStore(state => state.addItem);
    

    Zustand → Redux Toolkit

    // Avant (Zustand)
    export const useCartStore = create((set) => ({
      items: [],
      addItem: (item) => set((state) => ({ / ... */ }))
    }));
    
    // Après (Redux Toolkit)
    const cartSlice = createSlice({
      name: 'cart',
      initialState: { items: [] },
      reducers: {
        addItem: (state, action) => {
          // ...
        }
      }
    });
    
    // Migration du code
    // Avant
    const addItem = useCartStore(state => state.addItem);
    
    // Après
    const dispatch = useAppDispatch();
    dispatch(addItem(product));
    

    6. Tests

    Tester Context API

    // CartContext.test.tsx
    import { render, screen, fireEvent } from '@testing-library/react';
    import { CartProvider, useCart } from './CartContext';
    
    function TestComponent() {
      const { items, addItem } = useCart();
      return (
        
    {items.length}
    ); } describe('CartContext', () => { it('adds items to cart', () => { render( ); expect(screen.getByTestId('count')).toHaveTextContent('0'); fireEvent.click(screen.getByText('Add')); expect(screen.getByTestId('count')).toHaveTextContent('1'); }); });

    Tester Zustand

    // cartStore.test.ts
    import { renderHook, act } from '@testing-library/react';
    import { useCartStore } from './cartStore';
    
    describe('cartStore', () => {
      beforeEach(() => {
        useCartStore.setState({ items: [] });
      });
    
      it('adds item to cart', () => {
        const { result } = renderHook(() => useCartStore());
    
        act(() => {
          result.current.addItem({ id: 1, name: 'Test', price: 10 });
        });
    
        expect(result.current.items).toHaveLength(1);
        expect(result.current.items[0].name).toBe('Test');
      });
    });
    

    Tester Redux Toolkit

    // cartSlice.test.ts
    import cartReducer, { addItem, removeItem } from './cartSlice';
    
    describe('cartSlice', () => {
      const initialState = { items: [], loading: false, error: null };
    
      it('adds item', () => {
        const item = { id: 1, name: 'Test', price: 10 };
        const state = cartReducer(initialState, addItem(item));
    
        expect(state.items).toHaveLength(1);
        expect(state.items[0].quantity).toBe(1);
      });
    
      it('removes item', () => {
        const state = {
          items: [{ id: 1, name: 'Test', price: 10, quantity: 1 }],
          loading: false,
          error: null
        };
    
        const newState = cartReducer(state, removeItem(1));
    
        expect(newState.items).toHaveLength(0);
      });
    });
    

    Recommandations 2025

    Choix par taille de projet

    // Petit projet (< 10 composants)
    // → Context API
    
    // Projet moyen (10-50 composants)
    // → Zustand
    
    // Grand projet (> 50 composants)
    // → Redux Toolkit
    
    // Projet avec beaucoup de data fetching
    // → Redux Toolkit + RTK Query
    // ou TanStack Query + Zustand
    

    Combinaisons recommandées

    // Context API (auth) + Zustand (cart)
    const App = () => (
      
        
      
    );
    
    // Redux Toolkit + TanStack Query
    // Redux pour l'état global
    // TanStack Query pour le data fetching
    
    // Zustand + TanStack Query (moderne et léger)
    export const useCartStore = create(...);
    export const useProducts = () => useQuery(['products'], fetchProducts);
    

    Conclusion

    Chaque solution a ses forces :

  • Context API : Simple, natif, parfait pour des états simples
  • Zustand : Léger, performant, excellent développeur experience
  • Redux Toolkit : Robuste, scalable, standard de l'industrie
  • Choisissez selon

  • Taille du projet
  • Complexité de l'état
  • Besoins en DevTools
  • Expérience de l'équipe
  • Performance requise
  • Ressources

  • Redux Toolkit Documentation
  • Zustand Documentation
  • React Context Documentation
  • RTK Query
  • Une remarque, un retour ?

    Cet article est vivant — corrections, contre-arguments et retours de production sont les bienvenus. Trois canaux, choisissez celui qui vous convient.