Skip to Content
React Native6. État & formulaires6.4 Préférences & AsyncStorage

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;DRAsyncStorage = le localStorage du mobile (clé/valeur string, asynchrone). On ne l’utilise presque jamais à la main : le middleware persist de 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-storage
import 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/persist existe 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.