État global : Zustand + MMKV
Jusqu'ici, on a vu comment un composant gère sa propre mémoire avec useState
(dans le module sur useState & useEffect). Mais une application entière a besoin de
se souvenir de choses qu'aucun composant ne possède à lui seul : « qui est
connecté ? », « un entraînement est-il en cours ? », « où en est le minuteur de repos ? ».
Ces vérités-là appartiennent à l'app, pas à un écran. Ce module raconte comment Halterofit
leur donne un foyer : un store global, posé en dehors de React, et gardé en vie
entre deux lancements grâce à un stockage rapide nommé MMKV.
Imagine que l'info « utilisateur connecté » vive dans un useState à
l'intérieur de l'écran de connexion. Le jour où l'écran d'accueil, la barre de profil et
la garde de navigation ont besoin de cette même info, ils ne peuvent pas la voir : elle
est enfermée dans un composant qui n'est même plus à l'écran. Un store
global est une boîte unique, vivante en dehors de l'arbre des composants :
n'importe quel écran peut y lire et y écrire, et tout le monde voit la même vérité au
même instant. Halterofit utilise pour ça Zustand, une bibliothèque
volontairement minuscule. On va décortiquer le mot « global », le mot « source de vérité »
et le mot « persistance », un par un, sans rien prendre pour acquis.
État local vs état global : pourquoi useState ne suffit plus
Reprenons depuis le début. useState crée une variable mémorisée
à l'intérieur d'un composant. Quand ce composant disparaît de l'écran,
sa mémoire disparaît avec lui. Quand un autre composant veut lire cette valeur,
il ne le peut pas : elle n'existe que dans la « tête » du premier. C'est exactement ce
qu'on veut pour des choses purement locales — le texte en cours de frappe dans un champ,
le fait qu'un menu déroulant soit ouvert ou fermé, l'onglet sélectionné d'un sélecteur.
Personne d'autre n'a besoin de le savoir, donc on garde ça au plus près, là où ça vit.
Le problème commence quand deux écrans ont besoin de la même donnée.
Prenons « l'utilisateur est-il connecté ? ». L'écran de connexion la produit ; la garde de
navigation (vue dans le module sur la navigation) doit la lire pour décider qui a le droit
d'entrer ; la barre de profil doit l'afficher. Trois consommateurs, une seule donnée. Si
elle vit dans le useState de l'un d'eux, les deux autres sont aveugles.
La première réponse « React pur » à ce problème s'appelle remonter l'état
(lifting state up). L'idée : si deux composants frères ont besoin d'une donnée,
on la déplace dans leur parent commun, et le parent la redistribue par
props à ses enfants. Ça marche, et c'est même la bonne réponse pour beaucoup
de cas simples. Mais ça a deux limites qui deviennent vite douloureuses :
- Le parage de props (prop drilling) : si le composant qui a besoin de la donnée est enfoui à six niveaux de profondeur, la donnée doit traverser chacun des cinq composants intermédiaires — qui n'en ont aucun usage — juste pour la faire descendre. Chaque composant traversé devient inutilement couplé à une donnée qui ne le concerne pas.
- Le parent commun trop haut : pour « utilisateur connecté », le parent commun de l'écran de connexion, de la garde et du profil, c'est… la racine de l'app tout entière. Remonter l'état là-haut, c'est mettre l'état de toute l'application dans un seul énorme composant racine, qui re-render tout le monde au moindre changement.
C'est là qu'on franchit une frontière conceptuelle : et si la donnée ne vivait pas du tout dans l'arbre React ? Si elle vivait à côté, dans un objet ordinaire posé en mémoire, que les composants viendraient simplement consulter quand ils le veulent ? Plus de parent à trouver, plus de props à parer : une boîte unique, accessible de partout. C'est précisément la définition d'un store global, et c'est la raison d'être de Zustand.
Le store global n'est pas « mieux » que useState ; c'est un outil pour un
autre problème. Mettre tout dans un store global est une erreur courante de débutant :
ça transforme chaque petit état local en variable mondiale, ce qui complique tout et
multiplie les re-renders. La règle saine : commence toujours local
(useState), et ne promeus une donnée au global que le jour où un
autre composant en a réellement besoin. On reviendra sur cet arbre de décision
plus bas.
Le principe de la source unique de vérité
Avant même de parler de Zustand, il faut installer une idée qui gouverne tout bon état d'application : une donnée ne doit vivre qu'à un seul endroit ; tout le reste se calcule à partir d'elle. On appelle ça la source unique de vérité (single source of truth). Tout ce qui peut être déduit d'une autre donnée ne doit pas être stocké séparément — sinon, le jour où l'une change et pas l'autre, ton app se met à mentir.
Le store d'authentification de Halterofit en fait une démonstration parfaite. Il garde
un champ user (l'utilisateur, ou null si personne) et un champ
isAuthenticated (un booléen « est-il connecté ? »). À première vue, deux
informations indépendantes. En réalité, l'une découle de l'autre : on est
connecté si, et seulement si, un utilisateur existe. Regarde comment l'action
setUser les lie :
setUser: (user) =>
set({
user, // la SOURCE de vérité
isAuthenticated: user !== null, // DÉRIVÉ : connecté = un user existe
isLoading: false,
}),
isAuthenticated n'est jamais fourni « à la main » par l'appelant : il est
recalculé à chaque fois à partir de user !== null. Les deux
champs ne peuvent donc jamais se contredire.
Pourquoi ne pas simplement demander à l'appelant de fournir les deux ? Parce que ce serait
ouvrir la porte à l'incohérence. Imagine que setUser accepte
user ET isAuthenticated en paramètres séparés : un jour, par
mégarde, quelqu'un appelle setUser(unUser, false). L'app se retrouve avec un
utilisateur bien réel mais marqué « non connecté ». Quel écran a raison ? Personne ne sait.
Ce genre de bug est insidieux parce qu'il n'y a pas de plantage : juste deux vérités qui
se disputent. En dérivant isAuthenticated de user, on
rend cet état impossible à atteindre — c'est une garantie structurelle, pas une discipline
à tenir.
Pose-toi toujours la question : « cette donnée est-elle une source ou un dérivé ? » Si elle peut se calculer à partir d'une autre, ne la stocke pas — calcule-la. Un total de panier se calcule depuis les articles ; le nombre de séries faites se compte depuis la liste des séries ; « connecté » se déduit de « user existe ». Moins tu stockes de vérités indépendantes, moins tu as d'occasions de te contredire.
Zustand : un store = un état + des actions
Passons à l'outil. Un store Zustand se crée avec la fonction create. Tu lui
donnes une fonction qui décrit deux choses : l'état initial (des champs
de données) et les actions (des fonctions qui modifient cet état). Voici
le store d'authentification, allégé pour ne montrer que l'essentiel :
export const useAuthStore = create<AuthState>()(
persist( // (emballage "persist", détaillé plus bas)
(set) => ({
// ── L'ÉTAT (les données mémorisées) ──
user: null,
isAuthenticated: false,
isLoading: true,
// ── LES ACTIONS (les seules portes pour modifier l'état, via `set`) ──
setUser: (user) =>
set({
user,
isAuthenticated: user !== null, // dérivé : connecté = user existe
isLoading: false,
}),
setLoading: (isLoading) => set({ isLoading }),
// …signOut, etc.
}),
{ /* options de persistance */ }
)
);
La fonction reçoit set : un outil qui fusionne ce que tu lui passes dans
l'état existant. set({ isLoading }) ne touche QUE isLoading ;
les autres champs restent intacts.
Décomposons les pièces. create<AuthState>() annonce à TypeScript la
forme exacte de ce store (l'interface AuthState liste les champs et les
actions — on a vu l'importance de typer dans le module sur TypeScript). La fonction
(set) => ({ ... }) renvoie un objet : tout ce qui est une valeur est de
l'état, tout ce qui est une fonction est une action. Et set est le cœur du
réacteur : c'est l'équivalent global du setter de useState. La
différence cruciale, c'est que set fait une fusion superficielle
(shallow merge) : il prend l'objet que tu lui donnes et écrase uniquement les
champs cités, en laissant les autres tranquilles. C'est pour ça que
set({ isLoading }) ne réinitialise pas user.
Remarque un point de style qui n'est pas anodin : seules les actions appellent
set. Un composant ne fait jamais « écris directement dans le store » ;
il appelle une action (setUser, signOut…), et c'est l'action qui
décide comment l'état change. Ça donne un seul endroit où chercher quand on se demande
« qui a bien pu mettre isAuthenticated à true ? » : la réponse
est forcément une des actions. L'état devient traçable.
Si tu as déjà entendu parler de Redux, Zustand vise le même objectif (un état partagé,
prévisible) avec énormément moins de cérémonie. Redux demande des actions
typées, des reducers, des dispatchers, souvent des fichiers entiers de
boilerplate pour changer une seule valeur. Zustand, lui, c'est : un objet, des
fonctions, set. Pas de provider à enrober autour de l'app, pas de
réducteur à écrire. La contrepartie de cette simplicité, c'est moins de garde-fous
imposés ; mais pour une app de la taille de Halterofit, c'est exactement le bon
compromis. L'idée importante : le concept est le même, seule
l'ergonomie change.
Voici le point mental le plus important du module. Le store n'est pas un composant et ne vit pas dans l'arbre React. C'est un objet unique (singleton : il n'en existe qu'un seul exemplaire pour toute l'app), créé une fois au démarrage, qui reste en mémoire indépendamment de ce qui s'affiche. Les composants ne le contiennent pas : ils s'y abonnent. Quand le store change, il prévient ses abonnés, qui re-render. Cette indépendance est exactement ce qui résout le problème du début : comme le store ne dépend d'aucun composant, sa mémoire ne disparaît jamais quand un écran se ferme, et tout le monde peut y accéder sans chaîne de props.
Lire le store : les sélecteurs et l'art de re-render le moins possible
Pour lire le store dans un composant, on l'appelle comme un hook, mais en lui passant une petite fonction appelée sélecteur. Le sélecteur dit : « dans tout le store, donne-moi juste ce morceau-là ». Tu as déjà croisé ce motif dans la garde d'authentification (module sur la navigation) :
// On "sélectionne" UNE seule tranche de l'état : isAuthenticated.
const isAuthenticated = useAuthStore((s) => s.isAuthenticated);
La fonction (s) => s.isAuthenticated reçoit l'état complet s et
renvoie le seul champ qui intéresse ce composant.
Pourquoi se donner cette peine plutôt que de tout récupérer d'un coup ? Pour une raison
de performance qui touche directement au cœur de React. Souviens-toi de la grande leçon
du module sur le modèle mental de React : un composant se redessine quand l'état dont il
dépend change. Avec un sélecteur, Zustand n'abonne ton composant qu'à la tranche
sélectionnée. Le composant ci-dessus ne re-render que si isAuthenticated
change — pas si user change, pas si isLoading change, pas si
n'importe quel autre champ du store bouge. C'est une optimisation gratuite et automatique :
tu sélectionnes précis, tu re-render rarement.
À l'inverse, si tu écris const store = useAuthStore() sans sélecteur, tu
t'abonnes à tout le store : ton composant re-render dès que
n'importe quel champ change, même ceux qu'il n'utilise pas. Sur un store très
actif comme celui de l'entraînement — où le minuteur de repos, l'index d'exercice et l'état
de chaque pane changent en permanence — ça voudrait dire des re-renders incessants pour un
composant qui ne lit qu'un seul champ.
const store = useAuthStore() (sans sélecteur) est le piège classique : il
abonne le composant à l'intégralité du store, donc à chaque changement, même sans
rapport. Préfère un sélecteur précis par valeur dont tu as besoin. Et attention à un
piège jumeau : un sélecteur qui renvoie un nouvel objet à
chaque appel, comme (s) => ({ a: s.a, b: s.b }). Comme l'objet est
recréé à chaque rendu, sa référence change toujours (revois l'égalité référentielle dans
le module sur useContext, useMemo, useCallback et memo), et Zustand croit que la valeur a
changé → re-render à chaque fois. Deux solutions saines : sélectionner chaque champ
séparément (un useStore par valeur), ou utiliser un comparateur
d'égalité superficielle. Halterofit choisit la première : ses hooks d'entraînement lisent
currentWorkoutId, currentExerciseIndex, etc. en sélecteurs
distincts.
Le store d'entraînement (workoutStore.ts) sépare soigneusement ses champs
justement pour que chaque composant ne s'abonne qu'à ce qui le concerne. La barre de
footer lit l'état du minuteur de repos ; les pastilles de progression lisent
exerciseCompletion ; le pane actif lit son index. Mieux : l'action
setExerciseCompletion commence par vérifier if (state.exerciseCompletion[id]
=== allLogged) return state; — si la valeur n'a pas bougé, elle ne déclenche
aucune mise à jour, donc aucun re-render des abonnés. C'est de l'optimisation de
re-render au niveau de l'action elle-même.
La persistance : faire survivre l'état à un redémarrage
Un store ordinaire vit en mémoire : ferme l'app, il s'évapore. Or on veut rester connecté
d'un lancement à l'autre, et surtout ne pas perdre un entraînement en cours. C'est le rôle
du middleware persist : c'est l'emballage qu'on a vu autour
de create. Il intercepte chaque changement d'état, le sérialise en JSON et
l'écrit dans un stockage du téléphone ; au lancement suivant, il relit ce stockage et
réinjecte l'état sauvegardé. Tout ça, automatiquement.
Le stockage utilisé ici est MMKV, branché via un petit adaptateur. Voici les options de persistance du store d'auth :
{
name: 'auth-storage', // la "clé" de sauvegarde
storage: createJSONStorage(() => zustandMMKVStorage), // OÙ l'on sauvegarde
// On ne persiste QUE ce qui a du sens à restaurer.
partialize: (state) => ({
user: state.user,
isAuthenticated: state.isAuthenticated,
}),
// Que faire au moment de relire la sauvegarde au démarrage.
onRehydrateStorage: (state) => (_hydratedState, error) => {
if (error) {
state.setUser(null); // sauvegarde corrompue → on repart propre
return;
}
state.setLoading(false); // tout va bien → on arrête l'écran de chargement
},
}
Trois leviers : name (sous quelle clé sauver), partialize (quoi
sauver), onRehydrateStorage (que faire à la relecture).
Décortiquons. name: 'auth-storage' est la clé sous laquelle
tout l'état sérialisé sera rangé — pense à un tiroir étiqueté. Le store d'entraînement
utilise un tiroir différent, 'workout-storage', pour ne pas se mélanger.
storage: createJSONStorage(() => zustandMMKVStorage)
désigne le coffre physique. createJSONStorage s'occupe de transformer l'état
en texte JSON et inversement ; zustandMMKVStorage est un petit adaptateur
maison qui relie ce mécanisme à MMKV. Cet adaptateur (zustandStorage.ts) ne
fait que trois choses — lire, écrire, effacer une clé — en déléguant à MMKV :
export const zustandMMKVStorage: StateStorage = {
getItem: (name) => mmkvStorage.get(name) ?? null, // lire la sauvegarde
setItem: (name, value) => mmkvStorage.set(name, value), // l'écrire
removeItem: (name) => mmkvStorage.delete(name), // l'effacer
};
Un simple « traducteur » entre le vocabulaire attendu par Zustand
(getItem/setItem/removeItem) et l'API de MMKV.
MMKV vs AsyncStorage, et le cycle d'hydratation
Pourquoi MMKV et pas AsyncStorage, le stockage historique de React Native ?
Pour deux raisons décisives : MMKV est 10 à 30 fois plus
rapide et il est synchrone. Chacune compte :
-
Synchrone : MMKV lit et écrit instantanément, sans
await.AsyncStorage, lui, est asynchrone : chaque lecture est une promesse qu'il faut attendre. Au démarrage, ça fait la différence entre « l'état est là tout de suite » et « l'écran clignote vide une fraction de seconde le temps que la promesse se résolve ». Pour de la persistance de store, le synchrone est un vrai confort. - Rapide : MMKV est écrit en code natif optimisé. Sauver l'état d'un entraînement à chaque série loggée doit être imperceptible — c'est le cas.
On lit souvent que « MMKV est chiffré ». À nuancer : MMKV sait chiffrer le stockage, mais seulement si on lui fournit une clé de chiffrement à l'initialisation. Dans Halterofit, le stockage est créé sans clé — les données sont donc rapides et fiables, mais non chiffrées sur l'appareil. Bon à savoir le jour où tu manipuleras des données vraiment sensibles : le chiffrement n'est pas automatique, il se demande.
Maintenant, le moment délicat : l'hydratation. Au lancement, le store
naît d'abord avec ses valeurs initiales (user: null, isLoading: true…),
puis persist va lire MMKV et « réhydrater » l'état avec ce qui était
sauvegardé. Il y a donc un bref instant où le store est dans son état neuf avant que la
sauvegarde n'arrive. C'est exactement pourquoi isLoading démarre à
true : tant qu'on ne sait pas encore si un utilisateur était connecté, on
affiche un chargement, et c'est onRehydrateStorage qui le passe à
false une fois la relecture terminée.
C'est là que partialize prend tout son sens. Il choisit
quels champs sont sauvegardés. Dans l'auth, on persiste user et
isAuthenticated (pour rester connecté), mais surtout pas
isLoading : ce serait absurde de restaurer un « chargement en cours » qui
n'a aucun sens hors de l'instant où il a été émis. Restaurer un état temporaire, c'est
ranger un état qui ne devrait jamais ressusciter. Le vrai partialize de
Halterofit va même plus loin : il refuse de sauvegarder l'utilisateur fictif de
développement (DEV_MOCK_USER) pour qu'il ne « fuite » jamais dans une build de
production.
Enfin, onRehydrateStorage est le filet de sécurité. Il
s'exécute juste après la relecture et reçoit une éventuelle error. Si la
sauvegarde est corrompue (téléphone qui a coupé en pleine écriture, format changé entre
deux versions de l'app…), au lieu de planter ou de laisser un état à moitié lu, le store
repart propre : state.setUser(null). Une sauvegarde abîmée ne doit jamais
pouvoir bloquer l'app dans un état incohérent — c'est de la gestion d'erreur défensive
(dans l'esprit de ce qu'on a vu sur la robustesse au fil du guide).
La tentation est de tout persister « pour ne rien perdre ». C'est l'erreur inverse de la
bonne. Un état temporaire (isLoading, un message d'erreur affiché, une
animation en cours, une référence de fonction) n'a aucun sens à restaurer : au mieux il
est inutile, au pire il remet l'app dans un état faux au démarrage. partialize
existe pour répondre à une seule question, champ par champ : « si l'app redémarrait là,
tout de suite, voudrais-je vraiment retrouver ça ? » Si la réponse est non, on l'exclut.
Reprise après crash : pourquoi le workoutStore ne stocke que des nombres
Le store d'entraînement applique le même schéma persist + MMKV, mais pour un
enjeu plus fort : si l'app plante au milieu d'une séance, on ne veut surtout pas
perdre la session. En persistant l'identité de la séance, l'index de l'exercice courant et
l'état du minuteur, l'app peut reprendre exactement où tu en étais après
un redémarrage. Le commentaire d'en-tête du fichier le dit explicitement : « crash
recovery » — et c'est un commentaire d'en-tête qui paie d'être lu, dans l'esprit du module
sur la méthode de lecture du code.
Mais cette reprise impose une contrainte subtile et instructive. Tout ce qui est persisté
doit faire l'aller-retour par JSON sans se déformer. Or JSON ne connaît
que des types simples : chaînes, nombres, booléens, tableaux, objets plats,
null. Il ne connaît pas les objets Date de
JavaScript. Si tu sauvegardes un Date, JSON le transforme en chaîne de
caractères, et au retour, tu récupères une chaîne — plus un Date avec ses
méthodes. Le commentaire du store le résume d'une phrase : « un objet Date
ne survit pas à un aller-retour en JSON ».
La parade adoptée partout dans workoutStore.ts : stocker des
timestamps en millisecondes (de simples nombres) plutôt que des objets
Date. Regarde le démarrage d'une séance et le minuteur de repos :
startWorkout: ({ workoutId, exerciseIds }) =>
set({
isWorkoutActive: true,
currentWorkoutId: workoutId,
workoutStartTime: Date.now(), // un NOMBRE (ms), pas un objet Date
exerciseIds,
currentExerciseIndex: 0,
restTimer: IDLE_REST_TIMER,
// …
}),
startRestTimer: (durationSeconds) =>
set({
restTimer: {
status: 'running',
durationSeconds,
endsAt: Date.now() + durationSeconds * 1000, // un NOMBRE : l'instant de fin
remainingSeconds: null,
},
}),
Date.now() renvoie un nombre (millisecondes depuis 1970). On stocke
workoutStartTime et endsAt comme des nombres bruts : ils
traversent JSON sans broncher, et au redémarrage on retrouve exactement les bons instants.
L'élégance de ce choix va plus loin que la simple sérialisation. En stockant
endsAt (l'instant de fin absolu) plutôt qu'un « temps restant » qui
s'égrène, le minuteur reste juste même si l'app a été en arrière-plan ou a redémarré : il
suffit de comparer endsAt à Date.now() pour savoir combien de
secondes il reste, peu importe ce qui s'est passé entre-temps. Une seule source de vérité
(l'instant de fin), le « temps restant » s'en déduit — on retrouve exactement le principe
de source unique vu plus haut.
Et le filet de sécurité est encore là. Le onRehydrateStorage du store
d'entraînement, en cas de relecture ratée, appelle hydratedState?.endWorkout() :
une sauvegarde corrompue ne pourra jamais laisser l'app croire qu'un entraînement
fantôme est en cours. Mieux vaut repartir d'une session vide qu'avec une session cassée.
useState ou store ? Un arbre de décision
On a maintenant tous les éléments pour trancher proprement. La question « est-ce un
useState ou un champ de store ? » se résout presque toujours avec deux
questions, dans cet ordre :
-
Plusieurs composants éloignés ont-ils besoin de cette donnée ?
Si non — si elle ne concerne qu'un composant, ou un composant et ses enfants directs —
reste local :
useState(ou remonter d'un cran à un parent commun proche). Le global serait une complexité inutile. -
Si oui : doit-elle survivre à la fermeture de l'app ?
Si non, un store Zustand simple suffit. Si oui (rester connecté, reprendre une séance),
ajoute le middleware
persist+ MMKV, et réfléchis àpartialize(que garder) etonRehydrateStorage(que faire si la relecture échoue).
Un troisième réflexe utile : est-ce une donnée serveur ? Dans Halterofit, la base de données reste la source de vérité des entraînements, exercices et séries terminées. Le store d'entraînement ne garde que l'état de navigation de session — sur quel exercice on est, où en est le minuteur — pour que l'interface reprenne au bon endroit. La donnée « lourde » et durable vit dans la base (dans l'esprit des modules sur la base de données) ; le store ne porte que le « curseur » volatil de la session en cours. Bien séparer ces deux mondes évite d'utiliser le store comme un mauvais cache de la base.
| Situation | Bon outil |
|---|---|
| Texte d'un champ, menu ouvert/fermé, onglet actif | useState local |
| Donnée partagée par un parent et ses enfants proches | Remonter l'état (props) |
| Donnée partagée par des écrans éloignés (user connecté) | Store Zustand |
| État partagé qui doit survivre à un redémarrage (session, auth) | Zustand + persist + MMKV |
| Données durables et volumineuses (historique d'entraînements) | La base de données, pas le store |
Dans le store d'auth, l'action setUser fait ceci :
setUser: (user) =>
set({
user,
isAuthenticated: user !== null,
isLoading: false,
}),
Questions : (1) Pourquoi calculer isAuthenticated à partir
de user plutôt que de demander à l'appelant de fournir les deux ?
(2) Quand l'écran de garde (module sur la navigation) verra-t-il l'utilisateur comme
connecté ? (3) Pourquoi isLoading est-il forcé à false ici, et
pourquoi ce champ ne figure-t-il pas dans partialize ?
Voir le corrigé
(1) Pour éviter les incohérences. Si l'appelant devait passer
user ET isAuthenticated séparément, il pourrait se tromper
(un user présent mais isAuthenticated: false). En dérivant
isAuthenticated de user !== null, les deux ne peuvent jamais
se contredire. Une seule source de vérité (user), le reste en découle.
(2) Dès que setUser est appelé avec un user non-null :
l'état global change → tous les composants abonnés à isAuthenticated
re-render → la garde ré-évalue sa condition et arrête de rediriger. L'écran privé
s'affiche, sans qu'on ait eu à « prévenir » quoi que ce soit manuellement. C'est toute
la puissance d'un store : on change la vérité à un endroit, et les abonnés réagissent
seuls.
(3) setUser marque la fin d'une opération d'auth, donc
il n'y a plus rien à attendre : isLoading: false. Et isLoading
est exclu de partialize parce que c'est un état purement temporaire : le
restaurer au démarrage n'aurait aucun sens. Au lancement, on repart de
isLoading: true et c'est onRehydrateStorage qui le repasse à
false une fois la sauvegarde relue.
1. Quand préfère-t-on un store global à un useState ?
Quand l'état doit être partagé entre plusieurs écrans éloignés (utilisateur connecté, session active…). useState reste enfermé dans un seul composant ; remonter l'état atteint vite ses limites (parage de props, parent commun trop haut).
2. Que fait un sélecteur comme useAuthStore((s) => s.isAuthenticated), et pourquoi est-ce important ?
Il lit une seule tranche de l'état et n'abonne le composant qu'à elle : il ne re-render que si cette valeur précise change. Sélectionner tout le store (ou renvoyer un nouvel objet à chaque appel) abonne à tout et multiplie les re-renders inutiles.
3. C'est quoi la « source unique de vérité », illustrée par isAuthenticated = user !== null ?
Une donnée ne vit qu'à un seul endroit ; tout ce qui peut s'en déduire est calculé, pas stocké en double. Comme isAuthenticated dérive de user, ils ne peuvent jamais se contredire.
4. À quoi servent partialize et onRehydrateStorage dans persist ?
partialize choisit quels champs sont sauvegardés (on garde user, on jette isLoading). onRehydrateStorage s'exécute à la relecture au démarrage : il termine le chargement, ou, en cas de sauvegarde corrompue, remet le store dans un état propre (déconnexion / fin de séance).
5. Pourquoi le workoutStore stocke-t-il des nombres (timestamps) et pas des objets Date ?
Parce que la persistance passe par JSON, qui ne sait pas sérialiser un Date (« il ne survit pas à l'aller-retour »). En stockant des millisecondes (Date.now(), endsAt), les instants traversent JSON intacts et la reprise après crash retrouve les bons temps.
useState = mémoire d'un composant ; un store
Zustand = une boîte d'état partagée, vivante hors de React, lue par
sélecteur pour re-render le moins possible. Garde une source
unique de vérité et dérive le reste (isAuthenticated = user !== null).
persist + MMKV (rapide, synchrone) font survivre l'état à un
redémarrage ; partialize choisit quoi garder, onRehydrateStorage
gère une sauvegarde corrompue, et on ne persiste que des données JSON simples (des
nombres, jamais des Date). Local d'abord, global seulement si plusieurs
écrans en ont vraiment besoin.
L'idée « un état partagé, vivant hors des composants, lu par sélecteur, avec une source unique de vérité » est commune à tous les gestionnaires d'état : Redux et Jotai côté React, Pinia côté Vue, et bien d'autres. Zustand est l'une des implémentations les plus simples, mais le vocabulaire — état, action, sélecteur, persistance, hydratation — est universel. Comprends-le ici une bonne fois, et tu te sentiras chez toi dans n'importe lequel de ces outils.