Pourquoi Zustand est si performant ? Le secret des re-renders optimisés

4 min de lecture
ZustandReactState ManagementPerformanceTypeScriptFrontendJavaScript

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é.

Code Context API avec le problème

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.

Store Zustand complet pour la CAN

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

Exemples d’utilisation avec selectors (BON vs MAUVAIS)

✅ 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 de useSyncExternalStoreWithSelector

// 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 ?

  1. Le composant s’abonne au store avec un selector
  2. Quand l’état change, Zustand compare la nouvelle valeur du selector avec l’ancienne valeur
  3. Si elles sont identiques (avec ===), pas de re-render
  4. 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.

Exemple avec useShallow pour éviter les re-renders

// ❌ 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 useShallow pour 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 :