Pourquoi Zustand est si performant ? Le secret des re-renders optimisés
Table des matières
Introduction : Le problème avec Context API
Vous utilisez encore Context API pour gérer l’état global dans React ?
Voici pourquoi Zustand change la donne en termes de performance. ⚡
Avec useContext, TOUS les composants qui consomment ce contexte se re-rendent à chaque changement d’état, même si seule une petite partie a changé.

Le problème en détail
Imaginons un contexte qui gère à la fois l’authentification, les préférences utilisateur et les notifications. Si vous mettez à jour uniquement les notifications, tous les composants qui utilisent ce contexte (même ceux qui n’utilisent que l’authentification) vont se re-rendre inutilement.
// ❌ PROBLÈME : Tous les composants se re-rendent
const AppContext = createContext({
user: null,
notifications: [],
preferences: {}
});
// Même si seul 'notifications' change,
// tous les composants utilisant ce contexte se re-rendent
Comment Zustand résout ce problème
Zustand utilise useSyncExternalStoreWithSelector (React 18+) qui permet de s’abonner uniquement à la partie de l’état qui vous intéresse.
Architecture externe
Contrairement à Context API, les stores Zustand sont externes à l’arbre React. Les composants s’abonnent directement au store via des hooks, ce qui évite le problème “all-or-nothing” du Context.
Exemple concret : Gestion de la CAN (Coupe d’Afrique)
Imaginons un store pour gérer la Coupe d’Afrique avec les équipes (Sénégal 🇸🇳, Maroc 🇲🇦), les joueurs et les scores.

import { create } from 'zustand';
interface Team {
id: string;
name: string;
flag: string;
players: string[];
}
interface Match {
id: string;
team1: string;
team2: string;
score1: number;
score2: number;
}
interface CANStore {
teams: Team[];
matches: Match[];
selectedTeam: string | null;
setSelectedTeam: (teamId: string | null) => void;
updateScore: (matchId: string, score1: number, score2: number) => void;
addTeam: (team: Team) => void;
}
const useCANStore = create<CANStore>((set) => ({
teams: [
{ id: 'sn', name: 'Sénégal', flag: '🇸🇳', players: ['Sadio Mané', 'Kalidou Koulibaly'] },
{ id: 'ma', name: 'Maroc', flag: '🇲🇦', players: ['Achraf Hakimi', 'Yassine Bounou'] }
],
matches: [],
selectedTeam: null,
setSelectedTeam: (teamId) => set({ selectedTeam: teamId }),
updateScore: (matchId, score1, score2) =>
set((state) => ({
matches: state.matches.map(m =>
m.id === matchId ? { ...m, score1, score2 } : m
)
})),
addTeam: (team) =>
set((state) => ({ teams: [...state.teams, team] }))
}));
Utilisation optimale avec selectors

✅ BON : Utilisation avec selector
// ✅ Seul ce composant se re-render si 'selectedTeam' change
function TeamSelector() {
const selectedTeam = useCANStore((state) => state.selectedTeam);
return <div>Équipe sélectionnée : {selectedTeam}</div>;
}
// ✅ Seul ce composant se re-render si 'teams' change
function TeamsList() {
const teams = useCANStore((state) => state.teams);
return (
<ul>
{teams.map(team => (
<li key={team.id}>{team.flag} {team.name}</li>
))}
</ul>
);
}
// ✅ Seul ce composant se re-render si 'matches' change
function MatchesList() {
const matches = useCANStore((state) => state.matches);
return (
<ul>
{matches.map(match => (
<li>{match.team1} {match.score1} - {match.score2} {match.team2}</li>
))}
</ul>
);
}
❌ MAUVAIS : Utilisation sans selector
// ❌ Ce composant se re-render pour TOUS les changements du store
function TeamSelector() {
const store = useCANStore(); // Pas de selector !
return <div>Équipe : {store.selectedTeam}</div>;
}
Comment ça fonctionne en interne ?
1️⃣ Subscription sélective
Zustand utilise useSyncExternalStoreWithSelector qui permet de s’abonner uniquement à la valeur retournée par votre selector.

// Code interne simplifié de Zustand
function useStore<T, U>(
store: StoreApi<T>,
selector: (state: T) => U
) {
return useSyncExternalStoreWithSelector(
store.subscribe, // Fonction de subscription
store.getState, // Fonction pour obtenir l'état
store.getServerState, // Pour SSR
selector, // Selector pour filtrer l'état
equalityFn // Fonction de comparaison (=== par défaut)
);
}
Comment ça marche ?
- Le composant s’abonne au store avec un selector
- Quand l’état change, Zustand compare la nouvelle valeur du selector avec l’ancienne valeur
- Si elles sont identiques (avec
===), pas de re-render - Si elles sont différentes, re-render uniquement ce composant
2️⃣ Égalité référentielle
Par défaut, Zustand compare avec ===. Si votre selector retourne une nouvelle référence à chaque fois, il y aura re-render.

