Aller au contenu
Réussir mon BTS SIO

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

Par exemple

Chapitre 418 minIntermédiaire

useState et événements : rendre l'interface interactive

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

À la fin de ce chapitre, tu sauras

  • réagir à un clic ou à une saisie avec un gestionnaire d'événement ;
  • déclarer un état avec useState et le modifier correctement ;
  • mettre à jour un objet ou un tableau sans le modifier ;
  • distinguer ce qui va dans l'état de ce qui se calcule ;
  • respecter les règles des hooks.

4 exercices corrigés

useState est le hook de React qui donne à un composant une mémoire : une valeur qu'il garde d'un rendu à l'autre et qui, quand on la change, provoque un nouveau rendu. Les événements (onClick, onChange, onSubmit) sont le moment où l'on change cet état. Ce chapitre explique comment réagir à un clic, déclarer et modifier un état, mettre à jour correctement un objet ou un tableau, et respecter les règles des hooks.

Réagir à un événement

On attache un gestionnaire d'événement avec un attribut en camelCase qui reçoit une fonction :

tsx
export function BoutonAide() {
  function afficherAide() {
    alert('Saisis tes frais avant le 10 du mois suivant.');
  }

  return <button type="button" onClick={afficherAide}>Aide</button>;
}
ÉvénementQuandÉléments
onClickClic (ou Entrée, Espace sur un bouton)button, a, tout élément
onChangeChaque modification d'un champ (chaque frappe, pas seulement à la sortie du champ comme en HTML)input, select, textarea
onSubmitEnvoi d'un formulaireform
onFocus, onBlurLe champ prend ou perd le focuschamps, boutons, liens
onKeyDownUne touche est enfoncéetout élément focalisable

Le gestionnaire reçoit un objet événement. Ses propriétés les plus utiles :

tsx
<input onChange={(e) => console.log(e.target.value)} />       // la valeur saisie
<form onSubmit={(e) => { e.preventDefault(); … }}>             // empêcher le rechargement de la page
<a href="/aide" onClick={(e) => e.stopPropagation()}>          // ne pas prévenir les parents

Écrit directement dans l'attribut, le type de e est déduit par TypeScript. Dans une fonction à part, il faut le préciser (chapitre 8).

Pourquoi une variable ordinaire ne suffit pas

Premier essai pour afficher et masquer le détail des forfaits d'une fiche :

tsx
export function DetailRepliable() {
  let ouvert = false;

  return (
    <section>
      <button type="button" onClick={() => { ouvert = !ouvert; }}>Afficher le détail</button>
      {ouvert && <p>Forfait étape : 3 × 110,00 €</p>}
    </section>
  );
}

Le clic ne fait rien de visible. Deux raisons :

  1. Changer une variable locale ne déclenche pas de rendu. React ne sait pas qu'il doit rappeler la fonction.
  2. Une variable locale ne survit pas au rendu suivant. Même si React redessinait, la fonction repartirait de let ouvert = false.

Il faut donc une valeur que React conserve entre les rendus et dont la modification déclenche un rendu : c'est l'état.

Déclarer un état avec useState

tsx
import { useState } from 'react';

export function DetailRepliable() {
  const [ouvert, setOuvert] = useState(false);

  return (
    <section>
      <button type="button" aria-expanded={ouvert} onClick={() => setOuvert(!ouvert)}>
        {ouvert ? 'Masquer le détail' : 'Afficher le détail'}
      </button>
      {ouvert && <p>Forfait étape : 3 × 110,00 €</p>}
    </section>
  );
}

useState(false) renvoie un tableau de deux éléments, qu'on déstructure :

  • ouvert : la valeur actuelle de l'état (false au premier rendu, la valeur initiale) ;
  • setOuvert : la fonction de mise à jour. setOuvert(true) enregistre la nouvelle valeur et demande un nouveau rendu.

Par convention, on nomme la paire [truc, setTruc]. L'attribut aria-expanded dit aux lecteurs d'écran si la zone est dépliée (chapitre 14).

