📐 Construire une app en JS
44. Un chargement qui ne ment pas

Un état de chargement qui ne ment pas

Reprenons notre hook, écrit au chapitre 24 et enrichi depuis :

const [loading, setLoading] = useState(false);
const [accommodations, setAccommodations] = useState([]);
const [error, setError] = useState(null);

Trois états indépendants. Comptons ce qu'ils autorisent.

Le tableau qui fait mal

loadingaccommodationserrorest-ce possible ?
false[]nulloui — rien n'a encore été chargé, ou la recherche ne donne rien
true[]nulloui — chargement en cours
false6 élémentsnulloui — chargé
false[]"erreur"oui — échec
true6 élémentsnullnon — et pourtant ça arrive
true[]"erreur"non
false6 éléments"erreur"non
true6 éléments"erreur"non

Huit combinaisons, quatre absurdes.

Et la première ligne est pire que les quatre autres : elle est ambiguë. loading: false, accommodations: [] veut dire soit "aucun logement ne correspond à votre recherche", soit "le chargement n'a pas encore commencé". Deux écrans complètement différents, un seul état.

Vous connaissez déjà la phrase, elle est du chapitre 21 :

C'est ce qu'on appelle rendre les états invalides non représentables.

Nous l'avions appliquée à Stay : il n'existe pas de séjour de zéro nuit. Appliquons-la à l'écran : il ne doit pas exister d'état "en chargement avec une erreur".

Une seule variable, quatre formes

type Loader<T> =
  | { state: "idle" }
  | { state: "loading"; previous: T | null }
  | { state: "loaded"; value: T }
  | { state: "failed"; error: DomainError };

C'est une union discriminée : quatre formes possibles, distinguées par un champ commun, state.

Les quatre lignes impossibles du tableau ne peuvent plus s'écrire. Pas "ne devraient plus" : ne peuvent plus. Le compilateur refuse.

Et l'ambiguïté disparaît : { state: "idle" } et { state: "loaded", value: [] } sont deux choses différentes, écrites différemment.

Notez le previous sur loading. Il règle un défaut que vous avez forcément subi : l'utilisateur change une date, la liste disparaît, un spinner s'affiche, la liste revient. L'écran clignote, et il perd sa position de défilement.

En gardant la valeur précédente, on peut afficher l'ancienne liste légèrement grisée pendant le rechargement. C'est ce qu'on appelle stale-while-revalidate, et cela ne coûte qu'un champ — à condition de l'avoir prévu dans le type. C'est le genre de décision qu'un booléen loading ne permet même pas d'exprimer.

Les transitions sont des faits

Quatre états, donc des transitions. Et une machine à états se pilote avec un useReducer, pas avec six setState dispersés :

type Action<T> =
  | { type: "started" }
  | { type: "succeeded"; value: T }
  | { type: "failed"; error: DomainError };
 
function reducer<T>(state: Loader<T>, action: Action<T>): Loader<T> {
  switch (action.type) {
    case "started":
      return { state: "loading", previous: state.state === "loaded" ? state.value : null };
    case "succeeded":
      return { state: "loaded", value: action.value };
    case "failed":
      return { state: "failed", error: action.error };
  }
}

Vous reconnaissez le vocabulaire du chapitre 35 : les actions sont au passé. started, succeeded, failed. Ce sont des faits, et le reducer décide de l'état qui en découle.

Le chapitre 7 nous avait dit qu'un reducer est une commande — elle modifie l'état du système, ici l'état d'une page — et qu'elle doit être pure et synchrone. La voici : trois lignes, aucun await, aucun effet de bord. Testable sans React, sans navigateur, sans rien monter.

Le hook devient une coquille :

function useAccommodations(criteria: Criteria) {
  const [state, dispatch] = useReducer(reducer<Accommodation[]>, { state: "idle" });
 
  const load = useCallback(async (signal?: AbortSignal) => {
    dispatch({ type: "started" });
    try {
      const { accommodations, error } = await loader(criteria, signal);
      if (error) dispatch({ type: "failed", error });
      else dispatch({ type: "succeeded", value: accommodations });
    } catch (error) {
      if (error.name === "AbortError") return;      // annulation volontaire : pas un échec
      dispatch({ type: "failed", error });
    }
  }, [criteria.from, criteria.to, criteria.adults, criteria.children]);
 
  useEffect(() => {
    const controller = new AbortController();
    load(controller.signal);
    return () => controller.abort();
  }, [load]);
 
  return { state, refresh: load };
}

Le drapeau obsolete du chapitre 27 a définitivement laissé place à l'AbortController. Et notez le traitement de AbortError : une requête annulée par nous-mêmes n'est pas un échec, et l'afficher comme tel afficherait une erreur à chaque frappe dans le champ de date.

