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
-
Crée le fichier
src/command/teamCommands.ts. -
Définis une interface
Commandavec deux méthodes :execute()etundo(). -
Implémente deux commandes :
AddPokemonCommand, exécute l'ajout, annule en retirantRemovePokemonCommand, exécute la suppression, annule en remettant le Pokémon à sa position d'origine
-
Crée une classe
CommandHistoryqui stocke les commandes exécutées et expose une méthodeundo(). -
Dans
App.tsx, remplace les handlershandleAddethandleRemovepar des appels àhistory.execute(new AddPokemonCommand(...)). -
Ajoute un bouton "Annuler" dans
TeamPanelqui appellehistory.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.