IFT3225

Démo 8 : Exercice 1

Le but de cet exercice n’est pas d’apprendre des recettes React, mais de savoir évaluer la maintenabilité et réutilisabilité des modules qui composent une application.

Code de l’exercice: GitHub udem-diro/demos-IFT3225 : Démo 8 - Exercice 01

Point de départ

Voici le composant sur lequel on travaillera. Il récupère et affiche la liste des lignes de bus stockée dans le fichier "lignes.json". Grâce à un champ texte, il permet à l’utilisateur de filtrer la liste.

import { useEffect, useState } from "react";

export default function LignesList() {
  const [lignes, setLignes] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [filtre, setFiltre] = useState("");

  useEffect(() => {
    let annule = false;
    setLoading(true);

    fetch("/lignes.json")
      .then((result) => {
        if (!result.ok) throw new Error(`HTTP ${result.status}`);
        return result.json();
      })
      .then((data) => {
        if (!annule) {
          setLignes(data);
          setError(null);
        }
      })
      .catch((err) => {
        if (!annule) setError(err.message);
      })
      .finally(() => {
        if (!annule) setLoading(false);
      });

    return () => {
      annule = true;
    };
  }, []);

  if (loading) return <p>Chargement…</p>;
  if (error) return <p>Erreur : {error}</p>;

  const visibles = lignes.filter((ligne) =>
    ligne.nom.toLowerCase().includes(filtre.toLowerCase())
  );

  return (
    <section>
      <input placeholder="Filtrer une ligne" value={filtre} 
             onChange={(event) => setFiltre(event.target.value)} />
      <ul>
        {visibles.map((ligne) => (
          <li key={ligne.id}>
            <strong>{ligne.numero}</strong> {ligne.nom} ({ligne.arrets} arrêts)
          </li>
        ))}
      </ul>
    </section>
  );
}
Le problème n’est pas que le composant fonctionne mal, mais qu’il est difficilement réutilisable et délicat à modifier.

Le diagnostic

Voici des sources potentielles de changement:

  • L’URL des données ("/lignes.json") peut changer.
  • Le format d’erreur peut changer.
  • La présentation peut changer.
  • La façon de filtrer peut changer.

Ce fichier a donc au moins 4 raisons de changer!

De ce constat général découlent les faiblesses suivantes :

  1. Le composant est couplé à la source de données. L’URL est codée en dur et le composant appelle lui-même fetch. Il dépend d’un détail concret plutôt que d’une abstraction.
  2. Le cycle de vie du chargement est géré par le composant. Les trois états (en cours, réussi, échoué) et leur enchainement sont gérés dans le composant, et devront l’être dans le suivant.
  3. La vue s’occupe du chargement de données. Impossible d’afficher une liste de lignes sans déclencher une requête, donc impossible de la tester ou de la réutiliser simplement.
  4. Le composant est à la fois chef d’orchestre et exécutant. Le filtre est enfermé dans le composant qui affiche les données. Il manque un ancêtre commun qui le détienne et qui orchestre. Le composant gère deux natures d’état, celui des données et celui de l’interface.

1. Couplage entre composant et source de données

Problème

L’appel fetch("/lignes.json") est enfoui dans le composant. Changer de source, passer du fichier statique à une véritable API, oblige à ouvrir un fichier de rendu.

Le composant dépend d’un détail d’implémentation concret au lieu de dépendre d’une abstraction, résultant sur un couplage fort et le rendant plus susceptible à changer.

Résolution

La solution ici est d’isoler l’accès à la ressource derrière un module dédié. Son rôle est de traduire le langage du domaine (« récupérer les lignes de bus ») en langage du transport (« faire une requête HTTP sur telle URL »).

React ne propose rien de spécial ici, et c’est une bonne nouvelle : le module de service est du JavaScript ordinaire.

Fichier src/api/bus.js

export async function fetchLignes() {
  const result = await fetch("/lignes.json");

  if (!result.ok) {
    throw new Error(`Impossible de charger les lignes (HTTP ${result.status}).`);
  }

  return result.json();
}

