Chapitre 6.1 — Les hooks en contexte React Native
Où on en est : Drill navigue, mais tout est statique. Avant d’introduire un store, on recalibre vos réflexes hooks pour le mobile : ce qui change, c’est le COÛT des re-renders et le cycle de vie d’une app.
⏱️ TL;DR —
useState/useEffect/useMemo: identiques. Ce qui change : (1) les re-renders coûtent plus cher (pont vers le natif) — la discipline useCallback/memo compte surtout autour des FlatList ; (2) le cycle de vie a une dimension nouvelle : l’app passe en arrière-plan (AppState) — crucial pour un timer de quiz ; (3)useFocusEffectremplace useEffect pour « à chaque visite de l’écran ».
🎯 Objectifs
- Savoir OÙ la discipline anti-re-render est rentable en RN (et où elle ne l’est pas).
- Gérer l’arrière-plan avec AppState — le cas du timer de Drill.
- Utiliser useFocusEffect pour les écrans sous pile/tabs.
- Poser les règles d’hygiène d’état du cours.
Vos hooks, votre React
Rien à réapprendre : useState, useEffect, useReducer, useMemo, useCallback, useRef,
contexte — tout est le MÊME React (19.2). Une partie de Drill pourrait s’écrire entièrement en
useState. Ce chapitre calibre les trois différences d’environnement.
Différence 1 : le coût des re-renders
Un re-render web ne touche le DOM que s’il y a diff. Même chose en RN (Fabric ne commit que le diff)… mais chaque mutation traverse JSI vers le natif, et le thread JS mobile est plus lent qu’un desktop. En pratique :
- Écrans simples (réglages, résultat) : zéro discipline nécessaire. N’optimisez pas.
- Listes : LE point chaud. Un re-render du parent d’une FlatList re-rend tous les items
visibles si les props ne sont pas stables. D’où le triptyque déjà vu (chapitre 3.4) :
React.memo(Item)+useCallbacksur les handlers passés aux items + styles statiques. - Animations : jamais via re-renders JS (60-120 re-renders/s = mission impossible) — c’est le boulot de Reanimated (Partie 10), qui contourne React complètement.
// L'écran Thèmes — la discipline appliquée UNE fois, au bon endroit
const handleOpen = useCallback((slug: string) => router.push(`/themes/${slug}`), [])
<FlatList
data={themes}
renderItem={({ item }) => <ThemeRow theme={item} onOpen={handleOpen} />}
…
/>
// ThemeRow = React.memo(…) — et c'est tout. Pas de useCallback ailleurs dans l'écran.💡 Pour un dev Next.js — Même philosophie que le React Compiler vous l’a apprise : la mémoïsation manuelle n’est pas une vertu, c’est un outil ciblé. En RN 2026, le compiler arrive aussi (activable dans Expo) mais la règle reste : profiler d’abord (chapitre 2.4), optimiser les listes, ignorer le reste.
Différence 2 : l’app a un arrière-plan
Le web a des onglets ; le mobile a un cycle de vie d’app : active → background (l’user
répond à un SMS) → de retour. Aucun événement web ne correspond vraiment. L’API : AppState.
Le cas concret de Drill — le timer : le joueur a 20 s pour répondre ; il met l’app en arrière-plan à 12 s… le temps doit-il continuer ? (Oui — sinon pause tactique gratuite !) :
// hooks/useAppStateChange.ts
import { useEffect, useRef } from 'react'
import { AppState, AppStateStatus } from 'react-native'
export function useAppStateChange(onChange: (status: AppStateStatus) => void) {
const cb = useRef(onChange)
cb.current = onChange
useEffect(() => {
const sub = AppState.addEventListener('change', status => cb.current(status))
return () => sub.remove()
}, [])
}Le principe pour un timer honnête : ne JAMAIS compter en décrémentant un state chaque seconde
(le JS est gelé en arrière-plan !) — stocker l’heure de fin (endsAt = Date.now() + 20_000)
et calculer le restant à chaque tick ET au retour d’arrière-plan. On l’implémente au chapitre
6.3 ; retenez le pattern : l’horloge fait foi, pas le compteur.
⚠️ Piège — Un
setIntervaln’est PAS fiable en arrière-plan (throttlé/gelé selon l’état de l’app et d’Android). Tout ce qui dépend du temps réel — timer de jeu, expiration de cache, cooldown — se base sur des timestamps comparés à Date.now(), jamais sur l’accumulation de ticks.
Différence 3 : focus ≠ montage
Vu au chapitre 5.1 : les écrans restent montés sous la pile et dans les tabs. useEffect([])
= « au montage » = une fois par vie d’écran, pas à chaque visite. Pour rafraîchir à chaque
visite :
import { useFocusEffect } from 'expo-router'
import { useCallback } from 'react'
useFocusEffect(
useCallback(() => {
reloadHistory() // à CHAQUE focus de l'écran
return () => {} // au blur (départ de l'écran)
}, [reloadHistory])
)Cas d’usage dans Drill : l’Historique se rafraîchit quand on y revient après une partie (les données SQLite auront changé — Partie 8) ; l’écran de quiz coupe ses sons au blur.
L’hygiène d’état du cours
Trois règles qu’on suivra systématiquement :
- L’état le plus local possible : un toggle d’accordéon = useState dans le composant. N’escaladez vers un store que ce qui est PARTAGÉ entre écrans.
- Dériver plutôt que stocker :
const isLast = index === total - 1— pas un state. Un état stocké qui peut se calculer est un bug de synchronisation en attente. - La logique de jeu dans lib/, pure : le calcul de score, le tirage de questions — des fonctions TS testables. Les hooks/stores les appellent.
✏️ Exercices
1. Écrivez lib/scoring.ts : computePoints(secondsLeft: number, total: number): number —
1000 points max, dégressifs linéairement avec le temps restant, minimum 100 si correct.
Pure, testable.
✅ Solution
// lib/scoring.ts
export function computePoints(secondsLeft: number, totalSeconds: number): number {
if (totalSeconds <= 0) return 100
const ratio = Math.max(0, Math.min(1, secondsLeft / totalSeconds))
return Math.round(100 + ratio * 900)
}Réponse instantanée = 1000, dernière seconde = ~100. Bornée, sans dépendance — Jest la testera en Partie 14 sans aucun mock.
2. Le header de l’accueil doit afficher « Bon retour 👋 » si l’app revient d’un arrière-plan de plus de 5 minutes. Implémentez avec useAppStateChange.
✅ Solution
const wentBackgroundAt = useRef<number | null>(null)
const [showWelcomeBack, setShowWelcomeBack] = useState(false)
useAppStateChange(status => {
if (status === 'background') wentBackgroundAt.current = Date.now()
if (status === 'active' && wentBackgroundAt.current) {
if (Date.now() - wentBackgroundAt.current > 5 * 60_000) setShowWelcomeBack(true)
wentBackgroundAt.current = null
}
})Timestamps comparés, pas de compteur — le pattern du chapitre appliqué.
3. Un collègue met useCallback sur TOUS les handlers de l’écran réglages « par principe ».
Expliquez-lui, arguments concrets à l’appui, pourquoi c’est du bruit — et où le déplacer.
✅ Solution
L’écran réglages n’a ni liste ni enfants mémoïsés : les fonctions recréées à chaque render y sont gratuites (personne ne compare leur référence), et chaque useCallback ajoute lecture
- tableau de deps à maintenir (source de bugs de staleness). La discipline paie UNIQUEMENT quand la référence est comparée : props d’un React.memo, deps d’un effet, items de FlatList. → Garder le réflexe pour l’écran Thèmes (liste), l’enlever ici.
🧠 Quiz
1. Où la mémoïsation est-elle rentable en RN ?
Réponse
Autour des FlatList (items memo + handlers useCallback + styles stables) et des props de composants mémoïsés. Ailleurs (écrans simples), c’est du bruit.
2. Pourquoi un timer par décrément de state est-il faux, et quel est le bon pattern ?
Réponse
Le JS est gelé/throttlé en arrière-plan : les ticks se perdent, le timer « offre » du temps.
Bon pattern : stocker l’heure de fin et calculer endsAt - Date.now() à chaque tick et au
retour d’arrière-plan — l’horloge fait foi.
3. Quelle API écoute les passages actif/arrière-plan ?
Réponse
AppState (AppState.addEventListener('change', cb)) — statuts active, background
(et inactive sur iOS).
4. useEffect([]) vs useFocusEffect ?
Réponse
useEffect([]) = une fois au montage (l’écran reste monté sous pile/tabs). useFocusEffect
= à chaque fois que l’écran redevient visible (+ cleanup au blur).
5. Citez les trois règles d’hygiène d’état du cours.
Réponse
État le plus local possible (n’escalader que le partagé) ; dériver plutôt que stocker ; logique de jeu pure dans lib/ (les hooks l’appellent).
👉 Chapitre suivant : 6.2 — Zustand — le store de session de Drill.