Chapitre 526 minIntermédiaire
FlatList et formulaires : afficher et saisir des données
Par Josselyn C., prof de BTS SIO. Mis à jour le .
À la fin de ce chapitre, tu sauras
- afficher une liste de données avec FlatList, et dire pourquoi pas map ;
- gérer le chargement, l'erreur, la liste vide et l'actualisation ;
- charger la suite d'une liste paginée au défilement ;
- construire un formulaire adapté au clavier du téléphone ;
- valider une saisie avec une fonction pure et testable.
4 exercices corrigés
FlatList est le composant React Native qui affiche une liste de données en ne créant que les éléments visibles à l'écran, et TextInput est le champ de saisie, piloté par l'état comme un champ contrôlé de React. Ce chapitre construit l'écran « Mes fiches » d'AppliFrais avec une FlatList (actualisation par glissement, liste vide, pagination au défilement), puis le formulaire « Nouveau frais » : champs, clavier, et une fonction de validation séparée de l'écran, que tu testeras au chapitre 11.
Pourquoi FlatList plutôt que map
En React web, tu affiches une liste avec tableau.map(...). En React Native, tu peux faire pareil dans une ScrollView, mais tous les éléments sont alors créés d'un coup, même ceux qui sont loin sous l'écran. Avec 20 fiches, aucune différence ; avec 2 000 frais, l'écran met plusieurs secondes à s'afficher et la mémoire du téléphone sature.
FlatList est virtualisée : elle ne crée que les éléments visibles, plus une marge au-dessus et au-dessous, et recycle les autres pendant le défilement.
ScrollView + map | FlatList | |
|---|---|---|
| Éléments créés | Tous | Les visibles et une marge |
| Usage | Un formulaire, une page de contenu de longueur fixe | Toute liste de données, surtout venue d'une API |
| Liste vide, en-tête, pied, séparateurs | À coder toi-même | Props prêtes à l'emploi |
| Actualiser en tirant, charger la suite | À coder toi-même | refreshControl, onEndReached |
Afficher la liste des fiches
L'écran src/app/(onglets)/index.tsx charge les fiches de Lucas et les affiche :
import { useFocusEffect } from 'expo-router';
import { useCallback, useState } from 'react';
import { ActivityIndicator, FlatList, RefreshControl, StyleSheet, Text, View } from 'react-native';
import { CarteFiche } from '@/components/CarteFiche';
import type { ResumeFiche } from '@/lib/api';
import { api } from '@/lib/client';
export default function MesFiches() {
const [fiches, setFiches] = useState<ResumeFiche[] | null>(null);
const [erreur, setErreur] = useState<string | null>(null);
const [actualisation, setActualisation] = useState(false);
const charger = useCallback(async () => {
try {
const { donnees } = await api.listerFiches();
setFiches(donnees);
setErreur(null);
} catch (e) {
setErreur((e as Error).message);
}
}, []);
// Recharge à chaque retour sur l'onglet (après l'ajout d'un frais, par exemple).
useFocusEffect(
useCallback(() => {
charger();
}, [charger]),
);
async function actualiser() {
setActualisation(true);
await charger();
setActualisation(false);
}
if (fiches === null && erreur === null) {
return <ActivityIndicator style={styles.centre} size="large" />;
}
return (
<FlatList
data={fiches ?? []}
keyExtractor={(fiche) => String(fiche.id)}
renderItem={({ item }) => <CarteFiche fiche={item} />}
ItemSeparatorComponent={() => <View style={styles.separateur} />}
ListHeaderComponent={erreur ? <Text style={styles.erreur}>{erreur}</Text> : undefined}
ListEmptyComponent={erreur ? undefined : <Text style={styles.vide}>Aucune fiche pour l'instant.</Text>}
refreshControl={<RefreshControl refreshing={actualisation} onRefresh={actualiser} />}
contentContainerStyle={styles.contenu}
/>
);
}
const styles = StyleSheet.create({
centre: { flex: 1 },
contenu: { padding: 16 },
separateur: { height: 8 },
erreur: { color: '#b91c1c', marginBottom: 12 },
vide: { textAlign: 'center', color: '#6b7280', marginTop: 32 },
});
L'objet api et le type ResumeFiche viennent du client d'API du chapitre 6. En attendant de l'écrire, tu peux remplacer api.listerFiches() par une promesse qui renvoie des données en dur : Promise.resolve({ donnees: [{ id: 12, mois: '2026-10', etat: 'CR', montantValide: null }] }).
Les props de FlatList :
| Prop | Rôle |
|---|---|
data | Le tableau à afficher |
renderItem | La fonction qui dessine un élément ; elle reçoit { item, index } |
keyExtractor | La clé unique de chaque élément, en chaîne : React s'en sert pour savoir quel élément a changé |
ItemSeparatorComponent | Ce qui s'affiche entre deux éléments |
ListHeaderComponent, ListFooterComponent | Ce qui s'affiche avant et après la liste, en défilant avec elle |
ListEmptyComponent | Ce qui s'affiche quand data est vide |
refreshControl | L'actualisation en tirant la liste vers le bas |
onEndReached, onEndReachedThreshold | Le chargement de la suite quand on approche du bas |
Trois états à gérer, dans tout écran qui charge des données : le chargement (une roue, tant que rien n'est arrivé), l'erreur (un message, sans effacer la liste déjà affichée si c'est une actualisation qui échoue) et la liste vide (un message plutôt qu'un écran blanc qui fait croire à un bogue).
La carte d'une fiche
Chaque élément est une CarteFiche, dans src/components/CarteFiche.tsx. Elle affiche le mois, l'état dans une pastille colorée et, si elle est validée, le montant ; un appui ouvre le détail de la fiche.
import { Link } from 'expo-router';
import { Pressable, StyleSheet, Text, View } from 'react-native';
import type { ResumeFiche } from '@/lib/api';
import { formatEuros, formatMois, LIBELLES_ETAT } from '@/lib/format';
type Props = { fiche: ResumeFiche };
export function CarteFiche({ fiche }: Props) {
const etat = LIBELLES_ETAT[fiche.etat];
return (
<Link href={{ pathname: '/fiche/[id]', params: { id: fiche.id } }} asChild>
<Pressable
accessibilityRole="button"
accessibilityLabel={`Fiche ${formatMois(fiche.mois)}, ${etat}`}
style={({ pressed }) => [styles.carte, pressed && styles.appuyee]}>
<View style={styles.ligne}>
<Text style={styles.mois}>{formatMois(fiche.mois)}</Text>
<Text style={[styles.badge, styles[fiche.etat]]}>{etat}</Text>
</View>
{fiche.montantValide !== null && (
<Text style={styles.montant}>Montant validé : {formatEuros(fiche.montantValide)}</Text>
)}
</Pressable>
</Link>
);
}
const styles = StyleSheet.create({
carte: { backgroundColor: '#ffffff', borderRadius: 12, padding: 16, gap: 8, elevation: 1 },
appuyee: { opacity: 0.7 },
ligne: { flexDirection: 'row', justifyContent: 'space-between', alignItems: 'center' },
mois: { fontSize: 17, fontWeight: '600', textTransform: 'capitalize' },
badge: { fontSize: 12, paddingHorizontal: 8, paddingVertical: 2, borderRadius: 999, overflow: 'hidden' },
CR: { backgroundColor: '#dbeafe', color: '#1e3a8a' },
CL: { backgroundColor: '#fef3c7', color: '#78350f' },
VA: { backgroundColor: '#dcfce7', color: '#14532d' },
MP: { backgroundColor: '#e5e7eb', color: '#111827' },
montant: { fontSize: 15, color: '#374151' },
});
Les fonctions de mise en forme sont regroupées dans src/lib/format.ts, sans aucun import de React Native : elles se testent seules.
import type { Etat } from './api';
export const LIBELLES_ETAT: Record<Etat, string> = {
CR: 'En cours de saisie',
CL: 'Clôturée',
VA: 'Validée',
MP: 'Mise en paiement',
};
const MOIS = ['janvier', 'février', 'mars', 'avril', 'mai', 'juin', 'juillet', 'août', 'septembre', 'octobre', 'novembre', 'décembre'];
// "2026-10" → "octobre 2026"
export function formatMois(mois: string): string {
const [annee, numero] = mois.split('-');
return `${MOIS[Number(numero) - 1]} ${annee}`;
}
// 23.5 → "23,50 €"
export function formatEuros(montant: number): string {
return `${montant.toFixed(2).replace('.', ',')} €`;
}
// La date du jour sur le téléphone, au format AAAA-MM-JJ (pas toISOString, qui donne la date UTC).
export function aujourdhui(maintenant: Date = new Date()): string {
const mm = String(maintenant.getMonth() + 1).padStart(2, '0');
const jj = String(maintenant.getDate()).padStart(2, '0');
return `${maintenant.getFullYear()}-${mm}-${jj}`;
}
Record<Etat, string> oblige à donner un libellé pour chacun des quatre états : si un cinquième état apparaît dans le type Etat, TypeScript signale l'oubli ici.
Charger la suite : la pagination au défilement
L'API renvoie les fiches par pages de 20, avec le nombre total de pages (pagination d'une API REST). Pour charger la page suivante quand Lucas approche du bas de la liste :
export function ListePaginee() {
const [fiches, setFiches] = useState<ResumeFiche[]>([]);
const [page, setPage] = useState(1);
const [pages, setPages] = useState(1);
const [chargement, setChargement] = useState(false);
const chargerPage = useCallback(async (numero: number) => {
setChargement(true);
try {
const reponse = await api.listerFiches(numero);
setFiches((avant) => (numero === 1 ? reponse.donnees : [...avant, ...reponse.donnees]));
setPage(numero);
setPages(reponse.pagination.pages);
} finally {
setChargement(false);
}
}, []);
useEffect(() => {
chargerPage(1);
}, [chargerPage]);
return (
<FlatList
data={fiches}
keyExtractor={(f) => String(f.id)}
renderItem={({ item }) => <CarteFiche fiche={item} />}
onEndReached={() => {
if (!chargement && page < pages) chargerPage(page + 1);
}}
onEndReachedThreshold={0.5}
ListFooterComponent={chargement ? <ActivityIndicator /> : undefined}
/>
);
}
onEndReachedThreshold={0.5} déclenche le chargement quand il reste une demi-hauteur d'écran à défiler. La condition !chargement && page < pages est indispensable : onEndReached peut être appelé plusieurs fois de suite, et sans elle la même page serait chargée deux fois.
Grouper avec SectionList
Pour une liste découpée en sections avec un titre chacune (les fiches par année, les contacts par lettre), SectionList prend un tableau de sections :
export function FichesParAnnee({ fiches }: { fiches: ResumeFiche[] }) {
const annees = [...new Set(fiches.map((f) => f.mois.slice(0, 4)))];
const sections = annees.map((annee) => ({ title: annee, data: fiches.filter((f) => f.mois.startsWith(annee)) }));
return (
<SectionList
sections={sections}
keyExtractor={(f) => String(f.id)}
renderItem={({ item }) => <Text>{formatMois(item.mois)}</Text>}
renderSectionHeader={({ section }) => <Text style={styles.titre}>{section.title}</Text>}
/>
);
}
Les champs de saisie : TextInput
TextInput fonctionne comme un champ contrôlé de React : la valeur affichée vient de l'état (value), et chaque frappe met l'état à jour (onChangeText, qui reçoit directement le texte, pas un événement).
const [libelle, setLibelle] = useState('');
<TextInput value={libelle} onChangeText={setLibelle} placeholder="Repas client" />
Sur un téléphone, le clavier fait partie du formulaire. Les props qui le règlent :
| Prop | Effet | Exemple dans AppliFrais |
|---|---|---|
keyboardType | Le clavier affiché | 'decimal-pad' pour le montant, 'email-address', 'phone-pad' |
secureTextEntry | Masque la saisie | Le mot de passe |
autoCapitalize, autoCorrect | Majuscule automatique, correcteur | 'none' et false pour l'identifiant |
autoComplete, textContentType | Aide le gestionnaire de mots de passe à remplir le champ | 'username', 'current-password' |
returnKeyType, onSubmitEditing | La touche Entrée du clavier et son action | Valider la connexion depuis le champ mot de passe |
maxLength | Longueur maximale | 100 pour le libellé |
multiline | Zone de texte sur plusieurs lignes | Un commentaire |
Le clavier recouvre le bas de l'écran. Pour qu'il ne cache pas le champ en cours de saisie ni le bouton « Enregistrer », entoure le formulaire d'un KeyboardAvoidingView et d'une ScrollView :
<KeyboardAvoidingView style={{ flex: 1 }} behavior={Platform.OS === 'ios' ? 'padding' : undefined}>
<ScrollView contentContainerStyle={{ padding: 16, gap: 12 }} keyboardShouldPersistTaps="handled">
{/* … les champs et le bouton … */}
</ScrollView>
</KeyboardAvoidingView>
Sur iOS, behavior="padding" ajoute une marge de la hauteur du clavier ; sur Android, le système redimensionne déjà la fenêtre. keyboardShouldPersistTaps="handled" permet de toucher le bouton « Enregistrer » sans que le premier appui serve seulement à fermer le clavier.
Valider la saisie
Les règles de saisie d'un frais hors forfait d'AppliFrais : un libellé de 3 à 100 caractères, un montant positif d'au plus 5 000 € avec au plus deux décimales, une date valide qui n'est pas dans le futur. Plutôt que de les écrire dans l'écran, on les place dans une fonction pure de src/lib/validation.ts : elle reçoit la saisie, renvoie le frais nettoyé ou les erreurs, et ne dépend ni de React ni du téléphone.
export type SaisieFrais = { date: string; libelle: string; montant: string };
export type Frais = { date: string; libelle: string; montant: number };
export type Erreurs = Partial<Record<keyof SaisieFrais, string>>;
export function lireMontant(texte: string): number | null {
const normalise = texte.trim().replace(',', '.');
if (!/^\d+(\.\d{1,2})?$/.test(normalise)) return null;
return Number(normalise);
}
export function estDateValide(texte: string): boolean {
if (!/^\d{4}-\d{2}-\d{2}$/.test(texte)) return false;
const [annee, mois, jour] = texte.split('-').map(Number);
const d = new Date(Date.UTC(annee, mois - 1, jour));
return d.getUTCFullYear() === annee && d.getUTCMonth() === mois - 1 && d.getUTCDate() === jour;
}
export function validerFrais(saisie: SaisieFrais, aujourdhui: string): { frais?: Frais; erreurs: Erreurs } {
const erreurs: Erreurs = {};
const libelle = saisie.libelle.trim();
if (libelle.length < 3) erreurs.libelle = 'Au moins 3 caractères.';
else if (libelle.length > 100) erreurs.libelle = '100 caractères au maximum.';
const montant = lireMontant(saisie.montant);
if (montant === null) erreurs.montant = 'Un montant en euros, par exemple 23,50.';
else if (montant <= 0 || montant > 5000) erreurs.montant = 'Entre 0,01 € et 5 000 €.';
if (!estDateValide(saisie.date)) {
erreurs.date = 'Date au format AAAA-MM-JJ.';
} else if (saisie.date > aujourdhui) {
erreurs.date = 'La date ne peut pas être dans le futur.';
}
if (Object.keys(erreurs).length > 0) return { erreurs };
return { frais: { date: saisie.date, libelle, montant: montant as number }, erreurs };
}
Quatre choix à comprendre :
- La saisie reste du texte jusqu'à la validation : un champ contient
'23,50', pas un nombre.lireMontantaccepte la virgule française, et refuse'1 200','12,345'ou'-4'en renvoyantnull. estDateValidereconstruit la date et vérifie qu'elle n'a pas « glissé ».Date.parse('2026-02-30')ne renvoie pas d'erreur dans le moteur V8 : il donne le 2 mars. AvecDate.UTC(2026, 1, 30), on obtient aussi le 2 mars, mais le jour (2) ne correspond plus au jour saisi (30) : la date est refusée, quel que soit le moteur JavaScript.- La date du jour est un paramètre (
aujourdhui) au lieu d'un appel ànew Date()dans la fonction : le test peut fixer la date, et le résultat ne dépend pas du jour où il tourne. Comme les dates sont au formatAAAA-MM-JJ, la comparaison de chaînessaisie.date > aujourdhuisuit l'ordre chronologique. - Toutes les erreurs sont renvoyées en une fois, champ par champ, pour être affichées sous chaque champ.
Le formulaire « Nouveau frais »
L'écran src/app/fiche/[id]/frais.tsx assemble les champs du chapitre 3 et la validation. Dans cette première version, sans API, un enregistrement valide affiche simplement une alerte :
export function NouveauFraisSansApi() {
const [saisie, setSaisie] = useState<SaisieFrais>({ date: aujourdhui(), libelle: '', montant: '' });
const [erreurs, setErreurs] = useState<Erreurs>({});
const modifier = (champ: keyof SaisieFrais) => (texte: string) => setSaisie((s) => ({ ...s, [champ]: texte }));
function enregistrer() {
const { frais, erreurs: trouvees } = validerFrais(saisie, aujourdhui());
setErreurs(trouvees);
if (frais) Alert.alert('Frais valide', `${frais.libelle} : ${frais.montant} €`);
}
return (
<View style={{ padding: 16 }}>
<Champ libelle="Date (AAAA-MM-JJ)" value={saisie.date} onChangeText={modifier('date')} erreur={erreurs.date} />
<Champ libelle="Libellé" value={saisie.libelle} onChangeText={modifier('libelle')} erreur={erreurs.libelle} />
<Champ
libelle="Montant (€)"
value={saisie.montant}
onChangeText={modifier('montant')}
erreur={erreurs.montant}
keyboardType="decimal-pad"
/>
<Bouton titre="Enregistrer" onPress={enregistrer} />
</View>
);
}
La saisie est un seul objet d'état, et modifier('libelle') fabrique la fonction qui met à jour le champ libelle. Le formulaire est prérempli avec la date du jour, le cas le plus fréquent. La version finale, qui envoie le frais à l'API, joint la photo du ticket et garde le frais en attente hors réseau, est au chapitre 6 et au chapitre 8.
Exercice 1 sur 4 Que renvoie la validation ? Réussi À revoir
Avec aujourdhui = '2026-11-02', donne le résultat de validerFrais (le frais, ou la liste des champs en erreur) pour chaque saisie :
{ date: '2026-10-14', libelle: ' Repas client ', montant: '23,50' }{ date: '2026-02-30', libelle: 'Repas', montant: '23,50' }{ date: '14/10/2026', libelle: 'ab', montant: '0' }{ date: '2026-11-03', libelle: 'Taxi gare', montant: '1 200' }{ date: '2026-11-02', libelle: 'Péage', montant: '5000' }
Voir la réponseMasquer la réponse de l'exercice 1
- Valide :
{ date: '2026-10-14', libelle: 'Repas client', montant: 23.5 }. Le libellé est nettoyé de ses espaces, le montant converti. - Erreur sur date (« Date au format AAAA-MM-JJ. ») : le 30 février n'existe pas.
- Erreurs sur date (mauvais format), libelle (2 caractères) et montant (0 n'est pas positif : « Entre 0,01 € et 5 000 €. »).
- Erreurs sur date (« La date ne peut pas être dans le futur. ») et montant (
'1 200'contient une espace :lireMontantrenvoienull, « Un montant en euros, par exemple 23,50. »). - Valide :
{ date: '2026-11-02', libelle: 'Péage', montant: 5000 }. La date du jour n'est pas dans le futur, et 5 000 est la borne incluse.
Tu avais trouvé ?
Exercice 2 sur 4 Corriger une liste Réussi À revoir
Ce code affiche les 1 500 frais de l'année. Il est lent et produit un avertissement sur les clés. Corrige-le.
<ScrollView>
{frais.map((f) => (
<LigneFrais frais={f} />
))}
</ScrollView>
Voir la réponseMasquer la réponse de l'exercice 2
<FlatList
data={frais}
keyExtractor={(f) => String(f.id)}
renderItem={({ item }) => <LigneFrais frais={item} />}
ListEmptyComponent={<Text>Aucun frais.</Text>}
/>
FlatList ne crée que les lignes visibles au lieu des 1 500, et keyExtractor donne à chaque ligne une clé stable (l'identifiant), ce qui supprime l'avertissement. Avec map, il aurait fallu au moins key={f.id} sur LigneFrais. Ne prends jamais l'index comme clé dans une liste qui peut changer : après une suppression, les clés se décalent et React met à jour les mauvaises lignes.
Tu avais trouvé ?
Exercice 3 sur 4 Filtrer par état Réussi À revoir
Ajoute en haut de l'écran « Mes fiches » trois boutons « Toutes », « En cours », « Validées » ; la liste n'affiche que les fiches correspondantes. Le filtre se fait dans l'application, sur les fiches déjà chargées.
Voir la réponseMasquer la réponse de l'exercice 3
type Filtre = 'toutes' | 'CR' | 'VA';
const [filtre, setFiltre] = useState<Filtre>('toutes');
const visibles = (fiches ?? []).filter((f) => filtre === 'toutes' || f.etat === filtre);
const entete = (
<View style={{ flexDirection: 'row', gap: 8, marginBottom: 12 }}>
<Bouton titre="Toutes" variante={filtre === 'toutes' ? 'principal' : 'secondaire'} onPress={() => setFiltre('toutes')} />
<Bouton titre="En cours" variante={filtre === 'CR' ? 'principal' : 'secondaire'} onPress={() => setFiltre('CR')} />
<Bouton titre="Validées" variante={filtre === 'VA' ? 'principal' : 'secondaire'} onPress={() => setFiltre('VA')} />
</View>
);
// dans le JSX : data={visibles} et ListHeaderComponent={entete}
La liste filtrée est calculée à chaque rendu à partir de l'état, pas stockée dans un second état : il n'y a ainsi qu'une source de vérité. Le bouton actif utilise la variante pleine. Si la liste devenait très longue, ce filtre irait dans la requête (GET /v1/fiches?etat=VA) pour ne pas tout charger.
Tu avais trouvé ?
Exercice 4 sur 4 Lire un montant Réussi À revoir
Que renvoie lireMontant pour '23,5', '0,99', '12.345', ' 8 ' et '1e3' ? Laquelle de ces réponses pourrait surprendre un utilisateur, et que proposes-tu ?
Voir la réponseMasquer la réponse de l'exercice 4
'23,5'→23.5'0,99'→0.99'12.345'→null(trois décimales)' 8 '→8(les espaces autour sont retirés partrim)'1e3'→null(l'expression régulière n'accepte que des chiffres, une virgule ou un point, puis deux chiffres au plus)
'12.345' peut surprendre : un utilisateur qui pense « douze mille trois cent quarante-cinq » avec un point des milliers voit un refus. Le message d'erreur (« Un montant en euros, par exemple 23,50. ») et le clavier decimal-pad, qui n'offre pas d'espace, limitent la confusion. Tu peux aussi empêcher l'erreur à la frappe, en ne gardant que les chiffres et un seul séparateur décimal.
Tu avais trouvé ?