Pourquoi vos états deviennent vite difficiles à maintenir

Dans une interface web, un état paraît souvent simple au départ. Une page charge des données, affiche un résultat, montre une erreur si la requête échoue, puis permet éventuellement de relancer l’action. On commence donc avec quelques booléens : isLoading, isError, isSuccess, isEmpty.

Le problème apparaît quand ces booléens peuvent se combiner librement. Une interface peut-elle être à la fois en chargement et en erreur ? Peut-elle être en succès sans données ? Que se passe-t-il si une requête précédente échoue après qu’une nouvelle a déjà réussi ? Ces situations produisent du code fragile, difficile à tester et rempli de conditions défensives.

Les machines à états apportent une réponse simple : au lieu de représenter l’interface par plusieurs drapeaux indépendants, on décrit explicitement les états possibles et les transitions autorisées entre ces états. Cette approche complète très bien les stratégies de cache frontend, d’annulation avec AbortController ou de typage robuste des données API en TypeScript.

Le piège des booléens indépendants

Prenons un exemple classique : une recherche d’utilisateurs.

type SearchState = {
  isIdle: boolean;
  isLoading: boolean;
  isSuccess: boolean;
  isError: boolean;
  users: User[];
  error?: string;
};

À première vue, ce type semble suffisamment clair. En réalité, il autorise des états incohérents :

const impossibleState: SearchState = {
  isIdle: true,
  isLoading: true,
  isSuccess: true,
  isError: false,
  users: [],
};

TypeScript ne peut pas deviner que cette combinaison est invalide. Pour lui, chaque propriété respecte bien son type. Le problème ne vient donc pas de TypeScript, mais du modèle choisi.

Quand une interface devient plus riche, ces combinaisons impossibles se multiplient. On ajoute une pagination, un état vide, une validation de formulaire, une annulation de requête, une mutation optimiste, puis le composant commence à contenir des conditions comme if (isLoading && !isError && users.length === 0). Le code décrit alors moins le métier que les contorsions nécessaires pour corriger le modèle d’état.

Représenter les états avec une union discriminée

Une machine à états peut commencer très simplement avec une union discriminée TypeScript. Chaque état est représenté par un objet ayant une propriété commune, souvent appelée status, type ou state.

type User = {
  id: string;
  name: string;
};

type SearchState =
  | { status: 'idle' }
  | { status: 'loading'; query: string }
  | { status: 'success'; query: string; users: User[] }
  | { status: 'empty'; query: string }
  | { status: 'error'; query: string; message: string };

Cette fois, les états impossibles ne sont plus représentables. Si status vaut 'loading', il n’y a pas de users. Si status vaut 'error', un message d’erreur est obligatoire. Si status vaut 'success', les utilisateurs sont disponibles.

Le rendu devient également plus lisible :

function SearchResult({ state }: { state: SearchState }) {
  switch (state.status) {
    case 'idle':
      return <p>Lancez une recherche.</p>;

    case 'loading':
      return <p>Recherche de « {state.query} »...</p>;

    case 'empty':
      return <p>Aucun résultat pour « {state.query} ».</p>;

    case 'error':
      return <p>Erreur : {state.message}</p>;

    case 'success':
      return (
        <ul>
          {state.users.map((user) => (
            <li key={user.id}>{user.name}</li>
          ))}
        </ul>
      );
  }
}

L’avantage est immédiat : dans chaque branche du switch, TypeScript affine automatiquement le type. Vous n’avez plus besoin de tester partout si users existe.

Ajouter des événements explicites

Une machine à états ne se limite pas à la liste des états. Elle décrit aussi les événements qui font évoluer l’interface. Pour notre recherche, on peut définir les événements suivants :

type SearchEvent =
  | { type: 'SUBMIT'; query: string }
  | { type: 'RESOLVE'; users: User[] }
  | { type: 'REJECT'; message: string }
  | { type: 'RESET' };

Ensuite, on centralise les transitions dans une fonction pure :

function transition(state: SearchState, event: SearchEvent): SearchState {
  switch (event.type) {
    case 'SUBMIT':
      return { status: 'loading', query: event.query };

    case 'RESOLVE': {
      if (state.status !== 'loading') {
        return state;
      }

      if (event.users.length === 0) {
        return { status: 'empty', query: state.query };
      }

      return {
        status: 'success',
        query: state.query,
        users: event.users,
      };
    }

    case 'REJECT':
      if (state.status !== 'loading') {
        return state;
      }

      return {
        status: 'error',
        query: state.query,
        message: event.message,
      };

    case 'RESET':
      return { status: 'idle' };
  }
}

Cette fonction devient le point d’entrée unique de la logique d’état. Elle est facile à tester, indépendante de React, et compréhensible même sans connaître le détail de l’interface.

Utiliser la machine dans un composant React

Dans React, cette structure s’intègre naturellement avec useReducer.

import { useReducer } from 'react';

const initialState: SearchState = { status: 'idle' };