La séparation est nette et la source devient facilement remplaçable.

2. État surchargé : Cycle de vie du chargement des données

Problème

Le lien direct avec la source de données est brisé, mais le composant doit encore faire la gestion de l’état du cycle de chargement des données. Trois variables (lignes, loading, error) sont rattachées au composant pour monter une petite machine à états : inactif, en cours, réussi, échoué. Le prochain composant qui charge ces données devra également implémenter cette machine à état, avec ses propres approximations.

Résolution

La solution ici est d’encapsuler la gestion de l’état dans une ressource qui expose son état et prévient ceux qui l’écoutent (abonnés). Une telle résolution repose principalement sur une implémentation du patron de l’Observateur.

C’est exactement ce qu’on obtient sans trop d’effort en React, en définissant un hook personnalisé.

// src/hooks/useLignes.js
import { useEffect, useState } from "react";
import { fetchLignes } from "../api/bus.js";

export function useLignes() {
  const [lignes, setLignes] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setLoading(true);

    fetchLignes()
      .then((data) => {
        setLignes(data);
        setError(null);
      })
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

  return { lignes, loading, error };
}

Il encapsule la gestion d’état grâce au useState qui fourni gratuitement un moyen de notifier tout composant (abonné) utilisant le hook d’un changement et les redessiner automatiquement. Le hook est réutilisable, et sa signature (lignes, loading, error) documente le contrat.

Cependant, un hook ne peut être appelé que depuis un composant ou un autre hook, rendant la logique inutilisable hors de React.

3. Responsabilité multiple : Appel de données et présentation

Problème

Même après avoir encapsulé la communication avec la couche de données et la gestion de l’état dans un hook (useLignes), le composant demeure responsable de récupérer les lignes. Par conséquent, on ne peut pas non plus le réutiliser pour afficher une liste venue d’ailleurs, par exemple des favoris déjà en mémoire.

Résolution

Le remède est de découpler pleinement le composant de la récupération des données et le transforme en une vue simple. Le composant déclare simplement ses besoins en données (via des paramètres) et s’occupe uniquement de les présenter. Il laisse ainsi le soin au parent de lui fournir les données.

Le composant se transforme ainsi en une fonction pure hautement réutilisable sans état inutilement complexe. À noter que le composant a même été déchargé de la fonctionnalité de filtre.

export default function LignesList({ lignes }) {
  if (lignes.length === 0) {
    return <p>Aucune ligne ne correspond.</p>;
  }

  return (
    <ul className="lignes">
      {lignes.map((ligne) => (
        <li key={ligne.id}>
          <strong>{ligne.numero}</strong> {ligne.nom} ({ligne.arrets} arrêts)
        </li>
      ))}
    </ul>
  );
}

Où est passé le filtre ?

Faute d’une pièce prévue pour cela, il remonte dans App avec le chargement.
Cependant, sous sa forme actuelle, il se présente comme un simple champ de saisie, plutôt qu’un véritable champ de filtre avec des options pour contrôler la saisie.
On l’extrait donc dans un composant FiltreBarre.

Fichier src/components/FiltreBarre.jsx

import { useEffect, useState } from "react";

export default function FiltreBarre({ surChangement, valeur = "", minimum = 2, delai = 250 }) {
  const [saisie, setSaisie] = useState(valeur);

  // Règles internes : on n'applique le filtre qu'après une pause dans la frappe,
  // et seulement au-delà d'un nombre minimal de caractères.
  useEffect(() => {
    const terme = saisie.trim();
    const effectif = terme.length >= minimum ? terme : "";

    const minuterie = setTimeout(() => surChangement(effectif), delai);
    return () => clearTimeout(minuterie);
  }, [saisie, minimum, delai, surChangement]);

  return (
    <input value={saisie} onChange={(event) => setSaisie(event.target.value)}
           placeholder="Filtrer une ligne" aria-label="Filtrer une ligne" />
  );
}