// ❌ PROBLÈME : Retourne un nouvel objet à chaque fois
const teams = useCANStore((state) => ({
count: state.teams.length,
names: state.teams.map(t => t.name)
})); // Nouvelle référence à chaque render !
// ✅ SOLUTION 1 : Selector simple
const teamCount = useCANStore((state) => state.teams.length);
// ✅ SOLUTION 2 : useShallow pour comparer le contenu
import { useShallow } from 'zustand/react/shallow';
const teams = useCANStore(
useShallow((state) => ({
count: state.teams.length,
names: state.teams.map(t => t.name)
}))
); // Compare le contenu, pas la référence
3️⃣ Pas de Context Provider
Contrairement à Context API, Zustand stores sont externes à l’arbre React. Les composants s’abonnent directement au store via hooks, ce qui évite le problème “all-or-nothing” du Context.
Avantages :
- ✅ Pas besoin d’envelopper votre app dans un Provider
- ✅ Pas de problème de re-renders en cascade
- ✅ Stores accessibles depuis n’importe où dans votre code
- ✅ Meilleure performance globale
Pourquoi c’est important ?
⚡ Performance
Moins de re-renders = application plus rapide. Dans une application complexe avec des centaines de composants, cette optimisation peut faire la différence entre une app fluide et une app qui lag.
📈 Scalabilité
Votre app peut grandir sans ralentir. Avec Context API, plus vous avez de composants, plus les re-renders deviennent coûteux. Avec Zustand, seuls les composants concernés se re-rendent.
🎯 Simplicité
Pas besoin de Context Providers partout. Un store Zustand est accessible depuis n’importe quel composant sans configuration supplémentaire.
🔒 TypeScript
Type-safe de bout en bout. Zustand offre une excellente intégration TypeScript avec inférence de types automatique.
// TypeScript infère automatiquement les types
const selectedTeam = useCANStore((state) => state.selectedTeam);
// selectedTeam est de type: string | null
const teams = useCANStore((state) => state.teams);
// teams est de type: Team[]
💾 Persistence
Middleware persist intégré pour localStorage, sessionStorage, ou même des solutions personnalisées.
import { persist } from 'zustand/middleware';
const useCANStore = create<CANStore>()(
persist(
(set) => ({
// ... votre store
}),
{
name: 'can-storage', // Clé dans localStorage
}
)
);
Comparaison avec d’autres solutions
vs Redux
- Zustand : Plus simple, moins de boilerplate, meilleure performance par défaut
- Redux : Plus de fonctionnalités (DevTools avancés, middleware ecosystem), mais plus complexe
vs Context API
- Zustand : Re-renders sélectifs, pas de Provider nécessaire
- Context API : Intégré à React, mais problème de re-renders en cascade
vs Jotai / Recoil
- Zustand : Store unique, plus simple mentalement
- Jotai/Recoil : Atomic state, plus granulaire mais plus complexe
Bonnes pratiques
1. Utilisez des selectors simples
// ✅ BON
const teamCount = useCANStore((state) => state.teams.length);
// ❌ ÉVITEZ (si possible)
const complexData = useCANStore((state) => {
// Calculs complexes ici
return state.teams
.filter(/* ... */)
.map(/* ... */)
.reduce(/* ... */);
});
2. Utilisez useShallow pour les objets
import { useShallow } from 'zustand/react/shallow';
const teamInfo = useCANStore(
useShallow((state) => ({
count: state.teams.length,
selected: state.selectedTeam
}))
);
3. Séparez les stores par domaine
// ✅ BON : Stores séparés
const useAuthStore = create(/* ... */);
const useCANStore = create(/* ... */);
const useUISStore = create(/* ... */);
// ❌ ÉVITEZ : Un seul store géant
const useAppStore = create(/* ... tout ... */);
4. Utilisez des actions plutôt que de modifier directement
// ✅ BON
const updateScore = useCANStore((state) => state.updateScore);
updateScore('match-1', 2, 1);
// ❌ ÉVITEZ
const store = useCANStore.getState();
store.matches[0].score1 = 2; // Modification directe
Et sur mobile ? Parfois, le meilleur re-render est celui qui n’a pas lieu
Tout ce qui précède vaut aussi pour React Native : un selector bien choisi limite les rendus de la même façon.
Mais sur mobile, certaines mises à jour n’ont pas intérêt à passer par React du tout. Une touche qui s’enfonce sous le doigt, par exemple, doit réagir même si le thread JavaScript est occupé. Avec Reanimated, on écrit dans une valeur partagée, et l’animation tourne sur le thread UI :
const scale = useSharedValue(1);
const style = useAnimatedStyle(() => ({ transform: [{ scale: scale.get() }] }));
// Aucun setState, aucun re-render : le composant ne sait même pas qu'il bouge.
<AnimatedPressable onPressIn={() => scale.set(withSpring(0.95))} style={style} />
La règle reste la même qu’avec Zustand, poussée un cran plus loin : ne faites re-rendre un composant que pour ce qui change vraiment son contenu. Un état d’animation n’en fait pas partie.
J’ai détaillé ce parcours, du doigt jusqu’au serveur, dans Le trajet d’un tap dans une app React Native.
Conclusion
Zustand offre une solution élégante et performante pour la gestion d’état dans React. En utilisant useSyncExternalStoreWithSelector sous le capot, il permet des re-renders sélectifs qui améliorent significativement les performances de votre application.
Points clés à retenir :
- ✅ Utilisez des selectors pour limiter les re-renders
- ✅ Comprenez l’égalité référentielle (
===) - ✅ Utilisez
useShallowpour les objets complexes - ✅ Séparez vos stores par domaine
- ✅ Profitez de la simplicité et de la performance
Qu’est-ce que vous utilisez pour gérer l’état global dans vos projets React ? Redux, Context API, ou Zustand ? Partagez vos expériences !
Ressources pour aller plus loin :