Design Pattern
Comportementaux

State

Modifier le comportement d'un objet en fonction de son état interne

Problème

Un objet modifie son comportement selon son état. Gérer ça avec des conditions dispersées (if (state === 'loading'), if (status === 'submitted')) produit un code difficile à maintenir dès que les états se multiplient.

Machine à états avec classes

interface OrderState {
  confirm(): void;
  pay(): void;
  ship(): void;
  cancel(): void;
}

class Order {
  private state: OrderState;

  constructor() {
    this.state = new PendingState(this);
  }

  setState(state: OrderState) {
    this.state = state;
  }

  confirm() { this.state.confirm(); }
  pay() { this.state.pay(); }
  ship() { this.state.ship(); }
  cancel() { this.state.cancel(); }
}

class PendingState implements OrderState {
  constructor(private order: Order) {}
  confirm() { this.order.setState(new ConfirmedState(this.order)); }
  pay() { throw new Error('Confirmez d\'abord la commande'); }
  ship() { throw new Error('Impossible d\'expédier une commande non confirmée'); }
  cancel() { this.order.setState(new CancelledState(this.order)); }
}

class ConfirmedState implements OrderState {
  constructor(private order: Order) {}
  confirm() { throw new Error('Déjà confirmée'); }
  pay() { this.order.setState(new PaidState(this.order)); }
  ship() { throw new Error('Payez d\'abord la commande'); }
  cancel() { this.order.setState(new CancelledState(this.order)); }
}

class PaidState implements OrderState {
  constructor(private order: Order) {}
  confirm() { throw new Error('Déjà confirmée'); }
  pay() { throw new Error('Déjà payée'); }
  ship() { this.order.setState(new ShippedState(this.order)); }
  cancel() { /* remboursement... */ this.order.setState(new CancelledState(this.order)); }
}

class ShippedState implements OrderState {
  constructor(private order: Order) {}
  confirm() { throw new Error('Non applicable'); }
  pay() { throw new Error('Non applicable'); }
  ship() { throw new Error('Déjà expédiée'); }
  cancel() { throw new Error('Impossible d\'annuler une commande expédiée'); }
}

class CancelledState implements OrderState {
  constructor(private order: Order) {}
  confirm() { throw new Error('Commande annulée'); }
  pay() { throw new Error('Commande annulée'); }
  ship() { throw new Error('Commande annulée'); }
  cancel() { throw new Error('Déjà annulée'); }
}

Machine à états fonctionnelle avec useReducer

En React, useReducer modélise une machine à états de façon explicite :

type FormState =
  | { status: 'idle' }
  | { status: 'validating' }
  | { status: 'submitting' }
  | { status: 'success'; data: unknown }
  | { status: 'error'; message: string };

type FormAction =
  | { type: 'SUBMIT' }
  | { type: 'VALIDATION_SUCCESS' }
  | { type: 'VALIDATION_ERROR'; message: string }
  | { type: 'SUBMIT_SUCCESS'; data: unknown }
  | { type: 'SUBMIT_ERROR'; message: string }
  | { type: 'RESET' };

function formReducer(state: FormState, action: FormAction): FormState {
  switch (state.status) {
    case 'idle':
      if (action.type === 'SUBMIT') return { status: 'validating' };
      return state;

    case 'validating':
      if (action.type === 'VALIDATION_SUCCESS') return { status: 'submitting' };
      if (action.type === 'VALIDATION_ERROR') return { status: 'error', message: action.message };
      return state;

    case 'submitting':
      if (action.type === 'SUBMIT_SUCCESS') return { status: 'success', data: action.data };
      if (action.type === 'SUBMIT_ERROR') return { status: 'error', message: action.message };
      return state;

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

    default:
      return state;
  }
}

function ContactForm() {
  const [state, dispatch] = useReducer(formReducer, { status: 'idle' });

  async function handleSubmit(e: React.FormEvent) {
    e.preventDefault();
    dispatch({ type: 'SUBMIT' });

    const isValid = await validate(formData);
    if (!isValid) {
      dispatch({ type: 'VALIDATION_ERROR', message: 'Champs invalides' });
      return;
    }

    dispatch({ type: 'VALIDATION_SUCCESS' });

    try {
      const data = await submitForm(formData);
      dispatch({ type: 'SUBMIT_SUCCESS', data });
    } catch (err) {
      dispatch({ type: 'SUBMIT_ERROR', message: (err as Error).message });
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      {/* ... champs ... */}
      <button disabled={state.status === 'submitting' || state.status === 'validating'}>
        {state.status === 'submitting' ? 'Envoi...' : 'Envoyer'}
      </button>
      {state.status === 'error' && <p>{state.message}</p>}
      {state.status === 'success' && <p>Formulaire envoyé</p>}
    </form>
  );
}

XState

Pour des machines à états complexes, XState formalise le pattern :

import { createMachine, assign } from 'xstate';

const checkoutMachine = createMachine({
  id: 'checkout',
  initial: 'cart',
  states: {
    cart: {
      on: { PROCEED: 'shipping' },
    },
    shipping: {
      on: {
        PROCEED: 'payment',
        BACK: 'cart',
      },
    },
    payment: {
      on: {
        SUBMIT: 'processing',
        BACK: 'shipping',
      },
    },
    processing: {
      on: {
        SUCCESS: 'confirmation',
        FAILURE: 'payment',
      },
    },
    confirmation: { type: 'final' },
  },
});

XState génère le diagramme de la machine à partir de cette définition, ce qui facilite la documentation et la revue de code.