📍 Article principal du parcours : React Native et Expo : créer une application mobile en 2026
Cette leçon fait partie de la série React Native. Pour la vue d’ensemble, commencez par le guide principal.
Donner une mémoire à l’application
Jusqu’ici, les produits de StockPoche vivaient dans un tableau figé. Une vraie application doit faire mieux : retenir l’état au fil des interactions (un filtre de recherche, un produit sélectionné) et surtout dialoguer avec un serveur — récupérer la liste depuis une API, créer un article, ajuster une quantité. C’est le sujet de cette leçon : l’état local, pour ce qui ne concerne qu’un écran, et l’état serveur, pour les données qui vivent dans une base distante.
Confondre les deux est l’erreur la plus répandue en React Native. On va les séparer proprement : useState et useReducer pour l’état d’interface, le Context pour partager un état entre écrans, et TanStack Query pour tout ce qui vient du réseau — avec son cache, ses rechargements et sa gestion des erreurs offerts. À la fin, la liste de StockPoche se chargera depuis une API et un nouveau produit s’ajoutera en un appel.
🎯 Ce que vous allez apprendre
- Gérer l’état local d’un écran avec
useStateetuseReducer. - Partager un état entre écrans avec le Context React.
- Consommer une API REST avec
fetch, proprement typée. - Utiliser TanStack Query pour le cache, le rechargement et les états de chargement.
- Modifier des données distantes avec une mutation et rafraîchir la liste.
🛠️ Ce que vous allez construire
La couche de données de StockPoche : un champ de recherche qui filtre la liste en temps réel, le chargement des produits depuis une API REST avec indicateur de chargement et gestion d’erreur, et l’ajout d’un produit qui met à jour la liste automatiquement. Plus aucune donnée en dur.
Prérequis
- Le projet StockPoche avec la navigation en place (Navigation avec Expo Router).
- Des bases de React (hooks) — au besoin, révisez les fondamentaux JavaScript.
- Une API REST à interroger : une vraie, ou un service de test comme un point de terminaison JSON public.
- ⏱️ Temps estimé : ~50 minutes.
Étape 1 — L’état local avec useState
L’état local, c’est une donnée qui appartient à un seul composant et déclenche un nouveau rendu quand elle change. Le cas le plus courant dans StockPoche : un champ de recherche. La valeur saisie ne concerne que l’écran d’inventaire ; useState est parfait pour ça.
import { useState } from 'react';
import { TextInput } from 'react-native';
const [recherche, setRecherche] = useState('');
<TextInput
placeholder="Rechercher un article…"
value={recherche}
onChangeText={setRecherche}
style={{ backgroundColor: 'white', padding: 10, borderRadius: 8 }}
/>
useState('') renvoie la valeur courante et une fonction pour la modifier. À chaque frappe, onChangeText appelle setRecherche, React re-rend l’écran avec la nouvelle valeur, et l’on peut filtrer la liste en conséquence. On dérive la liste affichée plutôt que de la stocker — un principe clé : ne mettez dans l’état que ce qui ne peut pas être recalculé.
const produitsFiltres = produits.filter((p) =>
p.nom.toLowerCase().includes(recherche.toLowerCase())
);
✅ Point d’étape — Taper dans le champ filtre la liste instantanément. Si rien ne se filtre, vérifiez que la
FlatListreçoit bienproduitsFiltreset non le tableau d’origine.
Étape 2 — Un état plus riche avec useReducer
Quand l’état devient une structure avec plusieurs transitions — par exemple un panier d’ajustements de stock avec « incrémenter », « décrémenter », « réinitialiser » — useState multiplie les setters et éparpille la logique. useReducer centralise tout dans une fonction pure qui décrit comment l’état évolue selon une action.
import { useReducer } from 'react';
type Action =
| { type: 'incrementer'; id: string }
| { type: 'decrementer'; id: string };
function reducer(etat: Record<string, number>, action: Action) {
switch (action.type) {
case 'incrementer':
return { ...etat, [action.id]: (etat[action.id] ?? 0) + 1 };
case 'decrementer':
return { ...etat, [action.id]: Math.max(0, (etat[action.id] ?? 0) - 1) };
default:
return etat;
}
}
const [stock, dispatch] = useReducer(reducer, {});
// Usage : dispatch({ type: 'incrementer', id: '2' });
Le reducer reçoit l’état courant et une action, et renvoie le nouvel état — toujours en créant un nouvel objet, jamais en mutant l’ancien (d’où le ...etat). Cette discipline rend les changements prévisibles et faciles à tester : la même action sur le même état produit toujours le même résultat. Le Math.max(0, …) empêche une quantité négative.
Étape 3 — Partager l’état entre écrans avec le Context
La liste et la fiche produit ont besoin des mêmes données. Faire descendre l’état par props à travers plusieurs niveaux devient vite pénible. Le Context React expose une valeur à tout un sous-arbre de composants. Créons un contexte pour les produits.
// store/ProduitsContext.tsx
import { createContext, useContext, useState, ReactNode } from 'react';
import { Produit } from '@/data/produits';
type Ctx = { produits: Produit[]; ajouter: (p: Produit) => void };
const ProduitsContext = createContext<Ctx | null>(null);
export function ProduitsProvider({ children }: { children: ReactNode }) {
const [produits, setProduits] = useState<Produit[]>([]);
const ajouter = (p: Produit) => setProduits((liste) => [...liste, p]);
return (
<ProduitsContext.Provider value={{ produits, ajouter }}>
{children}
</ProduitsContext.Provider>
);
}
export function useProduits() {
const ctx = useContext(ProduitsContext);
if (!ctx) throw new Error('useProduits doit être utilisé dans ProduitsProvider');
return ctx;
}
Le Provider enveloppe l’app (on le place dans app/_layout.tsx), et n’importe quel écran appelle useProduits() pour lire la liste ou ajouter un produit. Le garde-fou dans useProduits lève une erreur claire si on l’appelle hors du Provider — une protection qui évite des heures de débogage. Le Context est idéal pour un état partagé de taille modeste ; pour des besoins plus lourds, des bibliothèques comme Zustand prennent le relais.
✅ Point d’étape — La liste et la fiche lisent les mêmes produits via
useProduits(). Si vous obtenez l’erreur du garde-fou, c’est que leProduitsProvidern’enveloppe pas encore vos écrans dans le layout racine.
Étape 4 — Consommer une API REST avec fetch
Place au réseau. fetch est disponible nativement en React Native, sans bibliothèque. Écrivons une fonction qui récupère les produits depuis une API et les renvoie typés. On isole l’appel dans un fichier dédié pour ne pas mélanger réseau et interface.
// api/produits.ts
import { Produit } from '@/data/produits';
const BASE = 'https://api.exemple.com';
export async function chargerProduits(): Promise<Produit[]> {
const reponse = await fetch(`${BASE}/produits`);
if (!reponse.ok) {
throw new Error(`Échec du chargement (HTTP ${reponse.status})`);
}
return reponse.json();
}
Deux points cruciaux. D’abord, fetch ne lève pas d’erreur sur un code HTTP 404 ou 500 — il ne rejette que sur une panne réseau. Il faut donc vérifier reponse.ok soi-même et lever une erreur explicite, sinon une réponse d’erreur du serveur serait traitée comme un succès. Ensuite, reponse.json() renvoie une promesse : on l’attend avec await. Cette fonction est volontairement « bête » : elle ne sait rien de l’affichage, ce qui la rend réutilisable et testable.
Étape 5 — Gérer l’état serveur avec TanStack Query
On pourrait appeler chargerProduits dans un useEffect et stocker le résultat avec useState. Mais il faudrait alors gérer à la main : le chargement, les erreurs, le cache, le rechargement quand l’utilisateur revient sur l’écran, les nouvelles tentatives… C’est exactement ce que TanStack Query automatise. Installez-le et enveloppez l’app d’un client.
npx expo install @tanstack/react-query
// app/_layout.tsx
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
const queryClient = new QueryClient();
export default function RootLayout() {
return (
<QueryClientProvider client={queryClient}>
{/* … Stack, Provider produits … */}
</QueryClientProvider>
);
}
Le QueryClient est le cache central ; le Provider le rend disponible partout. Maintenant, l’écran d’inventaire lit les produits avec useQuery, qui gère tout le cycle de vie de la requête.
import { useQuery } from '@tanstack/react-query';
import { chargerProduits } from '@/api/produits';
const { data, isLoading, isError, error } = useQuery({
queryKey: ['produits'],
queryFn: chargerProduits,
});
if (isLoading) return <ActivityIndicator style={{ marginTop: 40 }} />;
if (isError) return <Text>Erreur : {(error as Error).message}</Text>;
// data contient les produits, mis en cache sous la clé ['produits']
La queryKey identifie la donnée dans le cache : deux écrans qui demandent ['produits'] partagent le même résultat sans re-télécharger. isLoading couvre le premier chargement, isError et error exposent les pannes. Au retour sur l’écran, TanStack Query revalide en arrière-plan et affiche les données en cache pendant ce temps — l’utilisateur ne voit jamais un écran vide. Vous venez de supprimer des dizaines de lignes de code de plomberie.
✅ Point d’étape — La liste affiche un indicateur de chargement, puis les produits venus de l’API. Coupez le réseau et rechargez : le message d’erreur s’affiche proprement au lieu d’un écran figé.
Étape 6 — Ajouter un produit avec une mutation
Lire, c’est bien ; écrire, c’est mieux. Pour créer un produit côté serveur, TanStack Query fournit useMutation. Après succès, on invalide la requête ['produits'] pour que la liste se recharge automatiquement avec le nouvel article.
import { useMutation, useQueryClient } from '@tanstack/react-query';
async function creerProduit(p: Omit<Produit, 'id'>) {
const r = await fetch('https://api.exemple.com/produits', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(p),
});
if (!r.ok) throw new Error('Création impossible');
return r.json();
}
const qc = useQueryClient();
const mutation = useMutation({
mutationFn: creerProduit,
onSuccess: () => qc.invalidateQueries({ queryKey: ['produits'] }),
});
// Dans le formulaire :
mutation.mutate({ nom: 'Pinceau plat 50mm', quantite: 10, prix: 1500 });
L’appel mutation.mutate(...) envoie la requête POST ; à la réussite, invalidateQueries marque la liste comme périmée, ce qui déclenche un rechargement automatique. L’utilisateur ajoute un produit et le voit apparaître sans rien faire de plus. La propriété mutation.isPending permet d’afficher un état « envoi en cours » et de désactiver le bouton pour éviter les doublons.
✅ Point d’étape — Soumettre le formulaire ajoute le produit et la liste se met à jour seule. Si le nouvel article n’apparaît pas, vérifiez que la
queryKeyinvalidée est identique à celle de la requête de lecture.
Étape 7 — Affiner : cache et recherche temporisée
Deux réglages font passer l’app de « ça marche » à « c’est fluide ». D’abord, on dit à TanStack Query combien de temps une donnée reste « fraîche » avant d’être revalidée : un staleTime évite de retélécharger l’inventaire à chaque retour sur l’écran. Ensuite, on tempère la recherche pour ne pas filtrer à chaque frappe quand la liste vient du réseau.
const { data } = useQuery({
queryKey: ['produits'],
queryFn: chargerProduits,
staleTime: 5 * 60 * 1000, // 5 minutes : pas de rechargement inutile
});
Pour la recherche locale, un simple useState suffit ; mais si chaque caractère déclenchait une requête serveur, on saturerait le réseau. La solution est de temporiser (debounce) : n’agir qu’après une courte pause de saisie.
import { useEffect, useState } from 'react';
function useDebounce(valeur: string, delai = 300) {
const [differee, setDifferee] = useState(valeur);
useEffect(() => {
const t = setTimeout(() => setDifferee(valeur), delai);
return () => clearTimeout(t);
}, [valeur, delai]);
return differee;
}
// Usage : const rechercheDifferee = useDebounce(recherche, 300);
Le hook renvoie une valeur qui ne se met à jour que 300 ms après la dernière frappe ; le clearTimeout dans la fonction de nettoyage annule le minuteur précédent à chaque caractère. On filtre alors sur rechercheDifferee : l’interface reste réactive, mais le travail coûteux n’arrive qu’une fois la saisie stabilisée.
✅ Point d’étape — Taper rapidement ne déclenche plus qu’un seul filtrage après la pause. Revenir sur l’écran dans les 5 minutes n’entraîne plus de rechargement réseau visible.
🐞 Pièges fréquents
| Symptôme / erreur | Cause probable | Correctif |
|---|---|---|
| Une erreur serveur (500) passe pour un succès | fetch ne rejette pas sur un code HTTP d’erreur |
Tester reponse.ok et lever une erreur |
| « useProduits doit être utilisé dans… » | Composant hors du Provider | Envelopper l’app avec ProduitsProvider |
| La liste ne se rafraîchit pas après ajout | queryKey invalidée différente de celle lue |
Utiliser exactement la même clé |
| Re-rendus en boucle | Objet recréé à chaque rendu passé en dépendance | Mémoriser avec useMemo/useCallback |
| État qui « saute » après saisie | Donnée dérivée stockée dans l’état au lieu d’être calculée | Dériver à la volée, ne stocker que la source |
🌍 Réalités du terrain
Sur mobile, la connexion est intermittente par nature : ascenseur, sous-sol, zone mal couverte. C’est précisément là que TanStack Query brille : il sert les données en cache pendant qu’il revalide, ce qui donne une app utilisable même quand le réseau vacille. Configurez un staleTime raisonnable pour éviter de retélécharger à chaque ouverture d’écran — un gain direct sur la consommation de données, qui compte quand chaque mégaoctet est facturé.
Pour une application qui doit fonctionner hors connexion — un inventaire consulté dans un entrepôt sans réseau — la donnée serveur ne suffit pas : il faut la persister localement. C’est l’objet de la leçon suivante, qui ajoute le stockage sur l’appareil. En attendant, gardez en tête la règle : l’état serveur (TanStack Query) et l’état persistant local sont deux couches complémentaires, pas concurrentes.
✅ Récapitulatif
StockPoche a maintenant une vraie couche de données. Vous distinguez l’état local (useState, useReducer) de l’état partagé (Context) et de l’état serveur (TanStack Query). Vous consommez une API REST avec fetch en vérifiant reponse.ok, vous affichez chargement et erreurs proprement, et vous créez un produit via une mutation qui rafraîchit la liste automatiquement. La recherche filtre en temps réel. L’app est connectée.
🧾 Aide-mémoire
| Élément | Rôle |
|---|---|
useState |
État local simple d’un composant |
useReducer |
État local structuré avec transitions |
createContext / useContext |
Partager un état entre écrans |
fetch + reponse.ok |
Appel REST avec gestion d’erreur |
useQuery |
Lecture serveur avec cache et états |
useMutation |
Écriture serveur |
invalidateQueries |
Forcer le rechargement d’une donnée |
💪 À vous de jouer
Ajoutez un bouton « Réessayer » sur l’écran d’erreur qui relance la requête sans recharger toute l’application.
Voir une solution
useQuery renvoie une fonction refetch faite pour ça :
const { isError, refetch } = useQuery({ queryKey: ['produits'], queryFn: chargerProduits });
if (isError) {
return (
<View style={{ padding: 16, gap: 12 }}>
<Text>Impossible de charger l'inventaire.</Text>
<Button title="Réessayer" onPress={() => refetch()} />
</View>
);
}
refetch() relance uniquement cette requête et réutilise tout le mécanisme de chargement/erreur déjà en place.
Tutoriels frères
- Accès natif : caméra et stockage — persister les produits hors connexion.
- Composants et style — l’interface qui consomme ces données.
Pour aller plus loin
- 🔝 Retour au guide principal : React Native et Expo : créer une application mobile
- Documentation officielle : TanStack Query
- Pour concevoir l’API côté serveur : Concevoir une API REST
FAQ
Q : Dois-je utiliser TanStack Query ou Redux ?
R : Ce ne sont pas des concurrents. TanStack Query gère l’état serveur (données distantes, cache). Redux ou Zustand gèrent l’état client global complexe. Beaucoup d’apps n’ont besoin que de TanStack Query plus un peu de Context.
Q : Axios ou fetch ?
R : fetch est intégré et suffit pour la plupart des cas. Axios apporte des intercepteurs et une API un peu plus ergonomique ; ajoutez-le seulement si vous en avez le besoin réel, pour ne pas alourdir le bundle.
Q : Comment passer un jeton d’authentification dans les requêtes ?
R : Ajoutez un en-tête Authorization dans les options de fetch (headers: { Authorization: 'Bearer ' + token }). Le jeton lui-même se stocke de façon sécurisée avec expo-secure-store, abordé dans la leçon sur l’accès natif.
Q : Le Context provoque-t-il trop de re-rendus ?
R : Tout composant qui consomme le Context se re-rend quand sa valeur change. Pour un état qui change souvent, séparez les contextes ou passez à une bibliothèque qui ne re-rend que les abonnés concernés, comme Zustand.