TypeScript déduit le type de l'état de sa valeur initiale : ici boolean. Quand la valeur initiale ne suffit pas à le deviner, on le donne entre chevrons :

tsx
const [frais, setFrais] = useState<Frais[]>([]);            // un tableau vide : de quoi ?
const [erreur, setErreur] = useState<string | null>(null);  // null maintenant, un message plus tard
const [etat, setEtat] = useState<EtatFiche | ''>('');       // une union

Ce qui se passe quand on change l'état

Au clic sur le bouton :

  1. setOuvert(true) enregistre la nouvelle valeur et programme un rendu ;
  2. React rappelle DetailRepliable() ; cette fois, useState renvoie true ;
  3. le JSX renvoyé contient le paragraphe et le libellé « Masquer le détail » ;
  4. React compare avec le rendu précédent et modifie seulement le texte du bouton et l'ajout du paragraphe.

Chaque composant a son propre état. Si l'écran affiche trois DetailRepliable, chacun a son ouvert : déplier le premier ne touche pas aux deux autres.

L'état est une photo : le piège des mises à jour successives

Pendant un rendu, la valeur de l'état est figée : c'est une photo prise au moment du rendu. Appeler setNb ne change pas nb dans le code qui suit ; cela change la valeur du prochain rendu.

tsx
export function Compteur() {
  const [nb, setNb] = useState(0);

  function plusTrois() {
    setNb(nb + 1);   // nb vaut 0 : demande « 1 »
    setNb(nb + 1);   // nb vaut toujours 0 : demande encore « 1 »
    setNb(nb + 1);   // idem
    console.log(nb); // affiche 0
  }

  return <button onClick={plusTrois}>Valeur : {nb}</button>;
}

Après un clic, l'écran affiche Valeur : 1, pas 3. Quand la nouvelle valeur dépend de l'ancienne, passe une fonction de mise à jour : React l'appelle avec la valeur la plus récente.

tsx
function plusTrois() {
  setNb((n) => n + 1);   // 0 -> 1
  setNb((n) => n + 1);   // 1 -> 2
  setNb((n) => n + 1);   // 2 -> 3
}

React regroupe (batching) les mises à jour faites dans un même gestionnaire : un seul rendu après les trois appels, pas trois.

Mettre à jour un objet ou un tableau sans le modifier

React décide de redessiner en comparant l'ancienne et la nouvelle valeur de l'état (avec Object.is). Si tu modifies l'objet existant puis le repasses à setX, c'est le même objet : React ne voit aucun changement, et l'écran ne bouge pas.

tsx
const [saisie, setSaisie] = useState({ libelle: '', montant: 0 });

// FAUX : on modifie l'objet existant
saisie.libelle = 'Taxi';
setSaisie(saisie);          // même objet : React peut ignorer la mise à jour

// JUSTE : on crée un nouvel objet avec la décomposition { ...ancien, champModifié }
setSaisie({ ...saisie, libelle: 'Taxi' });

C'est l'immuabilité : on ne modifie jamais un état, on le remplace par une copie modifiée. Pour les tableaux, les méthodes qui renvoient un nouveau tableau font tout le travail :

OpérationÀ éviter (modifie le tableau)À utiliser (nouveau tableau)
Ajouterpush, unshift[...liste, nouveau], [nouveau, ...liste]
Supprimerspliceliste.filter((x) => x.id !== id)
Modifier un élémentliste[i] = …liste.map((x) => (x.id === id ? { ...x, montant: 30 } : x))
Triersortliste.toSorted(…) ou [...liste].sort(…)
Inverserreverseliste.toReversed()

Les trois opérations sur une liste de frais conservée dans l'état :

tsx
import { useState } from 'react';
import type { Frais } from '../types';

const DEPART: Frais[] = [
  { id: 58, date: '2026-10-14', libelle: 'Repas client à Lyon', montant: 23.5 },
  { id: 59, date: '2026-10-21', libelle: 'Parking gare Part-Dieu', montant: 12 },
];

