Design Pattern
Comportementaux

Command

Encapsuler une action comme un objet

Problème

On veut encapsuler des actions sous forme d'objets pour les mettre en file d'attente, les logger, les annuler (undo), ou les rejouer.

Structure de base

interface Command {
  execute(): void;
  undo(): void;
}

Undo/Redo avec Command

interface TextCommand {
  execute(text: string): string;
  undo(text: string): string;
}

class InsertTextCommand implements TextCommand {
  constructor(
    private position: number,
    private insertedText: string
  ) {}

  execute(text: string): string {
    return text.slice(0, this.position) + this.insertedText + text.slice(this.position);
  }

  undo(text: string): string {
    return text.slice(0, this.position) + text.slice(this.position + this.insertedText.length);
  }
}

class DeleteTextCommand implements TextCommand {
  private deletedText = '';

  constructor(
    private position: number,
    private length: number
  ) {}

  execute(text: string): string {
    this.deletedText = text.slice(this.position, this.position + this.length);
    return text.slice(0, this.position) + text.slice(this.position + this.length);
  }

  undo(text: string): string {
    return text.slice(0, this.position) + this.deletedText + text.slice(this.position);
  }
}

class TextEditor {
  private history: TextCommand[] = [];
  private redoStack: TextCommand[] = [];
  private text = '';

  execute(command: TextCommand): void {
    this.text = command.execute(this.text);
    this.history.push(command);
    this.redoStack = []; // une nouvelle action efface le redo
  }

  undo(): void {
    const command = this.history.pop();
    if (!command) return;
    this.text = command.undo(this.text);
    this.redoStack.push(command);
  }

  redo(): void {
    const command = this.redoStack.pop();
    if (!command) return;
    this.text = command.execute(this.text);
    this.history.push(command);
  }

  getText(): string {
    return this.text;
  }
}

Command avec useReducer

En React, useReducer est une implémentation du pattern Command. Chaque action dispatachée est un Command.

type CartAction =
  | { type: 'ADD_ITEM'; payload: CartItem }
  | { type: 'REMOVE_ITEM'; payload: { id: string } }
  | { type: 'UPDATE_QUANTITY'; payload: { id: string; quantity: number } }
  | { type: 'CLEAR_CART' };

interface CartState {
  items: CartItem[];
}

function cartReducer(state: CartState, action: CartAction): CartState {
  switch (action.type) {
    case 'ADD_ITEM':
      return { items: [...state.items, action.payload] };

    case 'REMOVE_ITEM':
      return { items: state.items.filter(item => item.id !== action.payload.id) };

    case 'UPDATE_QUANTITY':
      return {
        items: state.items.map(item =>
          item.id === action.payload.id
            ? { ...item, quantity: action.payload.quantity }
            : item
        ),
      };

    case 'CLEAR_CART':
      return { items: [] };

    default:
      return state;
  }
}

function CartProvider({ children }: { children: React.ReactNode }) {
  const [state, dispatch] = useReducer(cartReducer, { items: [] });

  // Les "Commands" sont les fonctions exposées
  const addItem = (item: CartItem) => dispatch({ type: 'ADD_ITEM', payload: item });
  const removeItem = (id: string) => dispatch({ type: 'REMOVE_ITEM', payload: { id } });
  const updateQuantity = (id: string, quantity: number) =>
    dispatch({ type: 'UPDATE_QUANTITY', payload: { id, quantity } });
  const clearCart = () => dispatch({ type: 'CLEAR_CART' });

  return (
    <CartContext.Provider value={{ ...state, addItem, removeItem, updateQuantity, clearCart }}>
      {children}
    </CartContext.Provider>
  );
}

File de commandes (Command Queue)

class CommandQueue {
  private queue: Array<() => Promise<void>> = [];
  private isProcessing = false;

  enqueue(command: () => Promise<void>): void {
    this.queue.push(command);
    if (!this.isProcessing) this.process();
  }

  private async process(): Promise<void> {
    this.isProcessing = true;
    while (this.queue.length > 0) {
      const command = this.queue.shift()!;
      try {
        await command();
      } catch (error) {
        console.error('Command failed:', error);
      }
    }
    this.isProcessing = false;
  }
}

// Sérialiser des mutations pour éviter les race conditions
const syncQueue = new CommandQueue();

syncQueue.enqueue(() => saveUserProfile(updates));
syncQueue.enqueue(() => sendAnalyticsEvent('profile_updated'));

Exercice : Command dans le Pokémon Builder

Contexte

Ajouter et retirer un Pokémon de l'équipe sont des actions irréversibles. Si tu fais une erreur, il n'y a aucun moyen d'annuler. La logique d'action est directement dans les handlers de App.tsx :

function handleAdd(pokemon: Pokemon) {
  setTeam([...team, pokemon]);
}

function handleRemove(id: number) {
  setTeam(team.filter((p) => p.id !== id));
}

Objectif

Encapsuler les actions sur l'équipe dans des objets Command pour permettre un historique et un undo.

Instructions

  1. Crée le fichier src/command/teamCommands.ts.

  2. Définis une interface Command avec deux méthodes : execute() et undo().

  3. Implémente deux commandes :

    • AddPokemonCommand, exécute l'ajout, annule en retirant
    • RemovePokemonCommand, exécute la suppression, annule en remettant le Pokémon à sa position d'origine
  4. Crée une classe CommandHistory qui stocke les commandes exécutées et expose une méthode undo().

  5. Dans App.tsx, remplace les handlers handleAdd et handleRemove par des appels à history.execute(new AddPokemonCommand(...)).

  6. Ajoute un bouton "Annuler" dans TeamPanel qui appelle history.undo() et est désactivé si l'historique est vide.

Résultat attendu

Chaque ajout et suppression est réversible. Le bouton "Annuler" remonte les actions une par une dans l'ordre inverse.