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;DR — Zustand = 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 ».
setfusionne (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
answerretourne 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 storeC’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.