export function FraisLocaux() {
  const [frais, setFrais] = useState<Frais[]>(DEPART);

  function ajouterTaxi() {
    const taxi: Frais = { id: Date.now(), date: '2026-10-28', libelle: 'Taxi', montant: 18.4 };
    setFrais([...frais, taxi]);                                                   // ajouter
  }

  function supprimer(id: number) {
    setFrais(frais.filter((f) => f.id !== id));                                  // supprimer
  }

  function arrondir(id: number) {
    setFrais(frais.map((f) => (f.id === id ? { ...f, montant: Math.round(f.montant) } : f)));   // modifier
  }

  return (
    <>
      <ul>
        {frais.map((f) => (
          <li key={f.id}>
            {f.libelle} : {f.montant} €
            <button onClick={() => arrondir(f.id)}>Arrondir</button>
            <button onClick={() => supprimer(f.id)}>Supprimer</button>
          </li>
        ))}
      </ul>
      <button onClick={ajouterTaxi}>Ajouter un taxi</button>
    </>
  );
}

Après « Ajouter un taxi », la liste affiche trois frais ; « Arrondir » sur le repas affiche 24 € ; « Supprimer » sur le parking le retire. Dans la vraie application, ces données viendront de l'API (chapitre 7) et l'état ne servira plus qu'aux saisies en cours.

Ce qui va dans l'état, et ce qui n'y va pas

Mettre trop de choses dans l'état est l'erreur la plus fréquente après la mutation. Une valeur va dans l'état seulement si elle change au cours du temps et qu'on ne peut pas la calculer à partir d'autres données.

ValeurDans l'état ?Pourquoi
Le panneau est-il déplié ?OuiElle change au clic et rien ne permet de la déduire
La saisie d'un champOuiElle change à chaque frappe
Le total des fraisNonElle se calcule à partir de la liste : frais.reduce(…)
Le nombre de fraisNonfrais.length
La liste filtréeNonElle se calcule à partir de la liste et du filtre
Une donnée reçue en propsNonElle appartient au parent
tsx
// FAUX : le total est un double de la liste, il faudra penser à le mettre à jour partout
const [frais, setFrais] = useState<Frais[]>(DEPART);
const [total, setTotal] = useState(35.5);

// JUSTE : le total est calculé à chaque rendu, il est toujours exact
const [frais, setFrais] = useState<Frais[]>(DEPART);
const total = frais.reduce((somme, f) => somme + f.montant, 0);

Une valeur calculée à partir de l'état s'appelle un état dérivé. Le calcul coûte quelques microsecondes : ce n'est jamais une raison de le stocker.

Les règles des hooks

useState est un hook : une fonction de React dont le nom commence par use et qui « branche » une fonctionnalité de React dans un composant. Tu en rencontreras d'autres : useEffect, useActionState, useContext… Tous obéissent à deux règles :

  1. On appelle un hook seulement au premier niveau d'un composant (ou d'un autre hook) : jamais dans une condition, une boucle, une fonction imbriquée ou après un return anticipé.
  2. On appelle un hook seulement depuis un composant fonction ou un hook personnalisé, pas depuis une fonction JavaScript ordinaire.
tsx
function CarteFiche({ fiche }: CarteFicheProps) {
  if (fiche.etat !== 'CR') {
    return <p>Fiche fermée</p>;
  }
  const [ouvert, setOuvert] = useState(false);   // INTERDIT : hook après un return conditionnel
  …
}

React retrouve chaque état grâce à l'ordre d'appel des hooks : le premier useState du composant, le deuxième, etc. Si un hook est appelé dans une condition, l'ordre change d'un rendu à l'autre et React mélange les états. Le linter installé par Vite (règle react/rules-of-hooks d'oxlint) signale ces erreurs : ne l'ignore pas. La seule exception est use, un hook de React 19 qui peut s'appeler dans une condition (chapitre 11).

Exercices

