Skip to Content

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 const

Câ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éseauCacheL’écran Thèmes affiche
fraisDonnées, instantané, zéro réseau
staleDonnées instantané + refetch silencieux (badge discret si MAJ)
videSpinner (isPending) → données
présentDonnées + bandeau « Hors ligne » (ch. 7.1) — l’app marche !
videErrorState + 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, isPending devient 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.