Chapitre 6.4 — Préférences & AsyncStorage
Où on en est : Drill est jouable, mais amnésique — durée du timer et réglages meurent avec l’app. On branche la persistance légère : AsyncStorage + le middleware persist de Zustand.
⏱️ TL;DR — AsyncStorage = le localStorage du mobile (clé/valeur string, asynchrone). On ne l’utilise presque jamais à la main : le middleware
persistde Zustand sauvegarde/réhydrate le store de réglages automatiquement. On y range : durée du timer, sons/haptics, choix de thème (système/sombre/clair — la promesse du chapitre 4.3). Règle de partage : préférences → AsyncStorage ; données structurées/volumineuses → SQLite (Partie 8).
🎯 Objectifs
- Comprendre AsyncStorage (API, limites, quand NE PAS l’utiliser).
- Créer le store de réglages persisté avec le middleware Zustand.
- Brancher le choix manuel de thème (le useTheme du ch. 4.3 tient sa promesse).
- Gérer l’hydratation (le démarrage lit le disque : et pendant ce temps ?).
AsyncStorage en 60 secondes
npx expo install @react-native-async-storage/async-storageimport AsyncStorage from '@react-native-async-storage/async-storage'
await AsyncStorage.setItem('lastTheme', 'symfony')
const v = await AsyncStorage.getItem('lastTheme') // string | null
await AsyncStorage.removeItem('lastTheme')Trois différences avec localStorage : asynchrone (le stockage est sur disque, pas en
mémoire), non chiffré (jamais de secrets — pour un token : expo-secure-store), et pensé
pour de petites valeurs (préférences, flags — pas des mégaoctets de JSON : les données de
jeu iront en SQLite, Partie 8).
Le store de réglages persisté
À la main, il faudrait : lire au démarrage, parser, écrire à chaque changement, gérer les erreurs. Le middleware fait tout :
// stores/settings.ts
import { create } from 'zustand'
import { persist, createJSONStorage } from 'zustand/middleware'
import AsyncStorage from '@react-native-async-storage/async-storage'
export type ThemeChoice = 'system' | 'dark' | 'light'
type SettingsState = {
timerSeconds: number // 5-60, défaut 20
soundEnabled: boolean
hapticsEnabled: boolean
themeChoice: ThemeChoice
dailyReminder: boolean // le défi quotidien (Partie 9 le consommera)
setTimerSeconds: (n: number) => void
toggleSound: () => void
toggleHaptics: () => void
setThemeChoice: (c: ThemeChoice) => void
setDailyReminder: (on: boolean) => void
}
export const useSettings = create<SettingsState>()(
persist(
set => ({
timerSeconds: 20,
soundEnabled: true,
hapticsEnabled: true,
themeChoice: 'system',
dailyReminder: false,
setTimerSeconds: n => set({ timerSeconds: Math.min(60, Math.max(5, Math.round(n))) }),
toggleSound: () => set(s => ({ soundEnabled: !s.soundEnabled })),
toggleHaptics: () => set(s => ({ hapticsEnabled: !s.hapticsEnabled })),
setThemeChoice: themeChoice => set({ themeChoice }),
setDailyReminder: dailyReminder => set({ dailyReminder }),
}),
{
name: 'drill-settings', // la clé AsyncStorage
storage: createJSONStorage(() => AsyncStorage),
version: 1, // migrations futures (cf. exercice 3)
}
)
)C’est tout : chaque set écrit sur disque (débouncé), chaque démarrage réhydrate. Le store de
session (ch. 6.2-6.3), lui, reste volontairement NON persisté : une partie interrompue par
un kill d’app est perdue — choix de design assumé (et un exercice de P19 le remettra en cause).
Brancher le thème manuel — la promesse du 4.3
// hooks/useTheme.ts — version finale
import { useColorScheme } from 'react-native'
import { useSettings } from '../stores/settings'
import { darkTheme, lightTheme } from '../constants/theme'
export function useThemeMode(): 'dark' | 'light' {
const system = useColorScheme()
const choice = useSettings(s => s.themeChoice)
if (choice === 'system') return system === 'light' ? 'light' : 'dark'
return choice
}
export function useTheme() {
return useThemeMode() === 'light' ? lightTheme : darkTheme
}Comme promis : aucun composant ne change — ils parlent aux rôles. Avec NativeWind
(darkMode piloté), on synchronise le mode via useColorScheme de nativewind — même logique,
la doc NativeWind donne le branchement setColorScheme(choice) à appeler dans setThemeChoice.
L’écran Réglages assemble le tout (Switch = le toggle natif) :
// app/(tabs)/settings.tsx — extrait
const hapticsEnabled = useSettings(s => s.hapticsEnabled)
const toggleHaptics = useSettings(s => s.toggleHaptics)
<Row className="justify-between py-3">
<AppText>Vibrations</AppText>
<Switch value={hapticsEnabled} onValueChange={toggleHaptics}
trackColor={{ true: '#087ea4' }} />
</Row>Et la boucle se ferme : useCountdown lit désormais useSettings.getState().timerSeconds au
startQuestion — le réglage change réellement le jeu.
L’hydratation — la subtilité du démarrage
persist lit AsyncStorage de façon asynchrone : pendant quelques millisecondes au
démarrage, le store affiche les valeurs par défaut. Pour Drill c’est invisible (un Switch qui
« clignote » au pire). Le jour où ça compte (thème qui flashe, écran dépendant d’un réglage) :
const hydrated = useSettings.persist.hasHydrated()
if (!hydrated) return <SplashPlaceholder /> // ou garder le splash screen (Partie 12)⚠️ Piège — Ne stockez JAMAIS dans AsyncStorage : des secrets (non chiffré → expo-secure-store), l’historique de jeu (croissance non bornée + requêtes impossibles → SQLite), ou des objets à références circulaires (JSON.stringify explose). La frontière saine : AsyncStorage = ce que vous mettriez dans un
.config; SQLite = ce que vous mettriez dans une base.
💡 Pour un dev Next.js — localStorage → AsyncStorage : même modèle clé/valeur, mais async et hors thread UI. Le combo
zustand/persistexiste identique sur le web — si vous l’adoptez ici, vous l’emporterez dans vos projets Next.js. Et remarquez : toujours pas un seul octet sur un serveur — tout ce chapitre est 100 % local, ADR 0001 respecté.
✏️ Exercices
1. Construisez l’écran Réglages complet : durée du timer (5/10/20/30 en Badges sélectionnables), 3 Switch (son, vibrations, rappel quotidien), choix de thème (3 boutons segmentés), et un bouton « Réinitialiser » avec Alert de confirmation.
✅ Solution
Chaque contrôle : sélecteur fin + action du store. Le segmented : trois Pressable dans une Row,
le choix actif en bg-brand. Réinitialiser :
Alert.alert('Réinitialiser ?', 'Les réglages reviendront aux valeurs par défaut.', [{ text: 'Annuler', style: 'cancel' }, { text: 'OK', onPress: () => useSettings.setState({ timerSeconds: 20, soundEnabled: true, hapticsEnabled: true, themeChoice: 'system', dailyReminder: false }) }]) — l’Alert natif du ch. 5.4 pour un cas
secondaire.
2. Le haptic du AnswerButton (exercice 3.3) doit désormais respecter hapticsEnabled.
Modifiez SANS ajouter de prop au composant.
✅ Solution
Créer lib/haptics.ts :
import * as Haptics from 'expo-haptics'
import { useSettings } from '../stores/settings'
export function tapFeedback() {
if (useSettings.getState().hapticsEnabled)
Haptics.impactAsync(Haptics.ImpactFeedbackStyle.Light)
}Le composant appelle tapFeedback() — le réglage est consulté par getState() au moment du
tap, hors React. Wrapper centralisé = un seul endroit à modifier (P9 l’étoffera).
3. En v2, timerSeconds devient timerMs. Écrivez la migration persist (version 1 → 2)
qui convertit les données existantes des utilisateurs.
✅ Solution
persist(config, {
name: 'drill-settings',
storage: createJSONStorage(() => AsyncStorage),
version: 2,
migrate: (state: any, fromVersion) => {
if (fromVersion < 2 && state && typeof state.timerSeconds === 'number') {
state.timerMs = state.timerSeconds * 1000
delete state.timerSeconds
}
return state
},
})version + migrate : le pattern officiel — les réglages des utilisateurs existants survivent
aux refontes. (Le même concept, en SQL, structure la Partie 8.)
🧠 Quiz
1. Trois différences AsyncStorage vs localStorage ?
Réponse
Asynchrone (Promises), non chiffré mais hors sandbox web (fichiers de l’app), et réservé aux petites valeurs clé/string. Secrets → expo-secure-store ; données → SQLite.
2. Que fait le middleware persist exactement ?
Réponse
Il sérialise le store en JSON sous une clé AsyncStorage à chaque changement et le réhydrate au démarrage — avec versionnage/migrations et l’API hasHydrated pour le démarrage.
3. Pourquoi le store de session n’est-il PAS persisté ?
Réponse
Choix de design : une partie est éphémère — la restaurer après un kill (timer expiré depuis longtemps, contexte perdu) créerait des états douteux. La frontière : settings persistés, session volatile, historique en SQLite.
4. Comment le choix manuel de thème s’ajoute-t-il sans toucher les composants ?
Réponse
Les composants consomment des rôles via useTheme (ch. 4.3) : on ne change QUE le hook — il combine maintenant useColorScheme (système) et themeChoice (persisté).
5. Qu’est-ce que l’hydratation, et quand s’en soucier ?
Réponse
La lecture asynchrone du disque au démarrage : quelques ms où le store montre ses défauts. S’en soucier si l’UI initiale en dépend visiblement (flash de thème) → hasHydrated + splash.
👉 Partie suivante : Partie 7 — Réseau & offline — les questions arrêtent d’être en dur : fetch depuis GitHub, cache, offline. Le pattern central de Drill.