Ici, le composant maintient la valeur saisie et l’application des règles reçues du (composant) parent: nombre minimal de caractères et délai entre frappes pour appliquer le filtre. Cependant, l’application du filtre lui-même est laissée au parent.

Version alternative: Saisie contrôlée par le parent

Si l’on souhaite donner davantage de contrôle au parent, pour le permettre de réinitialiser le filtre par exemple, il faudrait faire remonter la gestion de la saisie, laissant uniquement l’application des règles sous la responsabilité du filtre.

import { useEffect } from "react";

export default function FiltreBarre({ saisie, surSaisie, surChangement, minimum = 2, delai = 250 }) {
  // Règles internes : on n'applique le filtre qu'après une pause dans la frappe,
  // et seulement au-delà d'un nombre minimal de caractères.
  useEffect(() => {
    const terme = saisie.trim();
    const effectif = terme.length >= minimum ? terme : "";

    const minuterie = setTimeout(() => surChangement(effectif), delai);
    return () => clearTimeout(minuterie);
  }, [saisie, minimum, delai, surChangement]);

  return (
    <input value={saisie} onChange={(event) => surSaisie(event.target.value)}
           placeholder="Filtrer une ligne" aria-label="Filtrer une ligne" />
  );
}
function Parent() {
  const [saisie, setSaisie] = useState("");       // le texte tapé
  const [filtre, setFiltre] = useState("");       // le filtre appliqué

  const reinitialiser = () => {
    setSaisie("");
    setFiltre("");
  };

  return (
    <section>
      ...
      <FiltreBarre saisie={saisie} surSaisie={setSaisie} surChangement={setFiltre} />
      ...
    </section>
  );
}

Cette version est utilisée pour la suite.

4. Alléger la racine : Définir une Page

Problème

À l’étape précédente, on a obtenu un composant hautement réutilisable, mais l’orchestration est retombée sur App qui ne devrait pas s’occuper du détail de l’agencement d’une vue de l’application et de ses composants.

Il manque une pièce dont le rôle est précisément d’assembler et initialiser les autres.

Résolution

On peut définir pour cela un nouveau composant qu’on caractérisera comme un Page (LignesPage) qui agira comme un contrôleur.

Fichier src/pages/LignesPage.jsx

import { useState } from "react";
import { useLignes } from "../hooks/useLignes.js";
import LignesList from "../components/LignesList.jsx";

export default function LignesPage() {
  const { lignes, loading, error } = useLignes(); // état de données
  const [saisie, setSaisie] = useState("");       // le texte tapé
  const [filtre, setFiltre] = useState("");       // le filtre appliqué

  const reinitialiser = () => {
    setSaisie("");
    setFiltre("");
  };

  const visibles = lignes.filter((ligne) =>
    ligne.nom.toLowerCase().includes(filtre.toLowerCase())
  );

  if (loading) return <p>Chargement des lignes…</p>;
  if (error) return <p role="alert">Erreur : {error}</p>;

  return (
    <section>
      <h1>Lignes de bus</h1>
      <FiltreBarre saisie={saisie} surSaisie={setSaisie} surChangement={setFiltre} />
      <button onClick={reinitialiser} disabled={!saisie}>Effacer</button>
      <LignesList lignes={visibles} />
    </section>
  );
}

Récapitulatif

Après réusinage du composant initial, nous obtenons cinq fichiers, chacun avec une responsabilité unique.

src/
├── api/
│   └── bus.js            récupération de données, et rien d'autre
├── hooks/
│   └── useLignes.js      gestion du cycle de vie du chargement de données
├── pages/
│   └── LignesPage.jsx    orchestration des petits composants et état partagé
└── components/
    ├── FiltreBarre.jsx   saisie du filtre, avec ses règles internes
    └── LignesList.jsx    affichage d'une liste de lignes

Passer du fichier statique à une véritable API ne touchera qu’api/bus.js. Ajouter une seconde page qui affiche les mêmes lignes ne touchera rien : elle appellera useLignes et réutilisera LignesList. Modifier le mécanisme de filtre ne touche que FiltreBarre. Changer la présentation des lignes ne touchera que LignesList.