Chapitre 7.2 — TanStack Query
Où on en est : fetchJson est robuste mais brut — chaque écran devrait gérer loading/ erreur/cache/refetch à la main. TanStack Query industrialise tout ça.
⏱️ TL;DR — TanStack Query gère le cycle de vie des données distantes : cache par queryKey,
staleTime(fraîcheur), retries automatiques, refetch intelligent,pull-to-refreshen une ligne. Setup RN : QueryClientProvider dans le layout racine +onlineManagerbranché sur NetInfo +focusManagersur AppState. Les écrans consommentuseQuery({ queryKey, queryFn })et déclarent leurs trois états.
🎯 Objectifs
- Installer et configurer Query pour RN (online/focus managers).
- Comprendre queryKey, staleTime, gcTime — le modèle mental du cache.
- Écrire les hooks de données de Drill (useThemes, useQuestions).
- Brancher pull-to-refresh et retry sur l’existant.
Installation & branchement RN
pnpm add @tanstack/react-query// app/_layout.tsx — le provider + les branchements mobiles
import { QueryClient, QueryClientProvider, onlineManager, focusManager } from '@tanstack/react-query'
import NetInfo from '@react-native-community/netinfo'
import { AppState } from 'react-native'
// 1. Query sait si on est en ligne (retries intelligents, refetchOnReconnect)
onlineManager.setEventListener(setOnline =>
NetInfo.addEventListener(state => setOnline(!!state.isConnected))
)
// 2. « focus » = retour de l'app au premier plan (l'équivalent RN du focus d'onglet web)
focusManager.setEventListener(handleFocus => {
const sub = AppState.addEventListener('change', s => handleFocus(s === 'active'))
return () => sub.remove()
})
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60, // 1 h : les questions ne changent pas si souvent
retry: 2,
},
},
})
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
<Stack …/>
</QueryClientProvider>
)
}Les deux branchements sont LE setup spécifique RN : sur le web, Query écoute window (online,
visibilitychange) ; en RN, on lui donne NetInfo et AppState. Dix lignes, une fois.
Le modèle mental du cache
const { data, isPending, isError, refetch, isRefetching } = useQuery({
queryKey: ['themes'], // l'IDENTITÉ de la donnée
queryFn: fetchThemesManifest, // comment l'obtenir
})queryKey: l’identité. Deux composants avec la même clé partagent LA même donnée et le même fetch (déduplication automatique). Clé paramétrée :['questions', slug].staleTime: durée de « fraîcheur ». Donnée fraîche → servie du cache, AUCUN réseau. Donnée périmée (stale) → servie du cache PUIS refetch en arrière-plan (stale-while- revalidate). Pour Drill : 1 h — on rejouera 10 sessions sans re-télécharger.gcTime(défaut 5 min) : combien de temps une donnée inutilisée reste en mémoire.- Refetch automatiques : au retour au premier plan (focusManager), à la reconnexion (onlineManager), au remontage si stale. Gratuit.
Les hooks de données de Drill
Convention du cours : un fichier hooks/queries.ts — les écrans ne connaissent jamais les URLs :
// hooks/queries.ts
import { useQuery } from '@tanstack/react-query'
import { fetchJson } from '../lib/http'
import type { Theme, Question } from '../lib/types'
const BASE = 'https://raw.githubusercontent.com/<user>/drill-questions/main'
export function useThemes() {
return useQuery({
queryKey: ['themes'],
queryFn: () => fetchJson<Theme[]>(`${BASE}/manifest.json`),
})
}
export function useQuestions(slug: string) {
return useQuery({
queryKey: ['questions', slug],
queryFn: () => fetchJson<Question[]>(`${BASE}/themes/${slug}.json`),
enabled: !!slug, // pas de fetch tant que le slug n'existe pas
})
}(La validation zod remplace les <Theme[]> optimistes au chapitre suivant — chaque chose en
son temps.)
L’écran Thèmes, réécrit sur les vraies données :
// app/(tabs)/themes/index.tsx
export default function ThemesScreen() {
const { data: themes, isPending, isError, refetch, isRefetching } = useThemes()
if (isPending) return <Screen><ActivityIndicator className="mt-16" /></Screen>
if (isError) return <ErrorState onRetry={refetch} /> // composant du ch. 7.4
return (
<FlatList
data={themes}
keyExtractor={t => t.slug}
renderItem={…}
refreshing={isRefetching}
onRefresh={refetch} // pull-to-refresh : DONE
contentContainerClassName="gap-3 p-4"
/>
)
}Et le lancement d’une partie devient : le détail du thème lit useQuestions(slug), tire N
questions au hasard (lib/draw.ts, pur), et start(theme, drawn) — le store de la Partie 6
ne change pas d’un iota. Les couches s’emboîtent.
⚠️ Piège — Résistez à la tentation de copier
datadans un useState ou dans Zustand (« pour l’avoir sous la main »). Le cache Query EST votre état serveur — le dupliquer crée deux sources de vérité qui divergent. La frontière du cours : Query = données distantes, Zustand = état de jeu, SQLite = historique local. La session copie les questions tirées (snapshot de partie), pas la banque.
💡 Pour un dev Next.js — C’est le même TanStack Query que côté client web. Ce qui disparaît : les Server Components/
fetchserveur cache — ici TOUT passe par Query. Ce qui apparaît : online/focusManager mobiles.staleTimeélevé + refetch discrets = le comportement ISR-like, mais dans la poche.
✏️ Exercices
1. Créez lib/draw.ts : drawQuestions(all: Question[], count: number): Question[] —
tirage aléatoire sans doublon, mélange des choix PAS nécessaire (l’ordre des choices est fixe),
mais l’ordre des questions doit être aléatoire. Pure et testable.
✅ Solution
export function drawQuestions(all: Question[], count: number): Question[] {
const pool = [...all]
for (let i = pool.length - 1; i > 0; i--) { // Fisher-Yates
const j = Math.floor(Math.random() * (i + 1))
;[pool[i], pool[j]] = [pool[j], pool[i]]
}
return pool.slice(0, Math.min(count, pool.length))
}Fisher-Yates (mélange uniforme — sort(() => Math.random()-0.5) est biaisé), borné à la
taille de la banque. Pour tester le hasard en P14 : injecter le générateur (rng: () => number
en paramètre optionnel).
2. Le détail d’un thème doit précharger les questions dès son ouverture (pour que « Lancer » soit instantané). Trouvez l’API Query adaptée.
✅ Solution
Rien à faire si le détail appelle déjà useQuestions(slug) (le fetch part au montage — le
bouton Lancer lira le cache). Alternative explicite depuis la LISTE (précharger au tap
« avant » la navigation) : queryClient.prefetchQuery({ queryKey: ['questions', slug], queryFn: … }) dans le onPress. Même clé → le détail trouve la donnée déjà là.
3. Pendant une partie, l’utilisateur pull-to-refresh la liste des thèmes : les questions de la session en cours peuvent-elles changer sous ses pieds ? Argumentez avec l’architecture.
✅ Solution
Non : start() a copié les questions tirées dans le store de session (snapshot). Le
refetch met à jour le CACHE (['questions', slug]), pas la session. Prochaine partie = données
fraîches ; partie en cours = figée. C’est exactement pourquoi la session duplique le tirage —
la seule duplication légitime, car c’est un instantané, pas un miroir.
🧠 Quiz
1. Que garantit une même queryKey utilisée par deux écrans ?
Réponse
Une seule donnée partagée et des fetchs dédupliqués : le second écran lit le cache (et déclenche au plus un refetch si stale) — jamais deux requêtes parallèles identiques.
2. staleTime vs gcTime ?
Réponse
staleTime = durée de fraîcheur (frais → zéro réseau ; stale → servi + refetch en fond).
gcTime = durée de rétention en mémoire d’une donnée inutilisée avant éviction.
3. Quels sont les deux branchements RN spécifiques de Query ?
Réponse
onlineManager ← NetInfo (reconnexion) et focusManager ← AppState (retour au premier plan) —
les équivalents mobiles des événements window du web.
4. Comment obtient-on le pull-to-refresh avec Query ?
Réponse
refreshing={isRefetching} + onRefresh={refetch} sur la FlatList — Query gère l’état et la
requête.
5. Pourquoi ne copie-t-on jamais data dans Zustand — et quelle est l’exception de Drill ?
Réponse
Deux sources de vérité divergent (cache mis à jour, copie périmée). Exception : la session copie les questions tirées — un snapshot de partie voulu figé, pas un miroir de la banque.
👉 Chapitre suivant : 7.3 — Le pattern Drill — le repo drill-questions : schéma, manifest, zod. La pièce maîtresse.