Skip to Content

Chapitre 6.2 — Zustand : le store de session

Où on en est : les règles d’hygiène sont posées. La session de quiz — partagée entre l’écran de jeu, le modal d’abandon et l’écran résultat — mérite un store. Entrée en scène de Zustand.

⏱️ TL;DRZustand = un store global en ~3 lignes : create() avec état + actions, consommé par hook avec sélecteurs (useSession(s => s.score)) — seuls les composants abonnés au morceau qui change re-rendent. Pas de Provider, pas de reducers, utilisable HORS React (la logique de timer dira merci). On construit le store de session de Drill, actions comprises.

🎯 Objectifs

  • Créer le store de session (état + actions au même endroit).
  • Consommer avec des sélecteurs fins — et comprendre pourquoi c’est LA discipline Zustand.
  • Accéder au store hors composants (getState) pour la logique de jeu.
  • Savoir pourquoi Zustand plutôt que Context/Redux ici.

Pourquoi Zustand (l’argumentaire en 30 secondes)

  • useState : parfait localement, mais la session est lue/écrite par 3+ écrans.
  • Context : possible, mais tout consommateur re-rend à chaque changement (le score change → le modal re-rend), et l’accès hors React est acrobatique.
  • Redux (Toolkit) : machinerie (actions, dispatch, provider) surdimensionnée pour une app de cette taille.
  • Zustand : 1 ko, sélecteurs intégrés (re-renders chirurgicaux), getState()/setState() hors React, middlewares (persist — chapitre 6.4). Le standard 2026 des apps RN de taille petite/moyenne — et des grandes aussi.
pnpm add zustand

(Lib JS pure → pnpm add suffit, pas besoin d’expo install — chapitre 1.4.)

Le store de session

// stores/session.ts import { create } from 'zustand' import type { Question } from '../lib/types' import { computePoints } from '../lib/scoring' type SessionState = { // données de la partie theme: string | null questions: Question[] index: number // question courante score: number streak: number bestStreak: number answers: Array<{ questionId: string; correct: boolean }> // actions start: (theme: string, questions: Question[]) => void answer: (choiceIndex: number, secondsLeft: number, totalSeconds: number) => boolean next: () => void reset: () => void } export const useSession = create<SessionState>((set, get) => ({ theme: null, questions: [], index: 0, score: 0, streak: 0, bestStreak: 0, answers: [], start: (theme, questions) => set({ theme, questions, index: 0, score: 0, streak: 0, bestStreak: 0, answers: [] }), answer: (choiceIndex, secondsLeft, totalSeconds) => { const { questions, index, score, streak, bestStreak, answers } = get() const q = questions[index] const correct = choiceIndex === q.correctIndex const gained = correct ? computePoints(secondsLeft, totalSeconds) : 0 const newStreak = correct ? streak + 1 : 0 set({ score: score + gained, streak: newStreak, bestStreak: Math.max(bestStreak, newStreak), answers: [...answers, { questionId: q.id, correct }], }) return correct }, next: () => set(s => ({ index: s.index + 1 })), reset: () => set({ theme: null, questions: [], index: 0, score: 0, streak: 0, bestStreak: 0, answers: [] }), }))

Lecture du pattern :

  • État + actions ensemble : le store EST le module de la session. Pas d’action « ailleurs ».
  • set fusionne (pas besoin de spreader tout l’état) ; get() lit l’état courant dans une action — pour les logiques qui dépendent de l’existant.
  • L’action answer retourne le verdict : l’écran en a besoin immédiatement (haptics, couleur du bouton) sans re-sélectionner.
  • Le calcul des points vient de lib/scoring.ts — le store orchestre, la lib calcule.

Consommer : la discipline des sélecteurs

// ✅ Sélecteurs fins : chaque composant s'abonne au minimum const score = useSession(s => s.score) const streak = useSession(s => s.streak) const question = useSession(s => s.questions[s.index]) const answer = useSession(s => s.answer) // les actions sont stables : abonnement gratuit // ❌ L'anti-pattern : tout le store const session = useSession() // re-rend ce composant à CHAQUE changement du store

C’est LA règle Zustand : sélectionner petit. Le header de score ne re-rend que si score change ; les AnswerButton que si la question change. (Pour sélectionner un objet dérivé — plusieurs champs d’un coup — useShallow de zustand/react/shallow évite les re-renders par fausse inégalité de référence.)