Le rendu devient une correspondance

function HomePage({ criteria, onChange }: HomePageProps) {
  const { state, refresh } = useAccommodations(criteria);
 
  switch (state.state) {
    case "idle":
      return <Layout criteria={criteria} onChange={onChange}><SearchPrompt /></Layout>;
 
    case "loading":
      return (
        <Layout criteria={criteria} onChange={onChange} loading>
          {state.previous ? <AccommodationList accommodations={state.previous} dimmed /> : <Skeleton />}
        </Layout>
      );
 
    case "loaded":
      return (
        <Layout criteria={criteria} onChange={onChange}>
          {state.value.length === 0
            ? <NoResult criteria={criteria} />
            : <AccommodationList accommodations={state.value} onBook={/* ... */} />}
        </Layout>
      );
 
    case "failed":
      return (
        <Layout criteria={criteria} onChange={onChange}>
          <ErrorPanel message={toMessage(state.error)} onRetry={refresh} />
        </Layout>
      );
  }
}

Quatre branches, quatre écrans. Et surtout : dans la branche loaded, state.value existe — TypeScript le sait, il n'y a rien à vérifier. Dans la branche failed, state.error existe et state.value n'existe pas du tout.

C'est le rétrécissement de type par discriminant, et c'est ce que TypeScript fait de mieux.

Deux écrans que ce découpage vous force à écrire, et que la version à trois booléens vous laissait oublier :

NoResult. "Aucun logement disponible pour ces dates" avec une suggestion — élargir les dates, réduire le nombre de voyageurs. Sans l'union, ce cas se confondait avec "pas encore chargé", et l'utilisateur voyait une page blanche.

ErrorPanel avec un bouton "Réessayer". Une erreur réseau est souvent passagère. Afficher un message sans moyen de réessayer oblige à recharger la page — et à perdre la recherche en cours.

L'exhaustivité, gratuitement

Ajoutez demain un cinquième état — { state: "refreshing" }, { state: "empty" } — et le compilateur signalera tous les switch incomplets de l'application.

À condition de le lui demander :

default: {
  const impossible: never = state;
  throw new Error(`Unhandled state ${JSON.stringify(impossible)}`);
}

Si tous les cas sont couverts, state a le type never à cet endroit, et l'affectation compile. S'il en reste un, elle échoue avec un message qui nomme le cas oublié.

Cette astuce vaut pour toutes vos unions : les états d'une réservation (confirmed, cancelled, et demain pending du chapitre 19), les types d'événements du chapitre 35, les codes d'erreur du chapitre 42.

C'est le seul mécanisme que je connaisse qui transforme "ajouter un cas" d'un travail de recherche à un travail de correction. La différence entre chercher les endroits à modifier et se les faire montrer, c'est la différence entre un refactoring risqué et un refactoring ennuyeux.

Préférez toujours l'ennuyeux.

Faut-il écrire tout cela soi-même ?

Non. Et ce chapitre n'est pas un plaidoyer contre les bibliothèques.

TanStack Query, SWR et leurs équivalents font exactement ce que nous venons d'écrire, et davantage : cache partagé entre composants, déduplication des requêtes identiques, revalidation au retour sur l'onglet, réessais avec temporisation croissante, invalidation après une mutation.

const { data, isPending, isError, error, refetch } = useQuery({
  queryKey: ["accommodations", criteria],
  queryFn: ({ signal }) => api.availableAccommodations(criteria, signal),
});

Une ligne au lieu de trente. Prenez-la.

Trois raisons pour lesquelles ce chapitre existe malgré tout.

Vous devez reconnaître la forme. isPending, isError, data : ce sont les mêmes états, avec des noms différents. Une bibliothèque qu'on utilise sans comprendre le modèle qu'elle implémente produit du code où l'on teste data && !isPending && !isError — c'est-à-dire le tableau à huit lignes, reconstitué à la main par-dessus une abstraction qui l'avait éliminé.

Le queryKey mérite un instant d'attention. C'est notre tableau de dépendances du chapitre 27, sous un autre nom : un objet reconstruit à chaque rendu, et vous rechargez en boucle. Les mêmes règles s'appliquent.

Tout ce qui n'est pas une requête reste à votre charge. Un formulaire multi-étapes, un panneau ouvert/fermé, un brouillon en cours d'édition : ce sont des états, ils ont des transitions, et l'union discriminée reste le bon outil. Aucune bibliothèque de données ne vous les modélisera.

Nous avons un domaine typé, une API, une interface honnête sur ce qu'elle sait.

Il reste une question de style que nous avons repoussée depuis le chapitre 3.