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.