Hors React : getState

// N'importe où (lib, timer, listener AppState…) import { useSession } from '../stores/session' const { index, questions } = useSession.getState() useSession.setState({ index: 0 })

C’est le superpouvoir qui manque à Context : la logique de timer (chapitre 6.3) et le handler de notification (Partie 9) liront la session sans être des composants.

⚠️ Piège — Le store de session est éphémère par design : il vit le temps d’une partie. Ne soyez pas tenté d’y fourrer l’historique, les records, les réglages — l’historique va en SQLite (Partie 8), les réglages en AsyncStorage (chapitre 6.4). Un store global n’est pas un sac : c’est un module avec une durée de vie et une responsabilité.

💡 Pour un dev Next.js — Si vous avez utilisé Zustand côté web : c’est exactement le même (la lib est isomorphe). Sinon, pensez « useState global avec sélecteurs » — et notez le contraste avec les server components/SWR : ici TOUT est client, le store est LA mémoire vive de l’app.

✏️ Exercices

1. Créez le composant SessionHeader (score + streak + progression « 3/10 ») avec des sélecteurs fins, et prouvez avec un console.log qu’il ne re-rend PAS quand answers change.

✅ Solution

export function SessionHeader() { const score = useSession(s => s.score) const streak = useSession(s => s.streak) const index = useSession(s => s.index) const total = useSession(s => s.questions.length) console.log('SessionHeader render') return ( <View className="flex-row items-center justify-between"> <AppText variant="heading">{score} pts</AppText> {streak >= 2 && <Badge label={`🔥 ${streak}`} tone="streak" />} <AppText variant="caption">{index + 1}/{total}</AppText> </View> ) }

answers n’est pas sélectionné → sa mutation seule ne notifie pas ce composant. (En pratique answer change aussi score/streak — tester avec un setState({ answers: [...] }) isolé.)

2. Ajoutez au store un getter dérivé accuracy() (taux de bonnes réponses 0-1) SANS le stocker — deux implémentations possibles.

✅ Solution

(1) Fonction dans le store : accuracy: () => { const a = get().answers; return a.length ? a.filter(x => x.correct).length / a.length : 0 } — appelée useSession(s => s.accuracy())… mais attention, appeler dans le sélecteur recalcule à chaque notification. (2) Mieux : sélecteur dérivé côté consommateur : const accuracy = useSession(useShallow(s => …)) ou simplement calculer dans le composant depuis answers sélectionné. Règle « dériver plutôt que stocker » respectée dans les deux cas.

3. Pourquoi les actions Zustand (s => s.answer) peuvent-elles être sélectionnées sans risque de re-render ?

✅ Solution

Les fonctions d’action sont créées une fois dans create() et jamais remplacées : leur référence est stable pour toute la vie du store. Le sélecteur retourne toujours la même valeur → jamais de notification. (Contraste : un objet littéral dérivé serait une nouvelle référence à chaque fois — d’où useShallow.)

🧠 Quiz

1. Trois raisons de préférer Zustand à Context pour la session ?

Réponse

Re-renders chirurgicaux par sélecteurs (Context re-rend tous les consommateurs), accès hors React (getState/setState), zéro Provider/boilerplate — plus le middleware persist pour plus tard.

2. Quelle est la discipline n°1 côté consommation ?

Réponse

Sélecteurs fins : useSession(s => s.score) — jamais useSession() entier, qui abonne le composant à tout le store.

3. Comment la logique non-React lit-elle le store ?

Réponse

useSession.getState() (lecture) / useSession.setState() (écriture) — disponibles sur le hook lui-même, hors composants.

4. Que retourne l’action answer et pourquoi ?

Réponse

boolean (correct ou non) : l’écran a besoin du verdict immédiatement (haptique, styles) sans attendre une re-sélection — les actions peuvent retourner des valeurs.

5. Que ne met-on PAS dans le store de session ?

Réponse

Tout ce qui dépasse la vie d’une partie : historique/records (SQLite, P8), réglages (AsyncStorage, ch. 6.4), banques de questions (TanStack Query, P7). Un store = une responsabilité + une durée de vie.


👉 Chapitre suivant : 6.3 — La machine d’état d’une partie — le cœur du jeu : phases, timer honnête, back matériel.