Aller au contenu
Réussir mon BTS SIO

Tape une notion, une commande ou le nom d'une épreuve.

Par exemple

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 + mapFlatList
Éléments créésTousLes visibles et une marge
UsageUn formulaire, une page de contenu de longueur fixeToute liste de données, surtout venue d'une API
Liste vide, en-tête, pied, séparateursÀ coder toi-mêmeProps prêtes à l'emploi
Actualiser en tirant, charger la suiteÀ coder toi-mêmerefreshControl, onEndReached

Afficher la liste des fiches

L'écran src/app/(onglets)/index.tsx charge les fiches de Lucas et les affiche :

tsx
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 :

PropRôle
dataLe tableau à afficher
renderItemLa fonction qui dessine un élément ; elle reçoit { item, index }
keyExtractorLa clé unique de chaque élément, en chaîne : React s'en sert pour savoir quel élément a changé
ItemSeparatorComponentCe qui s'affiche entre deux éléments
ListHeaderComponent, ListFooterComponentCe qui s'affiche avant et après la liste, en défilant avec elle
ListEmptyComponentCe qui s'affiche quand data est vide
refreshControlL'actualisation en tirant la liste vers le bas
onEndReached, onEndReachedThresholdLe 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.

tsx
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.

ts
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 :

tsx
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 :

tsx
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).

tsx
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 :

PropEffetExemple dans AppliFrais
keyboardTypeLe clavier affiché'decimal-pad' pour le montant, 'email-address', 'phone-pad'
secureTextEntryMasque la saisieLe mot de passe
autoCapitalize, autoCorrectMajuscule automatique, correcteur'none' et false pour l'identifiant
autoComplete, textContentTypeAide le gestionnaire de mots de passe à remplir le champ'username', 'current-password'
returnKeyType, onSubmitEditingLa touche Entrée du clavier et son actionValider la connexion depuis le champ mot de passe
maxLengthLongueur maximale100 pour le libellé
multilineZone de texte sur plusieurs lignesUn 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 :

tsx
<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.

ts
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. lireMontant accepte la virgule française, et refuse '1 200', '12,345' ou '-4' en renvoyant null.
  • estDateValide reconstruit 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. Avec Date.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 format AAAA-MM-JJ, la comparaison de chaînes saisie.date > aujourdhui suit 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 :

tsx
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 :

  1. { date: '2026-10-14', libelle: ' Repas client ', montant: '23,50' }
  2. { date: '2026-02-30', libelle: 'Repas', montant: '23,50' }
  3. { date: '14/10/2026', libelle: 'ab', montant: '0' }
  4. { date: '2026-11-03', libelle: 'Taxi gare', montant: '1 200' }
  5. { date: '2026-11-02', libelle: 'Péage', montant: '5000' }
Voir la réponseMasquer la réponse de l'exercice 1
  1. Valide : { date: '2026-10-14', libelle: 'Repas client', montant: 23.5 }. Le libellé est nettoyé de ses espaces, le montant converti.
  2. Erreur sur date (« Date au format AAAA-MM-JJ. ») : le 30 février n'existe pas.
  3. Erreurs sur date (mauvais format), libelle (2 caractères) et montant (0 n'est pas positif : « Entre 0,01 € et 5 000 €. »).
  4. Erreurs sur date (« La date ne peut pas être dans le futur. ») et montant ('1 200' contient une espace : lireMontant renvoie null, « Un montant en euros, par exemple 23,50. »).
  5. 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.

tsx
<ScrollView>
  {frais.map((f) => (
    <LigneFrais frais={f} />
  ))}
</ScrollView>
Voir la réponseMasquer la réponse de l'exercice 2
tsx
<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
tsx
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 par trim)
  • '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é ?

Chapitre terminé !

Fais les 4 exercices avant de passer à la suite : c'est en t'entraînant que tout se fixe.

chapitres
–
de série
–
du cours
–
Chapitre suivant : Appeler une API REST depuis l'application : fetch, jetons et erreursRevoir le sommaire du cours