Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Chapitre 515 minIntermédiaire

Listes, clés et rendu conditionnel

Par Josselyn C., prof de BTS SIO. Mis à jour le .

À la fin de ce chapitre, tu sauras

  • afficher un tableau de données avec map ;
  • choisir une clé unique et stable pour chaque élément ;
  • afficher un élément sous condition avec un retour anticipé, un ternaire ou && ;
  • éviter le piège du zéro et les écrans blancs.

4 exercices corrigés

Pour afficher une liste en React, on transforme un tableau de données en tableau d'éléments JSX avec map, et on donne à chaque élément une clé (key) unique et stable. Pour afficher un élément sous condition, on utilise un if avant le return, l'opérateur ternaire ou &&. Ce chapitre détaille ces techniques, le rôle exact des clés et les pièges classiques, dont celui du zéro.

Afficher une liste avec map

JSX n'a pas de boucle for. On utilise la méthode map des tableaux, qui renvoie un nouveau tableau, ici un tableau d'éléments que React affiche à la suite :

tsx
import type { FicheResume } from '../types';
import { CarteFiche } from './CarteFiche';

type ListeFichesProps = {
  fiches: FicheResume[];
};

export function ListeFiches({ fiches }: ListeFichesProps) {
  return (
    <ul className="liste-fiches">
      {fiches.map((fiche) => (
        <li key={fiche.id}>
          <CarteFiche fiche={fiche} />
        </li>
      ))}
    </ul>
  );
}

Pour trois fiches, map produit trois <li>. Note les parenthèses après la flèche : (fiche) => ( … ) renvoie directement le JSX. Avec des accolades, il faudrait écrire return : (fiche) => { return <li>…</li>; }. Oublier ce return est une erreur fréquente, qui affiche une liste vide sans aucun message.

On peut enchaîner filter, toSorted et map pour préparer la liste :

tsx
{fiches
  .filter((f) => f.etat !== 'MP')                      // sans les fiches déjà mises en paiement
  .toSorted((a, b) => b.mois.localeCompare(a.mois))    // la plus récente d'abord
  .map((f) => <li key={f.id}>{f.mois}</li>)}

toSorted renvoie une copie triée sans modifier le tableau d'origine, qui appartient peut-être au parent (les props sont en lecture seule, chapitre 3). Les mois au format "2026-10" se trient correctement comme des chaînes, car l'année vient en premier et le mois a toujours deux chiffres.

À quoi servent les clés

Quand la liste change (un frais ajouté au début, un autre supprimé), React doit savoir quel élément de la nouvelle liste correspond à quel élément de l'ancienne. La clé sert d'identifiant :

  • même clé dans les deux rendus : c'est le même élément, React le met à jour ;
  • nouvelle clé : React crée un élément ;
  • clé disparue : React supprime l'élément, et l'état des composants qu'il contenait.

Sans clé, React affiche un avertissement dans la console : « Each child in a list should have a unique "key" prop ».

Règles d'une bonne clé :

  1. Unique parmi les frères (deux listes différentes peuvent réutiliser les mêmes clés).
  2. Stable : le même élément garde la même clé d'un rendu à l'autre.
  3. Venue des données : l'identifiant de la base (fiche.id) est la clé idéale.

La clé se place sur l'élément renvoyé par map, pas à l'intérieur du composant enfant. Et elle n'est pas transmise comme prop : si l'enfant a besoin de l'identifiant, passe-le aussi dans une prop (<CarteFiche key={f.id} fiche={f} />, où l'enfant lit fiche.id).

Quand chaque élément de la liste doit produire plusieurs balises sans parent, le fragment court <> ne peut pas recevoir de clé : utilise Fragment.

tsx
import { Fragment } from 'react';

<dl>
  {frais.map((f) => (
    <Fragment key={f.id}>
      <dt>{f.libelle}</dt>
      <dd>{formatMontant(f.montant)}</dd>
    </Fragment>
  ))}
</dl>

Afficher sous condition

Trois outils, selon la situation.

1. Un if avant le return, pour renvoyer un tout autre affichage. C'est le retour anticipé :

tsx
export function ListeFiches({ fiches }: ListeFichesProps) {
  if (fiches.length === 0) {
    return <p>Aucune fiche pour ce filtre.</p>;
  }
  return <ul>…</ul>;
}

2. Le ternaire condition ? a : b, pour choisir entre deux affichages au milieu du JSX :

tsx
<button type="submit" disabled={enCours}>
  {enCours ? 'Enregistrement…' : 'Ajouter le frais'}
