Aller au contenu principal

Concept #034

Design Patterns

Le Design Pattern State

#0347 min de lecture
  • design pattern
  • comportemental
  • état
State : changer de comportement sans changer de classe

Le Design Pattern State en une phrase

State est un patron de conception comportemental qui permet à un objet de modifier son comportement quand son état interne change. Pour le code extérieur, l'objet semble littéralement changer de classe.

Fini les chaînes interminables de if/else if/switch pour gérer les différents statuts d'un objet. Chaque état devient une classe à part entière avec son propre comportement.


Le problème : la machine à if/else

Imaginez un lecteur de musique avec trois états : Arrêté, En lecture, En pause. Chaque bouton (Play, Pause, Stop) a un comportement différent selon l'état courant.

L'approche naïve avec des conditionnelles :

class MusicPlayer {
  private state: "stopped" | "playing" | "paused" = "stopped";
 
  pressPlay(): void {
    if (this.state === "stopped") {
      console.log("Démarrage de la lecture");
      this.state = "playing";
    } else if (this.state === "playing") {
      console.log("Déjà en lecture");
    } else if (this.state === "paused") {
      console.log("Reprise de la lecture");
      this.state = "playing";
    }
  }
 
  pressPause(): void {
    if (this.state === "stopped") {
      console.log("Impossible de mettre en pause : rien ne joue");
    } else if (this.state === "playing") {
      console.log("Mise en pause");
      this.state = "paused";
    } else if (this.state === "paused") {
      console.log("Déjà en pause");
    }
  }
 
  pressStop(): void {
    if (this.state === "stopped") {
      console.log("Déjà arrêté");
    } else if (this.state === "playing") {
      console.log("Arrêt de la lecture");
      this.state = "stopped";
    } else if (this.state === "paused") {
      console.log("Arrêt depuis la pause");
      this.state = "stopped";
    }
  }
}

Ce code fonctionne, mais il souffre de plusieurs problèmes :

  • Chaque nouvelle action (avance rapide, retour) nécessite un nouveau if/else avec une branche par état.
  • Chaque nouvel état (buffering, erreur) nécessite une nouvelle branche dans chaque méthode.
  • La logique est dispersée : le comportement de l'état "En lecture" est éparpillé dans trois méthodes différentes.
  • Difficile à tester : pour tester le comportement de "En pause", il faut simuler les transitions qui y mènent.

Avec 5 états et 5 actions, on arrive à 25 branches conditionnelles imbriquées. Le code devient illisible.


La solution : encapsuler chaque état dans une classe

Le pattern State extrait chaque état dans sa propre classe. Toutes les classes d'état implémentent la même interface. L'objet principal (le "contexte") délègue les appels à l'objet d'état courant.

L'interface commune

interface PlayerState {
  pressPlay(player: MusicPlayer): void;
  pressPause(player: MusicPlayer): void;
  pressStop(player: MusicPlayer): void;
}

Les classes d'état

class StoppedState implements PlayerState {
  pressPlay(player: MusicPlayer): void {
    console.log("Démarrage de la lecture");
    player.setState(new PlayingState());
  }
 
  pressPause(player: MusicPlayer): void {
    console.log("Impossible de mettre en pause : rien ne joue");
  }
 
  pressStop(player: MusicPlayer): void {
    console.log("Déjà arrêté");
  }
}
 
class PlayingState implements PlayerState {
  pressPlay(player: MusicPlayer): void {
    console.log("Déjà en lecture");
  }
 
  pressPause(player: MusicPlayer): void {
    console.log("Mise en pause");
    player.setState(new PausedState());
  }
 
  pressStop(player: MusicPlayer): void {
    console.log("Arrêt de la lecture");
    player.setState(new StoppedState());
  }
}
 
class PausedState implements PlayerState {
  pressPlay(player: MusicPlayer): void {
    console.log("Reprise de la lecture");
    player.setState(new PlayingState());
  }
 
  pressPause(player: MusicPlayer): void {
    console.log("Déjà en pause");
  }
 
  pressStop(player: MusicPlayer): void {
    console.log("Arrêt depuis la pause");
    player.setState(new StoppedState());
  }
}

Le contexte

class MusicPlayer {
  private state: PlayerState;
 
  constructor() {
    this.state = new StoppedState();
  }
 
  setState(state: PlayerState): void {
    this.state = state;
  }
 
  pressPlay(): void {
    this.state.pressPlay(this);
  }
 
  pressPause(): void {
    this.state.pressPause(this);
  }
 
  pressStop(): void {
    this.state.pressStop(this);
  }
}

L'utilisation reste identique :

const player = new MusicPlayer();
 