Exercice 1 sur 4 Le compteur de caractères Réussi À revoir

Écris un composant ChampMotif : une zone de texte (textarea) pour le motif d'un frais, limitée à 200 caractères, et en dessous le texte « 153 caractères restants » qui se met à jour à chaque frappe. Quelle valeur va dans l'état, et laquelle n'y va pas ?

Voir la réponseMasquer la réponse de l'exercice 1
tsx
import { useState } from 'react';

export function ChampMotif() {
  const [motif, setMotif] = useState('');
  const restants = 200 - motif.length;   // état dérivé : calculé, pas stocké

  return (
    <p>
      <label htmlFor="motif">Motif</label>
      <textarea id="motif" maxLength={200} value={motif} onChange={(e) => setMotif(e.target.value)} />
      <span>{restants} caractères restants</span>
    </p>
  );
}

Seul le texte saisi va dans l'état. Le nombre de caractères restants se calcule à partir de lui : le stocker obligerait à le mettre à jour à chaque frappe, avec le risque d'un décalage.

Tu avais trouvé ?

Exercice 2 sur 4 Trouver le bogue Réussi À revoir

Ce composant doit ajouter un frais « Péage » à la liste à chaque clic, mais l'écran ne change pas. Pourquoi ? Corrige-le.

tsx
function ListePeages() {
  const [frais, setFrais] = useState<Frais[]>([]);
  function ajouter() {
    frais.push({ id: Date.now(), date: '2026-10-30', libelle: 'Péage', montant: 7.2 });
    setFrais(frais);
  }
  return (
    <>
      <p>{frais.length} péages</p>
      <button onClick={ajouter}>Ajouter</button>
    </>
  );
}
Voir la réponseMasquer la réponse de l'exercice 2

push modifie le tableau existant, puis setFrais(frais) repasse le même tableau. React compare l'ancienne et la nouvelle valeur avec Object.is : identiques, donc pas de nouveau rendu. Il faut créer un nouveau tableau :

tsx
function ajouter() {
  setFrais([...frais, { id: Date.now(), date: '2026-10-30', libelle: 'Péage', montant: 7.2 }]);
}

Tu avais trouvé ?

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

nb vaut 5. Que vaut-il après un clic sur chacun de ces boutons (en partant de 5 à chaque fois) ?

tsx
<button onClick={() => { setNb(nb + 2); setNb(nb + 2); }}>A</button>
<button onClick={() => { setNb((n) => n + 2); setNb((n) => n + 2); }}>B</button>
<button onClick={() => { setNb(nb + 2); setNb((n) => n * 10); }}>C</button>
<button onClick={() => { setNb((n) => n * 10); setNb(nb + 2); }}>D</button>
Voir la réponseMasquer la réponse de l'exercice 3
  • A : 7. Les deux appels demandent 5 + 2.
  • B : 9. La fonction reçoit la valeur la plus récente : 5 → 7 → 9.
  • C : 70. La première mise à jour donne 7 ; la fonction de la seconde reçoit 7 et renvoie 70.
  • D : 7. La première donne 50 ; la seconde remplace par nb + 2, calculé avec la photo nb = 5.

Tu avais trouvé ?

Exercice 4 sur 4 Modifier un élément d'une liste Réussi À revoir

L'état contient const [frais, setFrais] = useState<Frais[]>(…). Écris la fonction renommer(id: number, libelle: string) qui change le libellé du frais d'identifiant id, sans modifier les autres ni muter le tableau.

Voir la réponseMasquer la réponse de l'exercice 4
tsx
function renommer(id: number, libelle: string) {
  setFrais((liste) => liste.map((f) => (f.id === id ? { ...f, libelle } : f)));
}

map crée un nouveau tableau ; pour l'élément visé, { ...f, libelle } crée un nouvel objet qui reprend toutes ses propriétés et remplace libelle (écriture courte de libelle: libelle). Les autres éléments sont repris tels quels.

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 : Listes, clés et rendu conditionnelRevoir le sommaire du cours