</button>

3. condition && élément, pour afficher quelque chose ou rien :

tsx
{fiche.montantValide !== null && <p>Montant validé : {formatMontant(fiche.montantValide)}</p>}
{erreur && <p role="alert">{erreur}</p>}

a && b vaut b si a est « vrai », et a sinon. Quand a vaut false, null ou undefined, React n'affiche rien.

Pour ne rien afficher du tout, un composant renvoie null :

tsx
export function AlerteRetard({ jours }: { jours: number }) {
  if (jours <= 0) return null;
  return <p role="alert">Ta fiche est en retard de {jours} jour{jours > 1 ? 's' : ''}.</p>;
}

Le piège du zéro

tsx
function Alerte({ nbRefus }: { nbRefus: number }) {
  return <div>{nbRefus && <p>{nbRefus} frais refusés</p>}</div>;
}

Avec nbRefus = 3, le paragraphe s'affiche. Avec nbRefus = 0, on attend un écran vide… et l'écran affiche 0. 0 && … vaut 0, et React affiche les nombres. La condition à gauche de && doit être un vrai booléen :

tsx
{nbRefus > 0 && <p>{nbRefus} frais refusés</p>}
{fiches.length > 0 && <ListeFiches fiches={fiches} />}   // et pas {fiches.length && …}

Plusieurs cas : sortir la logique du JSX

Des ternaires imbriqués deviennent vite illisibles. Pour trois cas ou plus, prépare la valeur avant le return, ou utilise un objet de correspondance :

tsx
// Difficile à lire
{etat === 'CR' ? <BoutonCloturer /> : etat === 'CL' ? <p>En attente de validation</p> : etat === 'VA' ? <p>Validée</p> : null}

// Plus lisible : un objet qui associe chaque état à son affichage
const ACTIONS: Record<EtatFiche, ReactNode> = {
  CR: <BoutonCloturer />,
  CL: <p>En attente de validation</p>,
  VA: <p>Validée, en attente de paiement</p>,
  MP: <p>Mise en paiement</p>,
};

return <section>{ACTIONS[fiche.etat]}</section>;

Avec Record<EtatFiche, ReactNode>, TypeScript signale un état oublié : si l'API ajoute un état, la compilation te montre tous les endroits à compléter.

Un tableau de bord complet

Ce composant combine tout le chapitre : un retour anticipé, des valeurs dérivées, une liste triée avec clés et des conditions.

tsx
import type { FicheResume } from '../types';

export function TableauDeBord({ fiches }: { fiches: FicheResume[] }) {
  const enCours = fiches.filter((f) => f.etat === 'CR');
  const totalValide = fiches.reduce((somme, f) => somme + (f.montantValide ?? 0), 0);

  if (fiches.length === 0) return <p>Aucune fiche pour l'instant.</p>;

  return (
    <>
      <p>{enCours.length === 1 ? '1 fiche en cours' : `${enCours.length} fiches en cours`}</p>
      <p>Total validé : {totalValide.toFixed(2)} €</p>
      <ul>
        {fiches
          .toSorted((a, b) => b.mois.localeCompare(a.mois))
          .map((f) => (
            <li key={f.id}>{f.mois}{f.etat === 'CR' ? ' (à compléter)' : ''}</li>
          ))}
      </ul>
    </>
  );
}