player.pressPlay();   // "Démarrage de la lecture"
player.pressPause();  // "Mise en pause"
player.pressPlay();   // "Reprise de la lecture"
player.pressStop();   // "Arrêt de la lecture"
player.pressPause();  // "Impossible de mettre en pause : rien ne joue"

Exemple concret : le cycle de vie d'une commande

Un cas d'usage réel : la gestion des statuts d'une commande e-commerce.

interface OrderState {
  pay(order: Order): void;
  ship(order: Order): void;
  deliver(order: Order): void;
  cancel(order: Order): void;
}
 
class PendingState implements OrderState {
  pay(order: Order): void {
    console.log("Paiement accepté");
    order.setState(new PaidState());
  }
 
  ship(order: Order): void {
    throw new Error("Impossible d'expédier : commande non payée");
  }
 
  deliver(order: Order): void {
    throw new Error("Impossible de livrer : commande non expédiée");
  }
 
  cancel(order: Order): void {
    console.log("Commande annulée");
    order.setState(new CancelledState());
  }
}
 
class PaidState implements OrderState {
  pay(order: Order): void {
    throw new Error("Commande déjà payée");
  }
 
  ship(order: Order): void {
    console.log("Commande expédiée");
    order.setState(new ShippedState());
  }
 
  deliver(order: Order): void {
    throw new Error("Impossible de livrer : commande non expédiée");
  }
 
  cancel(order: Order): void {
    console.log("Remboursement et annulation");
    order.setState(new CancelledState());
  }
}
 
class ShippedState implements OrderState {
  pay(order: Order): void {
    throw new Error("Commande déjà payée");
  }
 
  ship(order: Order): void {
    throw new Error("Commande déjà expédiée");
  }
 
  deliver(order: Order): void {
    console.log("Commande livrée");
    order.setState(new DeliveredState());
  }
 
  cancel(order: Order): void {
    throw new Error("Impossible d'annuler : commande déjà expédiée");
  }
}
 
class DeliveredState implements OrderState {
  pay(): void { throw new Error("Commande terminée"); }
  ship(): void { throw new Error("Commande terminée"); }
  deliver(): void { throw new Error("Commande déjà livrée"); }
  cancel(): void { throw new Error("Impossible d'annuler : commande livrée"); }
}
 
class CancelledState implements OrderState {
  pay(): void { throw new Error("Commande annulée"); }
  ship(): void { throw new Error("Commande annulée"); }
  deliver(): void { throw new Error("Commande annulée"); }
  cancel(): void { throw new Error("Commande déjà annulée"); }
}

Chaque état est explicite. Les transitions autorisées sont claires. Les transitions interdites lancent des erreurs significatives. Ajouter un nouvel état (par exemple "Retourné") revient à créer une nouvelle classe sans modifier les existantes.


Les avantages du pattern State

Responsabilité unique : chaque classe d'état ne gère que le comportement d'un seul état. La logique est regroupée par état, pas dispersée dans des conditionnelles.

Open/Closed : ajouter un nouvel état ne nécessite pas de modifier les classes existantes. On crée une nouvelle classe qui implémente l'interface.

Transitions explicites : les transitions d'état sont codées dans les méthodes de chaque état. Elles sont visibles et testables individuellement.

Suppression des conditionnelles : plus de if/else if/switch basé sur l'état. Le polymorphisme gère la dispatch.


State vs Strategy : la confusion fréquente

Les deux patterns ont une structure quasi identique (une interface + des classes concrètes), mais leur intention est différente :

StateStrategy
IntentionL'objet change de comportement selon son étatLe client choisit un algorithme parmi plusieurs
TransitionsLes classes d'état se remplacent mutuellementL'algorithme est fixe pour la durée d'utilisation
ConnaissanceLes états connaissent les autres états (pour les transitions)Les stratégies ne se connaissent pas entre elles
Qui change ?L'objet lui-même (ou l'état courant)Le code client

Quand utiliser le pattern State ?

  • Un objet a un cycle de vie avec des statuts distincts : commande (en attente, payée, expédiée, livrée), document (brouillon, en revue, publié), connexion réseau (déconnecté, en cours, connecté).
  • Le comportement change radicalement selon l'état : les mêmes actions produisent des résultats très différents.
  • Les conditionnelles sur l'état se multiplient : quand vous avez plus de 3 états et plus de 3 actions, le switch/case devient ingérable.

Si votre objet n'a que deux états avec des comportements simples, un booléen suffit. Le pattern State se justifie quand la complexité des états et des transitions dépasse ce qu'une conditionnelle gère confortablement.


Résumé

Le pattern State transforme les branches conditionnelles en polymorphisme. Chaque état est une classe autonome qui sait comment réagir aux événements et vers quel état transitionner. Le résultat : un code plus lisible, plus testable et plus extensible, où l'ajout d'un nouvel état ne perturbe pas le comportement des états existants.