Chapitre 7.4 — Offline-first
Où on en est : le pipeline GitHub → zod → cache tourne… tant que le cache est chaud et l’app allumée. Dernier maillon : que TOUT survive au redémarrage sans réseau. Drill dans le métro.
⏱️ TL;DR — Trois étages : (1) persister le cache Query sur disque (
@tanstack/react-query-persist-client+ AsyncStorage) — les questions téléchargées une fois restent jouables app tuée + mode avion ; (2) une banque de secours bundlée (le thème mélange embarqué dans l’APK) — la toute première ouverture sans réseau fonctionne AUSSI ; (3) des états d’UI honnêtes : Empty/Error/Offline sont des écrans conçus, pas des spinners infinis.
🎯 Objectifs
- Persister le cache TanStack Query dans AsyncStorage (et régler maxAge/buster).
- Embarquer une banque de secours et la câbler en fallback propre.
- Construire les composants d’état : ErrorState, EmptyState, bandeau offline.
- Définir la matrice réseau×cache de Drill (qui affiche quoi, quand).
Étage 1 : persister le cache Query
Le cache Query vit en mémoire : app tuée = cache perdu. La persistance officielle :
pnpm add @tanstack/react-query-persist-client @tanstack/query-async-storage-persister// app/_layout.tsx — remplace QueryClientProvider
import { PersistQueryClientProvider } from '@tanstack/react-query-persist-client'
import { createAsyncStoragePersister } from '@tanstack/query-async-storage-persister'
import AsyncStorage from '@react-native-async-storage/async-storage'
const persister = createAsyncStoragePersister({ storage: AsyncStorage })
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 1000 * 60 * 60, // 1 h : frais → zéro réseau
gcTime: 1000 * 60 * 60 * 24 * 14, // 14 j : ce qui n'est pas GC est persistable
retry: 2,
},
},
})
export default function RootLayout() {
return (
<PersistQueryClientProvider
client={queryClient}
persistOptions={{
persister,
maxAge: 1000 * 60 * 60 * 24 * 14, // au-delà de 14 j, le cache disque est ignoré
buster: 'v1', // change → invalide tout (gros changement de schéma)
}}
>
<Stack … />
</PersistQueryClientProvider>
)
}Le point subtil : gcTime doit couvrir maxAge — une donnée garbage-collectée n’est plus
persistée. Avec ce réglage : questions téléchargées lundi, métro sans réseau vendredi, app
tuée entre-temps → ça joue. Les données sont servies du disque (stale), et Query refetch
silencieusement dès que le réseau revient. C’est tout le pattern offline-first : serve stale,
revalidate when possible.
Étage 2 : la banque de secours bundlée
Cas limite restant : première ouverture de l’app, sans réseau (installation par APK partagé, avion). Cache vide, GitHub inaccessible → l’app serait une coquille. Parade : un mini-thème embarqué dans le bundle :
// assets/fallback-questions.json — 30 questions mélangées, copiées du repo au moment du build
// lib/fallback.ts
import fallback from '../assets/fallback-questions.json'
import { questionSchema, type Question } from './schemas'
import { z } from 'zod'
export const fallbackQuestions: Question[] =
z.array(questionSchema).parse(fallback.questions) // validé AU DÉMARRAGE (dev : crash tôt = bien)
export const fallbackTheme = {
slug: 'melange',
title: 'Mélange (hors ligne)',
emoji: '🎲',
} as constCâblage dans le hook : si useThemes échoue ET n’a rien en cache, l’écran propose au moins le
thème mélange. Un require() de JSON est synchrone et bundlé — disponibilité garantie à 100 %.
(On régénérera ce fichier à chaque release depuis le repo — checklist de build, Partie 12.)
Étage 3 : les états d’UI honnêtes
Un écran de données a QUATRE états, tous conçus :
// components/ui/ErrorState.tsx
export function ErrorState({ onRetry, message }: { onRetry: () => void; message?: string }) {
return (
<View className="flex-1 items-center justify-center gap-4 p-8">
<AppText className="text-4xl">📡</AppText>
<AppText variant="heading" className="text-center">Impossible de charger</AppText>
<AppText variant="caption" className="text-center">
{message ?? 'Vérifie ta connexion — les thèmes déjà téléchargés restent jouables.'}
</AppText>
<Button label="Réessayer" variant="secondary" onPress={onRetry} />
</View>
)
}Et la matrice de Drill — à connaître par cœur, elle vaut pour toutes vos apps :
| Réseau | Cache | L’écran Thèmes affiche |
|---|---|---|
| ✅ | frais | Données, instantané, zéro réseau |
| ✅ | stale | Données instantané + refetch silencieux (badge discret si MAJ) |
| ✅ | vide | Spinner (isPending) → données |
| ❌ | présent | Données + bandeau « Hors ligne » (ch. 7.1) — l’app marche ! |
| ❌ | vide | ErrorState + bouton réessayer + thème mélange bundlé proposé |
La ligne 4 est la philosophie offline-first : hors ligne n’est pas une erreur — c’est un mode de fonctionnement. Seule la ligne 5 est dégradée, et même elle reste jouable.
⚠️ Piège — Avec la persistance,
isPendingdevient RARE (le cache disque répond presque toujours). Ne testez donc jamais vos états avec « ça a l’air de marcher » : coupez vraiment le réseau (mode avion), tuez l’app, videz le stockage (adb shell pm clear), et déroulez la matrice ligne par ligne. Cinq minutes qui valident des mois de tranquillité.
📱 Essaie sur ton téléphone — Le test rituel : ouvrir Drill en WiFi (cache chaud) → mode avion → tuer l’app → rouvrir → jouer une partie complète. Si ça passe, votre pipeline est offline-first. Puis
adb shell pm clear+ mode avion + rouvrir : le thème mélange doit vous sauver.
✏️ Exercices
1. Ajoutez le badge « MAJ disponible » : quand un refetch silencieux ramène un manifest différent de l’affiché, un petit badge sur l’écran Thèmes invite à rafraîchir.
✅ Solution
Query gère déjà la transition : quand le refetch aboutit, data CHANGE et l’écran se met à
jour tout seul — le badge n’est nécessaire que si l’on veut une MAJ explicite (éviter que
la liste bouge sous le doigt). Pattern : notifyOnChangeProps: ['data'] par défaut fait la MAJ
auto ; pour l’explicite, stocker data affiché dans un state local et comparer
updatedAt : différent → badge → tap → setDisplayed(data). Choix UX assumé (Drill : MAJ
auto, les thèmes bougent peu).
2. Le détail d’un thème hors ligne SANS cache pour CE thème (mais manifest en cache) doit afficher un état dédié : « Ce thème n’a pas encore été téléchargé ». Implémentez.
✅ Solution
useQuestions(slug) : isError + useIsOnline() === false → état spécifique :
<ErrorState message="Ce thème n'a jamais été téléchargé. Reconnecte-toi une fois pour le rendre disponible hors ligne." onRetry={refetch} />. La distinction erreur-réseau vs
erreur-serveur (HttpError, ch. 7.1) affine le message. Leçon : les messages d’erreur disent ce
que l’utilisateur PEUT faire.
3. Rédigez le test rituel de la matrice (5 lignes) comme une checklist adb reproductible — elle resservira avant chaque release (P12).
✅ Solution
1. WiFi ON, pm clear → ouvrir : spinner puis thèmes (ligne 3)
2. Rouvrir aussitôt : instantané sans spinner (ligne 1)
3. Attendre > staleTime, rouvrir : instantané + refetch fond (ligne 2 — logs Metro)
4. Mode avion, tuer, rouvrir : thèmes présents + bandeau offline, partie jouable (ligne 4)
5. Mode avion + adb shell pm clear com.alex.drill → ouvrir : ErrorState + mélange jouable (ligne 5)À coller dans le README de Drill — c’est la définition exécutable d’« offline-first ».
🧠 Quiz
1. Quel package persiste le cache Query, et sur quel stockage ?
Réponse
@tanstack/react-query-persist-client + createAsyncStoragePersister (AsyncStorage). Le
PersistQueryClientProvider remplace le provider standard.
2. Pourquoi gcTime doit-il être ≥ maxAge ?
Réponse
La persistance sauvegarde le cache en mémoire : une donnée garbage-collectée (gcTime écoulé) n’existe plus au moment de persister — le maxAge disque ne peut pas couvrir plus loin que la rétention mémoire.
3. Quel cas la banque bundlée couvre-t-elle que la persistance ne couvre pas ?
Réponse
Première ouverture sans réseau (cache disque encore vide) : APK sideloadé dans l’avion, par exemple. Le require() bundlé est disponible à 100 %, par construction.
4. « Hors ligne » est-il un état d’erreur dans Drill ?
Réponse
Non — avec du cache, c’est un mode de fonctionnement (données servies + bandeau). Seul « hors ligne + cache vide » est dégradé, et le fallback bundlé le garde jouable.
5. À quoi sert le buster de persistOptions ?
Réponse
C’est un invalidateur global : changer sa valeur (ex. ‘v2’) fait ignorer TOUT le cache disque existant — l’arme pour les changements de schéma incompatibles.
👉 Partie suivante : Partie 8 — Persistance locale : SQLite — l’historique, les records et les stats de Drill entrent en base.