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 (
);
}
// src/components/ProductCard.tsx
function ProductCard({ product }: { product: Product }) {
const { addItem } = useCart();
return (
### {product.name}
{product.price}€
addItem(product)}>
Ajouter au panier
);
}
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
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 (
);
}
// src/components/ProductCard.tsx
function ProductCard({ product }: { product: Product }) {
// Sélectionner uniquement la fonction
const addItem = useCartStore(state => state.addItem);
return (
### {product.name}
addItem(product)}>
Ajouter
);
}
// 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}
removeItem(item.id)}>×
))}
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;
}
})
}))
);
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
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;
// 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 (
);
}
// 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}
dispatch(addItem(product))}>
Ajouter
);
}
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}
deleteProduct(product.id)}
disabled={isDeleting}
>
Supprimer
))}
);
}
// 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
// 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);
// 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}
addItem({ id: 1, name: 'Test', price: 10 })}>
Add
);
}
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');
});
});
// 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
Related Articles Nextcloud installé dans un LXC Proxmox, mais lent et sans optimisations. Voici ce que j'ai fait pour passer d'une instance poussive à un setup qui tient la route : Redis, APCu, preview generator, Collabora, NFS et maintenance automatisée.
Le monitoring sans alertes, c'est un dashboard que tu regardes uniquement quand tu penses à…
PBS tourne depuis plusieurs mois sur heighliner et je me félicitais d'avoir mes sauvegardes bien…
Articles similaires
· · ·
À propos de l'auteur
Pascal
Pascal — SRE & Observability Engineer. J'applique les pratiques SRE (SLI/SLO, alerting, observabilité) à mon homelab Proxmox, avec les mêmes exigences qu'en production.