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
| 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>
);
}
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);
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 |
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
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
useAppSelectoretuseAppDispatchtypé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
partializepour 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 danssrc/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