Retour au blog
août 2026·3 min de lectureGénéré par IA
Langue:DeutschEnglishFrançaisÖsterreichischKlingonian Version originale: English

Construire une machine à états simple en TypeScript

#typescript#design-patterns#frontend

Si vous avez déjà écrit un composant avec isLoading, isError, hasData et isEmpty comme booléens séparés, vous avez probablement aussi livré un bug où deux d'entre eux étaient vrais en même temps et où l'interface affichait un spinner et un message d'erreur empilés l'un sur l'autre. Une machine à états règle cela au niveau des types : au lieu de drapeaux indépendants qui peuvent se combiner en absurdités, vous avez exactement un état nommé à la fois, avec des transitions clairement définies entre eux.

Le problème de la soupe de booléens

Voici le genre d'état de composant que les machines à états remplacent :

interface FetchState {
  isLoading: boolean;
  isError: boolean;
  data: string[] | null;
  error: string | null;
}

Quatre champs, seize combinaisons possibles, dont seulement quatre sont réellement valides (loading, success, error, idle). Rien n'empêche isLoading: true et isError: true d'exister en même temps : il faut simplement se souvenir de ne pas le faire, partout, pour toujours.

Modéliser les états comme une union

Une machine à états représente « ce qui se passe maintenant » comme une unique union étiquetée plutôt qu'un sac de drapeaux :

type FetchState =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: string[] }
  | { status: "error"; message: string };

Désormais, data n'existe que lorsque status vaut "success", et message n'existe que lorsque status vaut "error". Le rétrécissement de type par flux de contrôle de TypeScript l'impose pour vous :

function render(state: FetchState): string {
  switch (state.status) {
    case "idle":
      return "Nothing yet.";
    case "loading":
      return "Loading…";
    case "success":
      return `Got ${state.data.length} items.`; // data is known to exist here
    case "error":
      return `Failed: ${state.message}`; // message is known to exist here
  }
}

Essayez d'accéder à state.data dans la branche "error" et TypeScript le refusera à la compilation, et non à 2 h du matin quand un utilisateur signale un écran blanc.

Ajouter des transitions

Un état seul n'est qu'un instantané. L'autre moitié d'une machine à états est l'ensemble des transitions autorisées : quels états peuvent passer à quels autres états, et sous quel événement. Modélisez les événements comme vous avez modélisé les états :

type FetchEvent =
  | { type: "FETCH" }
  | { type: "RESOLVE"; data: string[] }
  | { type: "REJECT"; message: string }
  | { type: "RESET" };

function transition(state: FetchState, event: FetchEvent): FetchState {
  switch (state.status) {
    case "idle":
      if (event.type === "FETCH") return { status: "loading" };
      return state;

    case "loading":
      if (event.type === "RESOLVE") return { status: "success", data: event.data };
      if (event.type === "REJECT") return { status: "error", message: event.message };
      return state;

    case "success":
    case "error":
      if (event.type === "RESET") return { status: "idle" };
      return state;
  }
}

Chaque branche de cette fonction dit explicitement ce qui est permis. Vous ne pouvez pas sauter accidentellement de "idle" directement à "success" : aucun chemin de code ne le fait. Si une exigence future réclame cette transition, elle doit être ajoutée délibérément, en un seul endroit, plutôt que d'émerger comme effet de bord d'un composant qui modifie trois booléens dans le mauvais ordre.

L'intégrer dans un composant

La fonction transition est pure : pas d'effets de bord, facile à tester unitairement de façon isolée. L'envelopper autour du vrai travail asynchrone est une fine couche par-dessus :

class FetchController {
  private state: FetchState = { status: "idle" };
  private listeners: Array<(s: FetchState) => void> = [];

  private setState(next: FetchState) {
    this.state = next;
    this.listeners.forEach((listener) => listener(next));
  }

  subscribe(listener: (s: FetchState) => void) {
    this.listeners.push(listener);
    listener(this.state);
  }

  async load(url: string) {
    this.setState(transition(this.state, { type: "FETCH" }));
    try {
      const res = await fetch(url);
      const data: string[] = await res.json();
      this.setState(transition(this.state, { type: "RESOLVE", data }));
    } catch (err) {
      const message = err instanceof Error ? err.message : "Unknown error";
      this.setState(transition(this.state, { type: "REJECT", message }));
    }
  }
}

Chaque changement d'état passe par transition, si bien que la garantie « seules les combinaisons valides sont atteignables » vaut pour toute la durée de vie du contrôleur, et pas seulement au niveau des types pour un rendu.

Tester les transitions plutôt que les composants

Comme transition est une fonction simple sans dépendances, vous pouvez tester chaque chemin sans rien monter :

test("FETCH moves idle to loading", () => {
  const result = transition({ status: "idle" }, { type: "FETCH" });
  expect(result).toEqual({ status: "loading" });
});

test("REJECT during loading carries the error message", () => {
  const result = transition(
    { status: "loading" },
    { type: "REJECT", message: "network down" },
  );
  expect(result).toEqual({ status: "error", message: "network down" });
});

test("FETCH while already loading is a no-op", () => {
  const state: FetchState = { status: "loading" };
  const result = transition(state, { type: "FETCH" });
  expect(result).toBe(state);
});

Ce dernier test compte plus qu'il n'y paraît : il documente et impose qu'un événement FETCH en double, alors que le chargement est déjà en cours, ne relance rien et ne déclenche pas la requête deux fois.

Quand c'est excessif

Un interrupteur à deux états (open / closed) n'a pas besoin de cette mécanique : un seul booléen suffit et un type union ne ferait que du cérémonial. Recourez à une machine à états explicite quand un composant a trois états mutuellement exclusifs ou plus, quand les transitions entre eux sont la vraie source de bugs (les états de chargement sont le cas classique), ou quand vous vous surprenez à écrire des gardes if (isLoading && !isError && data) qui n'existent que pour compenser un état qui pourrait être invalide mais ne devrait pas l'être.

À retenir

Les booléens se multiplient : quatre drapeaux créent seize états, la plupart absurdes. Une union étiquetée les réduit exactement aux états réellement valides, et une unique fonction transition devient le seul endroit qui décide de ce qui peut arriver ensuite. Le compilateur fait respecter le reste.

Plus récentGuide pratique des stratégies de branches GitPlus ancienReflows et repaints : pourquoi votre page semble saccadée