Avec trois fiches, août (MP, 512,40 € validés), septembre (VA, 389,20 €) et octobre (CR, montant null), l'écran affiche « 1 fiche en cours », « Total validé : 901.60 € », puis la liste 2026-10 (à compléter), 2026-09, 2026-08. L'opérateur ?? remplace null par 0 dans la somme. (Le formatage toFixed(2) garde le point décimal ; dans l'application, formatMontant affiche « 901,60 € ».)

Exercices

Exercice 1 sur 4 Afficher les frais Réussi À revoir

Écris le composant TableFrais qui reçoit frais: Frais[] et affiche un tableau HTML : une ligne par frais (date au format jj/mm/aaaa, libellé, montant en euros), puis une ligne de total. Si la liste est vide, affiche « Aucun frais hors forfait pour ce mois. » à la place du tableau. Utilise formatDate et formatMontant.

Voir la réponseMasquer la réponse de l'exercice 1
tsx
import { formatDate, formatMontant } from '../format';
import type { Frais } from '../types';

export function TableFrais({ frais }: { frais: Frais[] }) {
  if (frais.length === 0) return <p>Aucun frais hors forfait pour ce mois.</p>;

  const total = frais.reduce((somme, f) => somme + f.montant, 0);

  return (
    <table>
      <caption>Frais hors forfait</caption>
      <thead>
        <tr><th scope="col">Date</th><th scope="col">Libellé</th><th scope="col">Montant</th></tr>
      </thead>
      <tbody>
        {frais.map((f) => (
          <tr key={f.id}>
            <td>{formatDate(f.date)}</td>
            <td>{f.libelle}</td>
            <td>{formatMontant(f.montant)}</td>
          </tr>
        ))}
      </tbody>
      <tfoot>
        <tr><th scope="row" colSpan={2}>Total</th><td>{formatMontant(total)}</td></tr>
      </tfoot>
    </table>
  );
}

Le total est calculé, pas stocké. caption et scope rendent le tableau compréhensible aux lecteurs d'écran (chapitre 14).

Tu avais trouvé ?

Exercice 2 sur 4 Choisir la clé Réussi À revoir

Pour chaque liste, quelle clé choisis-tu ?

  1. Les fiches de frais reçues de l'API, qui ont un id.
  2. Les mois de l'année affichés dans une liste déroulante fixe : ['janvier', 'février', …].
  3. Des lignes de saisie que l'utilisateur ajoute et supprime avant d'envoyer le formulaire, sans identifiant de la base.
  4. Les messages d'erreur d'un formulaire, qui ne sont jamais réordonnés.
Voir la réponseMasquer la réponse de l'exercice 2
  1. fiche.id.
  2. Le nom du mois lui-même (key={mois}) : unique et stable. L'indice conviendrait aussi, car la liste ne change jamais.
  3. Un identifiant créé au moment de l'ajout et stocké avec la ligne : { cle: crypto.randomUUID(), libelle: '', montant: 0 }, puis key={ligne.cle}. Pas l'indice (les lignes sont supprimées au milieu), pas un identifiant généré pendant le rendu.
  4. Le texte du message, s'il est unique ; sinon l'indice, acceptable ici puisque la liste n'est jamais réordonnée et que ses éléments n'ont pas d'état.

Tu avais trouvé ?

Exercice 3 sur 4 Corriger l'affichage Réussi À revoir

Ce composant affiche parfois un 0 isolé et parfois plante avec « Cannot read properties of undefined (reading 'map') ». Explique les deux bogues et corrige-les.

tsx
function Resume({ fiches }: { fiches?: FicheResume[] }) {
  return (
    <section>
      {fiches.length && <h2>{fiches.length} fiches</h2>}
      <ul>{fiches.map((f) => <li>{f.mois}</li>)}</ul>
    </section>
  );
}
Voir la réponseMasquer la réponse de l'exercice 3
  • Le 0 : avec une liste vide, fiches.length && … vaut 0, que React affiche. Il faut une vraie condition : fiches.length > 0 && ….
  • Le plantage : fiches est facultatif ; quand il vaut undefined (données pas encore chargées), fiches.length et fiches.map lèvent une erreur. On traite ce cas d'abord.
  • En prime : il manque la key sur les <li>.
tsx
function Resume({ fiches }: { fiches?: FicheResume[] }) {
  if (fiches === undefined) return <p role="status">Chargement…</p>;
  if (fiches.length === 0) return <p>Aucune fiche.</p>;
  return (
    <section>
      <h2>{fiches.length} fiche{fiches.length > 1 ? 's' : ''}</h2>
      <ul>{fiches.map((f) => <li key={f.id}>{f.mois}</li>)}</ul>
    </section>
  );
}

TypeScript avait d'ailleurs signalé le second bogue : « 'fiches' is possibly 'undefined' ».

Tu avais trouvé ?

Exercice 4 sur 4 Filtrer à l'affichage Réussi À revoir

Le composant reçoit fiches et a un état const [etat, setEtat] = useState<EtatFiche | ''>('') relié à un FiltreEtat. Écris la ligne qui calcule fichesAffichees (toutes les fiches si etat vaut '', sinon celles de l'état choisi). Faut-il un deuxième useState pour la liste filtrée ?

Voir la réponseMasquer la réponse de l'exercice 4
tsx
const fichesAffichees = etat === '' ? fiches : fiches.filter((f) => f.etat === etat);

Pas de deuxième état : la liste filtrée se calcule à partir de fiches et de etat. La stocker obligerait à la recalculer à la main à chaque changement de l'un ou de l'autre, avec un risque d'oubli.

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 : useEffect : les effets de bord, et quand s'en passerRevoir le sommaire du cours