function SearchPage() {
  const [state, dispatch] = useReducer(transition, initialState);

  async function handleSearch(query: string) {
    dispatch({ type: 'SUBMIT', query });

    try {
      const response = await fetch(`/api/users?q=${encodeURIComponent(query)}`);

      if (!response.ok) {
        throw new Error('La recherche a échoué.');
      }

      const users = (await response.json()) as User[];
      dispatch({ type: 'RESOLVE', users });
    } catch (error) {
      dispatch({
        type: 'REJECT',
        message: error instanceof Error ? error.message : 'Erreur inconnue',
      });
    }
  }

  return (
    <main>
      <SearchForm onSubmit={handleSearch} disabled={state.status === 'loading'} />
      <SearchResult state={state} />
    </main>
  );
}

Ce code reste volontairement simple. Dans un projet réel, il faudrait aussi gérer l’annulation des requêtes concurrentes, par exemple avec AbortController, afin d’éviter qu’une ancienne réponse ne remplace un résultat plus récent. C’est précisément là que le modèle par états devient utile : il donne un cadre clair dans lequel intégrer les effets asynchrones.

Rendre les transitions plus strictes

Dans l’exemple précédent, certains événements sont ignorés si l’état courant ne les accepte pas. C’est une stratégie raisonnable pour une interface. Mais dans une logique métier plus stricte, on peut préférer signaler explicitement les transitions interdites.

function assertNever(value: never): never {
  throw new Error(`Cas non géré : ${JSON.stringify(value)}`);
}

function labelForState(state: SearchState): string {
  switch (state.status) {
    case 'idle':
      return 'En attente';
    case 'loading':
      return 'Chargement';
    case 'success':
      return 'Résultats disponibles';
    case 'empty':
      return 'Aucun résultat';
    case 'error':
      return 'Erreur';
    default:
      return assertNever(state);
  }
}

La fonction assertNever force TypeScript à vérifier que tous les cas ont été traités. Si vous ajoutez plus tard un état cancelled, le compilateur signalera les endroits où ce nouvel état doit être pris en compte.

Quand utiliser une vraie bibliothèque de state machine

Une union discriminée suffit pour beaucoup de composants : formulaire multi-étapes, modale complexe, recherche asynchrone, upload de fichier, tunnel de paiement simplifié. Mais certaines situations justifient une bibliothèque spécialisée comme XState : états imbriqués, parallélisme, temporisations, visualisation graphique, acteurs, orchestration de workflows longs.

Le bon critère n’est pas la mode, mais la complexité réelle. Si votre composant tient avec trois états et deux événements, une union TypeScript est préférable. Si votre produit contient un enchaînement métier critique avec de nombreuses transitions, une bibliothèque peut rendre le système plus explicite et plus testable.

Dans les deux cas, la démarche reste la même : nommer les états, nommer les événements, refuser les combinaisons implicites.

Tester une machine à états

Comme la transition est une fonction pure, les tests sont directs. Vous n’avez pas besoin de monter un composant ou de simuler le DOM pour valider la logique principale.

import { describe, expect, it } from 'vitest';

describe('search state machine', () => {
  it('passe de idle à loading lors d’une recherche', () => {
    const nextState = transition(
      { status: 'idle' },
      { type: 'SUBMIT', query: 'Ada' }
    );

    expect(nextState).toEqual({ status: 'loading', query: 'Ada' });
  });

  it('passe de loading à empty si aucun utilisateur n’est trouvé', () => {
    const nextState = transition(
      { status: 'loading', query: 'Ada' },
      { type: 'RESOLVE', users: [] }
    );

    expect(nextState).toEqual({ status: 'empty', query: 'Ada' });
  });
});

Ces tests documentent le comportement attendu. Ils servent aussi de garde-fou lorsque la logique évolue.

Bonnes pratiques d’architecture

Commencez par dessiner les états avant d’écrire le code. Une simple liste suffit souvent : idle, loading, success, empty, error. Ensuite, ajoutez les événements qui provoquent les transitions. Cette étape révèle rapidement les zones ambiguës.

Évitez les noms trop techniques pour les états métier. step2WithInvalidPaymentButRetryEnabled est rarement un bon état. Préférez des états courts, compréhensibles, puis placez les détails dans les données associées.

Séparez aussi la machine des effets. La fonction de transition ne devrait pas appeler fetch, écrire dans le localStorage ou manipuler le DOM. Elle reçoit un état et un événement, puis retourne le prochain état. Les effets restent dans le composant, un hook ou une couche applicative dédiée.

Enfin, gardez le rendu aligné sur les états. Si le design system prévoit une carte d’erreur, un squelette de chargement ou une vue vide, ces éléments devraient correspondre à des états explicites. C’est particulièrement utile dans une architecture frontend qui combine composants responsives, design tokens et formulaires complexes comme ceux décrits dans l’article sur les formulaires accessibles.

Conclusion

Les machines à états ne sont pas réservées aux architectures complexes. En TypeScript, elles commencent souvent par une simple union discriminée et une fonction de transition. Cette structure élimine les états impossibles, rend le rendu plus lisible, facilite les tests et clarifie les effets asynchrones.

Avant d’ajouter un nouveau booléen à un composant, posez-vous une question simple : est-ce vraiment une propriété indépendante, ou est-ce un nouvel état de l’interface ? Dans beaucoup de cas, remplacer plusieurs booléens par un modèle d’état explicite suffit à rendre le code plus prévisible, plus robuste et plus agréable à maintenir.