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
| 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
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ère | RTK Query | TanStack Query |
|---|---|---|
| Intégration store | Native (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ée | Native (useSuspenseQuery) |
| Optimistic updates | onQueryStarted | onMutate + setQueryData |
| Cache hierarchique | Via tags | Via queryKey arrays |
| Multi-framework | React uniquement | React/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é
- 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