Le fichier s’appelle Bulletin.tsx et fait quatre cents lignes. Il connaît l’URL, le filtre, la ligne, la validation, le message d’erreur. Le jour où la note sur 20 devient une note sur 100, on touche partout, et on casse le filtre.

Je ne découpe pas pour « faire des petits fichiers ». Je découpe quand un bloc décide de deux choses. Ici, trois décisions : quoi chercher, quoi montrer, quoi enregistrer.

Le bloc qui décide trop

function Bulletin() {
  const [notes, setNotes] = useState<Note[]>([]);
  const [filtre, setFiltre] = useState("");
  const [valeur, setValeur] = useState("");

  useEffect(() => {
    fetch("/api/notes/").then((r) => r.json()).then(setNotes);
  }, []);

  const visibles = notes.filter((note) => note.matiere.includes(filtre));

  async function enregistrer() {
    const nombre = Number(valeur);
    if (Number.isNaN(nombre) || nombre < 0 || nombre > 20) return;
    await fetch("/api/notes/", {
      method: "POST",
      headers: { "Content-Type": "application/json" },
      body: JSON.stringify({ valeur: nombre }),
    });
  }

  return (
    <form onSubmit={(event) => { event.preventDefault(); enregistrer(); }}>
      <input value={filtre} onChange={(event) => setFiltre(event.target.value)} />
      <ul>
        {visibles.map((note) => (
          <li key={note.id}>{note.matiere} — {note.valeur}/20</li>
        ))}
      </ul>
      <input value={valeur} onChange={(event) => setValeur(event.target.value)} />
      <button type="submit">Enregistrer</button>
    </form>
  );
}

Le filtre, l’affichage et la règle « entre 0 et 20 » vivent au même endroit. On ne peut pas tester la règle sans monter tout l’écran.

Trois rôles

La règle sort d’abord. Elle ne connaît pas React.

export function noteValide(valeur: number, plafond = 20) {
  return Number.isInteger(valeur) && valeur >= 0 && valeur <= plafond;
}

La ligne ne fait qu’afficher. Elle ne va pas chercher les données.

export function LigneNote({ matiere, valeur }: { matiere: string; valeur: number }) {
  return (
    <li>
      {matiere} — {valeur}/20
    </li>
  );
}

Le formulaire reçoit la décision déjà prise, et il prévient quand la note est refusée. Il n’écrit pas l’URL.

export function FormulaireNote({ onEnregistrer }: { onEnregistrer: (valeur: number) => void }) {
  const [brut, setBrut] = useState("");
  const [erreur, setErreur] = useState("");
  const nombre = Number(brut);

  return (
    <form
      onSubmit={(event) => {
        event.preventDefault();
        if (!noteValide(nombre)) {
          setErreur("Une note tient entre 0 et 20.");
          return;
        }
        setErreur("");
        onEnregistrer(nombre);
      }}
    >
      <input value={brut} onChange={(event) => setBrut(event.target.value)} inputMode="numeric" />
      {erreur ? <p>{erreur}</p> : null}
      <button type="submit">Enregistrer</button>
    </form>
  );
}

Bulletin ne garde que la liste et le filtre. Il passe onEnregistrer au formulaire. La règle des 20 points se relit dans une fonction de dix lignes.

Un composant unique, séparé en liste, ligne et formulaire

Exemple

Saisie 21. Avant, le return silencieux : rien ne se passe, l’enseignant recommence. Après, la phrase « Une note tient entre 0 et 20. » reste sous le champ, et aucun fetch ne part.

Saisie 14. noteValide(14) est vrai, onEnregistrer(14) part, la liste se met à jour. Le formulaire n’a pas besoin de savoir si la liste vient de Django ou d’un tableau local.

Le lancer, pour voir la règle seule

npx tsx -e "
import { noteValide } from './src/notes.ts';
console.log(noteValide(14));
console.log(noteValide(21));
console.log(noteValide(14.5));
"

J’attends true, false, false. Si ce script ment, je ne touche pas à l’écran : la règle n’est pas encore posée.