Cours JavaScript et TypeScript · Module 9: Design patterns et architecture

Les patterns de gestion d'état en JavaScript/TypeScript

20 min de lecture
Dans cette leçon5

Au centre de commandement du parc, l'état change à chaque seconde : portes ouvertes, dinosaures nourris, alarmes. Quand chaque module modifie ces données à sa façon, personne ne sait après une panne qui a ouvert la porte de l'enclos des vélociraptors, ni à quel moment. Les patterns de gestion d'état sont là pour ça. Tu vas en découvrir trois : Flux, Event Sourcing et les machines à états (State Machines).

Flux Architecture - le flux de données unidirectionnel

Flux est un pattern d'architecture d'application qui introduit un flux de données unidirectionnel, ce qui rend l'état de l'application plus prévisible et plus facile à déboguer. Il a été proposé par Facebook : une action arrive au dispatcher, qui la transmet aux stores, et la vue se rafraîchit après le changement.

Les composants de base de Flux

Une action (Action) est un objet doté d'un champ type et d'un payload optionnel, le dispatcher distribue les actions, le store conserve l'état et prévient ses abonnés, et les action creators sont des fonctions qui créent des actions :

1// Types pour Flux
2type Action<T = any> = {
3  type: string;
4  payload?: T;
5};
6
7type Listener = () => void;
8
9// Store - conserve l'état de l'application
10class Store<T> {
11  private state: T;
12  private listeners: Listener[] = [];
13
14  constructor(initialState: T) {
15    this.state = initialState;
16  }
17
18  getState(): T {
19    return this.state;
20  }
21
22  protected setState(newState: T): void {
23    this.state = newState;
24    this.emitChange();
25  }
26
27  subscribe(listener: Listener): () => void {
28    this.listeners.push(listener);
29
30    // On renvoie une fonction de désabonnement (unsubscribe)
31    return () => {
32      const index = this.listeners.indexOf(listener);
33      if (index > -1) {
34        this.listeners.splice(index, 1);
35      }
36    };
37  }
38
39  private emitChange(): void {
40    this.listeners.forEach(listener => listener());
41  }
42}
43
44// Dispatcher - le hub central de toutes les actions
45class Dispatcher {
46  private callbacks: Array<(action: Action) => void> = [];
47
48  register(callback: (action: Action) => void): string {
49    const id = Math.random().toString(36).slice(2, 11);
50    this.callbacks.push(callback);
51    return id;
52  }
53
54  dispatch(action: Action): void {
55    console.log('Dispatching action:', action);
56    this.callbacks.forEach(callback => {
57      callback(action);
58    });
59  }
60}
61
62// Action Creators - des fonctions qui créent des actions
63const TodoActions = {
64  addTodo: (text: string): Action<{ text: string }> => ({
65    type: 'ADD_TODO',
66    payload: { text }
67  }),
68
69  toggleTodo: (id: string): Action<{ id: string }> => ({
70    type: 'TOGGLE_TODO',
71    payload: { id }
72  }),
73
74  deleteTodo: (id: string): Action<{ id: string }> => ({
75    type: 'DELETE_TODO',
76    payload: { id }
77  }),
78
79  setFilter: (filter: 'all' | 'active' | 'completed'): Action<{ filter: string }> => ({
80    type: 'SET_FILTER',
81    payload: { filter }
82  })
83};
84
85// État de l'application
86interface Todo {
87  id: string;
88  text: string;
89  completed: boolean;
90  createdAt: Date;
91}
92
93interface TodoState {
94  todos: Todo[];
95  filter: 'all' | 'active' | 'completed';
96}
97
98// Implémentation de TodoStore
99class TodoStore extends Store<TodoState> {
100  constructor(dispatcher: Dispatcher) {
101    super({
102      todos: [],
103      filter: 'all'
104    });
105
106    // Enregistrement auprès du dispatcher
107    dispatcher.register((action: Action) => {
108      this.handleAction(action);
109    });
110  }
111
112  private handleAction(action: Action): void {
113    const currentState = this.getState();
114
115    switch (action.type) {
116      case 'ADD_TODO':
117        this.setState({
118          ...currentState,
119          todos: [
120            ...currentState.todos,
121            {
122              id: Math.random().toString(36).slice(2, 11),
123              text: action.payload.text,
124              completed: false,
125              createdAt: new Date()
126            }
127          ]
128        });
129        break;
130
131      case 'TOGGLE_TODO':
132        this.setState({
133          ...currentState,
134          todos: currentState.todos.map(todo =>
135            todo.id === action.payload.id
136              ? { ...todo, completed: !todo.completed }
137              : todo
138          )
139        });
140        break;
141
142      case 'DELETE_TODO':
143        this.setState({
144          ...currentState,
145          todos: currentState.todos.filter(todo => todo.id !== action.payload.id)
146        });
147        break;
148
149      case 'SET_FILTER':
150        this.setState({
151          ...currentState,
152          filter: action.payload.filter
153        });
154        break;
155
156      case 'LOAD_TODOS_SUCCESS':
157        this.setState({
158          ...currentState,
159          todos: action.payload.todos
160        });
161        break;
162
163      default:
164        // Aucun changement d'état pour les actions inconnues
165        break;
166    }
167  }
168
169  // Sélecteurs - des fonctions qui récupèrent des données transformées
170  getFilteredTodos(): Todo[] {
171    const { todos, filter } = this.getState();
172
173    switch (filter) {
174      case 'active':
175        return todos.filter(todo => !todo.completed);
176      case 'completed':
177        return todos.filter(todo => todo.completed);
178      default:
179        return todos;
180    }
181  }
182
183  getStats() {
184    const todos = this.getState().todos;
185    return {
186      total: todos.length,
187      completed: todos.filter(todo => todo.completed).length,
188      active: todos.filter(todo => !todo.completed).length
189    };
190  }
191}
192
193// Utilisation de Flux
194const dispatcher = new Dispatcher();
195const todoStore = new TodoStore(dispatcher);
196
197// Abonnement aux changements
198const unsubscribe = todoStore.subscribe(() => {
199  console.log("L'état a changé :", {
200    todos: todoStore.getFilteredTodos(),
201    stats: todoStore.getStats(),
202    filter: todoStore.getState().filter
203  });
204});
205
206// Exécution des actions
207dispatcher.dispatch(TodoActions.addTodo('Apprendre Flux'));
208dispatcher.dispatch(TodoActions.addTodo('Implémenter la TodoApp'));
209dispatcher.dispatch(TodoActions.toggleTodo(todoStore.getState().todos[0].id));
210dispatcher.dispatch(TodoActions.setFilter('active'));

La méthode setState est protégée, donc seule une action passée par le dispatcher peut définir un nouvel état. getState renvoie toutefois une référence vers l'objet d'état, qui permet de le modifier sans passer par le dispatcher. C'est pourquoi il vaut mieux geler le nouvel état avant que le store ne prévienne ses abonnés : Object.freeze ne gèle qu'un seul niveau, donc on gèle aussi les tableaux imbriqués comme todos. Chaque élément a une seule responsabilité (SRP), et le store n'existe généralement qu'en une seule instance, comme un Singleton. Dans Redux, la logique de handleAction part dans un reducer : une fonction pure qui crée un nouvel état à partir de l'état actuel et de l'action.

Flux avancé avec des middlewares

Un middleware est une couche que chaque action traverse avant d'atteindre le store, comme un sas à l'entrée d'un enclos. Ici, il ajoute la journalisation, le débogage et la gestion des actions asynchrones :

1// Middleware pour Flux
2type Middleware = (action: Action, next: (action: Action) => void) => void;
3
4class EnhancedDispatcher extends Dispatcher {
5  private middlewares: Middleware[] = [];
6
7  use(middleware: Middleware): void {
8    this.middlewares.push(middleware);
9  }
10
11  dispatch(action: Action): void {
12    let index = 0;
13
14    const next = (currentAction: Action) => {
15      if (index < this.middlewares.length) {
16        const middleware = this.middlewares[index++];
17        middleware(currentAction, next);
18      } else {
19        super.dispatch(currentAction);
20      }
21    };
22
23    next(action);
24  }
25}
26
27// Middleware de journalisation
28const loggingMiddleware: Middleware = (action, next) => {
29  console.group(`Action: ${action.type}`);
30  console.log('Payload:', action.payload);
31  console.time('Action Duration');
32
33  next(action);
34
35  console.timeEnd('Action Duration');
36  console.groupEnd();
37};
38
39// Middleware de débogage
40const debugMiddleware: Middleware = (action, next) => {
41  if (process.env.NODE_ENV === 'development') {
42    console.log('Debug: Before action', action);
43  }
44
45  next(action);
46
47  if (process.env.NODE_ENV === 'development') {
48    console.log('Debug: After action', action);
49  }
50};
51
52// Middleware de gestion des actions asynchrones
53const asyncMiddleware: Middleware = (action, next) => {
54  if (typeof action.payload === 'function') {
55    // C'est une action asynchrone
56    action.payload((asyncAction: Action) => {
57      next(asyncAction);
58    });
59  } else {
60    next(action);
61  }
62};
63
64// Async Action Creator
65const AsyncTodoActions = {
66  loadTodos: () => ({
67    type: 'ASYNC_ACTION',
68    payload: (dispatch: (action: Action) => void) => {
69      // Simulation d'un appel API
70      setTimeout(() => {
71        dispatch({
72          type: 'LOAD_TODOS_SUCCESS',
73          payload: {
74            todos: [
75              { id: '1', text: "Todo venant de l'API", completed: false, createdAt: new Date() }
76            ]
77          }
78        });
79      }, 1000);
80    }
81  })
82};
83
84// Utilisation de l'Enhanced Dispatcher
85const enhancedDispatcher = new EnhancedDispatcher();
86enhancedDispatcher.use(loggingMiddleware);
87enhancedDispatcher.use(debugMiddleware);
88enhancedDispatcher.use(asyncMiddleware);
89
90const enhancedTodoStore = new TodoStore(enhancedDispatcher);

Chaque middleware décide s'il appelle next, il peut donc interrompre, retarder ou enrichir l'action. Tu ajoutes un nouveau comportement avec use, sans modifier la classe, conformément au principe ouvert/fermé.

Event Sourcing - l'historique des événements comme source de vérité

L'Event Sourcing est un pattern dans lequel l'état de l'application est déterminé par une séquence d'événements, au lieu de stocker directement l'état actuel. C'est comme le journal d'un gardien : tu n'effaces pas le tableau qui affiche l'état de l'enclos, tu consignes chaque événement, et tu reconstitues l'état en relisant le journal depuis le début.

Implémentation de base de l'Event Sourcing

Un agrégat (Aggregate) est un objet dont on construit l'état à partir des events, ici User. Les méthodes comme changeEmail ne modifient pas l'état, elles renvoient seulement de nouveaux events, et c'est apply qui effectue réellement le changement :

1// Types pour l'Event Sourcing
2interface Event {
3  id: string;
4  type: string;
5  data: any;
6  timestamp: Date;
7  version: number;
8}
9
10interface Aggregate {
11  id: string;
12  version: number;
13  apply(event: Event): void;
14}
15
16// Event Store - conserve tous les events
17class EventStore {
18  private events: Map<string, Event[]> = new Map();
19
20  async saveEvents(aggregateId: string, events: Event[], expectedVersion: number): Promise<void> {
21    const existingEvents = this.events.get(aggregateId) || [];
22
23    if (existingEvents.length !== expectedVersion) {
24      throw new Error('Concurrency conflict: unexpected version');
25    }
26
27    // Ajout de nouveaux events
28    const newEvents = events.map((event, index) => ({
29      ...event,
30      version: expectedVersion + index + 1,
31      timestamp: new Date()
32    }));
33
34    this.events.set(aggregateId, [...existingEvents, ...newEvents]);
35    console.log(`Saved ${newEvents.length} events for aggregate ${aggregateId}`);
36  }
37
38  async getEvents(aggregateId: string, fromVersion: number = 0): Promise<Event[]> {
39    const events = this.events.get(aggregateId) || [];
40    return events.filter(event => event.version > fromVersion);
41  }
42
43  async getAllEvents(): Promise<Event[]> {
44    const allEvents: Event[] = [];
45    for (const events of this.events.values()) {
46      allEvents.push(...events);
47    }
48    return allEvents.sort((a, b) => a.timestamp.getTime() - b.timestamp.getTime());
49  }
50}
51
52// Définitions des events
53const UserEvents = {
54  UserCreated: (data: { name: string; email: string }) => ({
55    type: 'UserCreated',
56    data
57  }),
58
59  UserEmailChanged: (data: { email: string }) => ({
60    type: 'UserEmailChanged',
61    data
62  }),
63
64  UserDeactivated: (data: { reason: string }) => ({
65    type: 'UserDeactivated',
66    data
67  }),
68
69  UserReactivated: () => ({
70    type: 'UserReactivated',
71    data: {}
72  })
73};
74
75// User Aggregate
76class User implements Aggregate {
77  id: string;
78  version: number = 0;
79  name: string = '';
80  email: string = '';
81  isActive: boolean = true;
82  createdAt?: Date;
83  deactivatedAt?: Date;
84
85  constructor(id: string) {
86    this.id = id;
87  }
88
89  // Méthodes qui créent des events (commands)
90  static create(id: string, name: string, email: string): { user: User; events: any[] } {
91    const user = new User(id);
92    const events = [UserEvents.UserCreated({ name, email })];
93    return { user, events };
94  }
95
96  changeEmail(newEmail: string): any[] {
97    if (this.email === newEmail) {
98      return []; // Aucun changement
99    }
100
101    if (!this.isActive) {
102      throw new Error('Cannot change email for deactivated user');
103    }
104
105    return [UserEvents.UserEmailChanged({ email: newEmail })];
106  }
107
108  deactivate(reason: string): any[] {
109    if (!this.isActive) {
110      return []; // Déjà inactif
111    }
112
113    return [UserEvents.UserDeactivated({ reason })];
114  }
115
116  reactivate(): any[] {
117    if (this.isActive) {
118      return []; // Déjà actif
119    }
120
121    return [UserEvents.UserReactivated()];
122  }
123
124  // Application des events pour changer l'état
125  apply(event: Event): void {
126    switch (event.type) {
127      case 'UserCreated':
128        this.name = event.data.name;
129        this.email = event.data.email;
130        this.createdAt = event.timestamp;
131        this.isActive = true;
132        break;
133
134      case 'UserEmailChanged':
135        this.email = event.data.email;
136        break;
137
138      case 'UserDeactivated':
139        this.isActive = false;
140        this.deactivatedAt = event.timestamp;
141        break;
142
143      case 'UserReactivated':
144        this.isActive = true;
145        this.deactivatedAt = undefined;
146        break;
147
148      default:
149        throw new Error(`Unknown event type: ${event.type}`);
150    }
151
152    this.version = event.version;
153  }
154
155  // Reconstitution de l'état à partir des events
156  static fromHistory(id: string, events: Event[]): User {
157    const user = new User(id);
158    events.forEach(event => user.apply(event));
159    return user;
160  }
161}
162
163// Repository pour l'agrégat User
164class UserRepository {
165  constructor(private eventStore: EventStore) {}
166
167  async save(user: User, newEvents: any[]): Promise<void> {
168    if (newEvents.length === 0) return;
169
170    const events = newEvents.map(eventData => ({
171      id: Math.random().toString(36).slice(2, 11),
172      type: eventData.type,
173      data: eventData.data,
174      timestamp: new Date(),
175      version: 0 // Sera définie dans l'EventStore
176    }));
177
178    await this.eventStore.saveEvents(user.id, events, user.version);
179
180    // Application des events enregistrés (avec les versions attribuées par l'EventStore) à l'agrégat
181    const savedEvents = await this.eventStore.getEvents(user.id, user.version);
182    savedEvents.forEach(event => user.apply(event));
183  }
184
185  async getById(id: string): Promise<User | null> {
186    const events = await this.eventStore.getEvents(id);
187
188    if (events.length === 0) {
189      return null;
190    }
191
192    return User.fromHistory(id, events);
193  }
194}
195
196// Utilisation de l'Event Sourcing
197async function demonstrateEventSourcing() {
198  const eventStore = new EventStore();
199  const userRepository = new UserRepository(eventStore);
200
201  // Création d'un utilisateur
202  const { user, events } = User.create('user1', 'Jan Kowalski', 'jan@example.com');
203  await userRepository.save(user, events);
204
205  console.log('Utilisateur après sa création :', user);
206
207  // Changement d'e-mail
208  const emailChangeEvents = user.changeEmail('jan.kowalski@example.com');
209  await userRepository.save(user, emailChangeEvents);
210
211  console.log("Utilisateur après le changement d'e-mail :", user);
212
213  // Désactivation
214  const deactivateEvents = user.deactivate('User requested account deletion');
215  await userRepository.save(user, deactivateEvents);
216
217  console.log('Utilisateur après sa désactivation :', user);
218
219  // Réactivation
220  const reactivateEvents = user.reactivate();
221  await userRepository.save(user, reactivateEvents);
222
223  console.log('Utilisateur après sa réactivation :', user);
224
225  // Reconstitution de l'état à partir des events
226  const reconstructedUser = await userRepository.getById('user1');
227  console.log('Utilisateur reconstitué :', reconstructedUser);
228
229  // Historique de tous les events
230  const allEvents = await eventStore.getAllEvents();
231  console.log('Historique des events :', allEvents);
232}
233
234demonstrateEventSourcing();

EventStore surveille les versions : si quelqu'un a ajouté un event entre-temps, l'écriture se solde par un conflit (contrôle de concurrence optimiste). C'est pourquoi le repository applique les events avec les versions attribuées par le magasin, et fromHistory reconstitue l'utilisateur à partir de son seul historique.

Projecteurs et read models

Relire tout l'historique à chaque question serait lent. Un projecteur écoute les events et construit à partir d'eux un modèle prêt à être lu (read model), par exemple des statistiques :

1// Projecteur - crée des read models à partir des events
2abstract class Projector {
3  abstract getProjectionName(): string;
4  abstract handle(event: Event): Promise<void>;
5}
6
7// Read model pour les statistiques des utilisateurs
8interface UserStats {
9  totalUsers: number;
10  activeUsers: number;
11  deactivatedUsers: number;
12  emailChanges: number;
13}
14
15class UserStatsProjector extends Projector {
16  private stats: UserStats = {
17    totalUsers: 0,
18    activeUsers: 0,
19    deactivatedUsers: 0,
20    emailChanges: 0
21  };
22
23  getProjectionName(): string {
24    return 'UserStats';
25  }
26
27  async handle(event: Event): Promise<void> {
28    switch (event.type) {
29      case 'UserCreated':
30        this.stats.totalUsers++;
31        this.stats.activeUsers++;
32        break;
33
34      case 'UserEmailChanged':
35        this.stats.emailChanges++;
36        break;
37
38      case 'UserDeactivated':
39        this.stats.activeUsers--;
40        this.stats.deactivatedUsers++;
41        break;
42
43      case 'UserReactivated':
44        this.stats.activeUsers++;
45        this.stats.deactivatedUsers--;
46        break;
47    }
48
49    console.log(`[UserStatsProjector] Updated stats:`, this.stats);
50  }
51
52  getStats(): UserStats {
53    return { ...this.stats };
54  }
55}
56
57// Event Bus pour les projecteurs
58class EventBus {
59  private projectors: Projector[] = [];
60
61  registerProjector(projector: Projector): void {
62    this.projectors.push(projector);
63  }
64
65  async publishEvent(event: Event): Promise<void> {
66    console.log(`Publishing event: ${event.type}`);
67
68    const promises = this.projectors.map(projector =>
69      projector.handle(event).catch(error => {
70        console.error(`Error in projector ${projector.getProjectionName()}:`, error);
71      })
72    );
73
74    await Promise.all(promises);
75  }
76}
77
78// Enhanced Event Store avec Event Bus
79class EnhancedEventStore extends EventStore {
80  constructor(private eventBus: EventBus) {
81    super();
82  }
83
84  async saveEvents(aggregateId: string, events: Event[], expectedVersion: number): Promise<void> {
85    await super.saveEvents(aggregateId, events, expectedVersion);
86
87    // Publication des events vers les projecteurs
88    const savedEvents = await this.getEvents(aggregateId, expectedVersion);
89    for (const event of savedEvents) {
90      await this.eventBus.publishEvent(event);
91    }
92  }
93}
94
95// Utilisation avec des projecteurs
96async function demonstrateProjectors() {
97  const eventBus = new EventBus();
98  const userStatsProjector = new UserStatsProjector();
99
100  eventBus.registerProjector(userStatsProjector);
101
102  const enhancedEventStore = new EnhancedEventStore(eventBus);
103  const userRepository = new UserRepository(enhancedEventStore);
104
105  // Exécution des opérations - les projecteurs seront mis à jour automatiquement
106  const { user, events } = User.create('user2', 'Anna Nowak', 'anna@example.com');
107  await userRepository.save(user, events);
108
109  const emailChangeEvents = user.changeEmail('anna.nowak@example.com');
110  await userRepository.save(user, emailChangeEvents);
111
112  console.log('Statistiques finales :', userStatsProjector.getStats());
113}
114
115demonstrateProjectors();

Le projecteur ne modifie pas l'historique, il le résume : tu peux donc en ajouter un nouveau à tout moment et l'alimenter avec les events existants.

State Machines (machines à états) avec XState

Les machines à états permettent de modéliser des flux d'états complexes de façon déclarative et prévisible. Une machine se trouve toujours dans exactement un état, et une transition ne se produit qu'après un événement autorisé dans cet état, comme une porte que tu ne peux pas ouvrir pendant le nourrissage.

Une machine à états de base

Commençons par notre propre machine. Chaque état possède une map on, dans laquelle un événement désigne l'état suivant, ainsi que des entry et exit optionnels, lancés à l'entrée et à la sortie :

1// Implémentation d'une machine à états simple (sans XState)
2interface StateDefinition {
3  on: Record<string, string>;
4  entry?: () => void;
5  exit?: () => void;
6}
7
8interface MachineConfig {
9  id: string;
10  initial: string;
11  states: Record<string, StateDefinition>;
12}
13
14class StateMachine {
15  private currentState: string;
16  private config: MachineConfig;
17  private listeners: Array<(state: string) => void> = [];
18
19  constructor(config: MachineConfig) {
20    this.config = config;
21    this.currentState = config.initial;
22    this.executeEntry();
23  }
24
25  getCurrentState(): string {
26    return this.currentState;
27  }
28
29  send(event: string): void {
30    const currentStateConfig = this.config.states[this.currentState];
31    const nextState = currentStateConfig.on[event];
32
33    if (!nextState) {
34      console.warn(`Event "${event}" not handled in state "${this.currentState}"`);
35      return;
36    }
37
38    this.transition(nextState);
39  }
40
41  private transition(nextState: string): void {
42    if (!this.config.states[nextState]) {
43      throw new Error(`State "${nextState}" not defined`);
44    }
45
46    console.log(`Transition: ${this.currentState} -> ${nextState}`);
47
48    // Sortie de l'état courant
49    this.executeExit();
50
51    // Changement d'état
52    const previousState = this.currentState;
53    this.currentState = nextState;
54
55    // Entrée dans le nouvel état
56    this.executeEntry();
57
58    // Notification des abonnés
59    this.listeners.forEach(listener => listener(this.currentState));
60  }
61
62  private executeEntry(): void {
63    const stateConfig = this.config.states[this.currentState];
64    if (stateConfig.entry) {
65      stateConfig.entry();
66    }
67  }
68
69  private executeExit(): void {
70    const stateConfig = this.config.states[this.currentState];
71    if (stateConfig.exit) {
72      stateConfig.exit();
73    }
74  }
75
76  subscribe(listener: (state: string) => void): () => void {
77    this.listeners.push(listener);
78    return () => {
79      const index = this.listeners.indexOf(listener);
80      if (index > -1) {
81        this.listeners.splice(index, 1);
82      }
83    };
84  }
85}
86
87// Exemple : une machine à états pour le processus de commande
88const orderMachine = new StateMachine({
89  id: 'order',
90  initial: 'cart',
91  states: {
92    cart: {
93      on: {
94        CHECKOUT: 'checkout'
95      },
96      entry: () => console.log('Entered cart state'),
97      exit: () => console.log('Exited cart state')
98    },
99    checkout: {
100      on: {
101        PAYMENT: 'payment',
102        BACK_TO_CART: 'cart'
103      },
104      entry: () => console.log('Entered checkout state')
105    },
106    payment: {
107      on: {
108        SUCCESS: 'confirmed',
109        FAILURE: 'payment_failed',
110        BACK_TO_CHECKOUT: 'checkout'
111      },
112      entry: () => console.log('Processing payment...')
113    },
114    payment_failed: {
115      on: {
116        RETRY: 'payment',
117        BACK_TO_CHECKOUT: 'checkout'
118      },
119      entry: () => console.log('Payment failed!')
120    },
121    confirmed: {
122      on: {
123        FULFILL: 'shipped'
124      },
125      entry: () => console.log('Order confirmed!')
126    },
127    shipped: {
128      on: {
129        DELIVER: 'delivered'
130      },
131      entry: () => console.log('Order shipped!')
132    },
133    delivered: {
134      on: {},
135      entry: () => console.log('Order delivered!')
136    }
137  }
138});
139
140// Abonnement aux changements d'état
141orderMachine.subscribe((state) => {
142  console.log(`Current state: ${state}`);
143});
144
145// Simulation du parcours d'une commande
146console.log('=== Order Flow Simulation ===');
147orderMachine.send('CHECKOUT');
148orderMachine.send('PAYMENT');
149orderMachine.send('SUCCESS');
150orderMachine.send('FULFILL');
151orderMachine.send('DELIVER');

Un événement non autorisé dans l'état courant, par exemple DELIVER à l'étape du panier, est ignoré avec un avertissement, donc une transition invalide est impossible.

Intégration avec XState (conceptuelle)

XState est une bibliothèque de machines à états très utilisée. Elle ajoute un contexte (des données à côté de l'état), des gardes (guards) qui bloquent les transitions et l'appel de services, comme un paiement :

1// Exemple de ce à quoi ressemblerait une machine à états avec XState
2// (code conceptuel - nécessite l'installation de XState)
3
4/*
5import { createMachine, interpret } from 'xstate';
6
7const orderMachineXState = createMachine({
8  id: 'order',
9  initial: 'cart',
10  context: {
11    items: [],
12    total: 0,
13    customer: null
14  },
15  states: {
16    cart: {
17      on: {
18        ADD_ITEM: {
19          actions: 'addItem'
20        },
21        REMOVE_ITEM: {
22          actions: 'removeItem'
23        },
24        CHECKOUT: {
25          target: 'checkout',
26          cond: 'hasItems'
27        }
28      }
29    },
30    checkout: {
31      entry: 'calculateTotal',
32      on: {
33        PAYMENT: 'payment',
34        BACK_TO_CART: 'cart'
35      }
36    },
37    payment: {
38      invoke: {
39        id: 'paymentService',
40        src: 'processPayment',
41        onDone: {
42          target: 'confirmed',
43          actions: 'savePaymentInfo'
44        },
45        onError: {
46          target: 'payment_failed',
47          actions: 'savePaymentError'
48        }
49      },
50      on: {
51        BACK_TO_CHECKOUT: 'checkout'
52      }
53    },
54    payment_failed: {
55      on: {
56        RETRY: 'payment',
57        BACK_TO_CHECKOUT: 'checkout'
58      }
59    },
60    confirmed: {
61      entry: 'sendConfirmationEmail',
62      on: {
63        FULFILL: 'shipped'
64      }
65    },
66    shipped: {
67      entry: 'sendShippingNotification',
68      on: {
69        DELIVER: 'delivered'
70      }
71    },
72    delivered: {
73      entry: 'sendDeliveryConfirmation',
74      type: 'final'
75    }
76  }
77}, {
78  actions: {
79    addItem: (context, event) => {
80      // Ajouter un article au panier
81    },
82    removeItem: (context, event) => {
83      // Retirer un article du panier
84    },
85    calculateTotal: (context) => {
86      // Calculer le total
87    },
88    savePaymentInfo: (context, event) => {
89      // Enregistrer les informations de paiement
90    }
91  },
92  guards: {
93    hasItems: (context) => context.items.length > 0
94  },
95  services: {
96    processPayment: (context) => {
97      // Renvoie une Promise avec le processus de paiement
98      return fetch('/api/payment', {
99        method: 'POST',
100        body: JSON.stringify(context)
101      });
102    }
103  }
104});
105*/

Le code est en commentaire, car il nécessite l'installation de la bibliothèque, et il utilise l'API de XState 4. Dans la version 5, la fonction interpret a été remplacée par createActor, cond s'appelle guard, services est devenu actors, les actions et les gardes reçoivent un seul objet ({ context, event }), et un service qui renvoie une Promise doit être enveloppé dans fromPromise.

Machines à états avancées avec hiérarchie

Une machine peut aussi stocker un contexte, comme un lecteur de musique avec son volume et sa playlist :

1// Machine à états hiérarchique pour un lecteur de musique
2interface PlayerContext {
3  currentTrack: string | null;
4  volume: number;
5  position: number;
6  playlist: string[];
7}
8
9class HierarchicalStateMachine extends StateMachine {
10  private context: any = {};
11
12  constructor(config: MachineConfig, initialContext: any = {}) {
13    super(config);
14    this.context = { ...initialContext };
15  }
16
17  getContext(): any {
18    return { ...this.context };
19  }
20
21  updateContext(updates: any): void {
22    this.context = { ...this.context, ...updates };
23  }
24}
25
26const musicPlayerMachine = new HierarchicalStateMachine({
27  id: 'musicPlayer',
28  initial: 'stopped',
29  states: {
30    stopped: {
31      on: {
32        PLAY: 'playing',
33        LOAD: 'loading'
34      },
35      entry: () => console.log('Player stopped')
36    },
37    loading: {
38      on: {
39        LOADED: 'playing',
40        ERROR: 'error'
41      },
42      entry: () => console.log('Loading track...')
43    },
44    playing: {
45      on: {
46        PAUSE: 'paused',
47        STOP: 'stopped',
48        NEXT: 'loading',
49        PREVIOUS: 'loading'
50      },
51      entry: () => console.log('Playing music'),
52      exit: () => console.log('Stopped playing')
53    },
54    paused: {
55      on: {
56        PLAY: 'playing',
57        STOP: 'stopped'
58      },
59      entry: () => console.log('Music paused')
60    },
61    error: {
62      on: {
63        RETRY: 'loading',
64        STOP: 'stopped'
65      },
66      entry: () => console.log('Error loading track')
67    }
68  }
69}, {
70  currentTrack: null,
71  volume: 50,
72  position: 0,
73  playlist: ['track1.mp3', 'track2.mp3']
74});
75
76// Simulation du lecteur
77console.log('=== Music Player Simulation ===');
78musicPlayerMachine.send('LOAD');
79musicPlayerMachine.send('LOADED');
80musicPlayerMachine.send('PAUSE');
81musicPlayerMachine.send('PLAY');
82musicPlayerMachine.send('NEXT');

Le nom de la classe est exagéré : il s'agit toujours d'une machine plate enrichie d'un contexte. Une vraie hiérarchie implique des états imbriqués, par exemple playing avec les sous-états normal et shuffle, ce que XState gère nativement.

Comparaison des patterns de gestion d'état

Quand utiliser quel pattern ?

Dans une grande application, les patterns coopèrent souvent, et chacun s'occupe d'un type d'état différent :

1// Exemple d'application qui utilise les trois patterns
2
3// 1. Flux - pour une gestion simple de l'état de l'UI
4class UIStateManager {
5  // Gestion de l'état des composants UI, des formulaires, des modales
6}
7
8// 2. Event Sourcing - pour la logique métier avec historique
9class OrderEventStore {
10  // Gestion des commandes avec l'historique complet des changements
11}
12
13// 3. State Machines - pour les flux complexes
14class WorkflowStateMachine extends StateMachine {
15  // Gestion des processus métier (circuits de validation, etc.)
16}
17
18// Combinaison des patterns dans une seule application
19class ApplicationStateManager {
20  private uiState: UIStateManager;
21  private orderEvents: OrderEventStore;
22  private workflows: Map<string, WorkflowStateMachine>;
23
24  constructor() {
25    this.uiState = new UIStateManager();
26    this.orderEvents = new OrderEventStore();
27    this.workflows = new Map();
28  }
29
30  // Flux pour l'UI
31  updateUI(action: Action): void {
32    // this.uiState.dispatch(action);
33  }
34
35  // Event Sourcing pour les événements métier
36  processBusinessEvent(event: Event): void {
37    // this.orderEvents.append(event);
38  }
39
40  // State Machines pour les workflows
41  advanceWorkflow(workflowId: string, event: string): void {
42    const workflow = this.workflows.get(workflowId);
43    if (workflow) {
44      workflow.send(event);
45    }
46  }
47}

Flux gère l'état de l'interface, l'Event Sourcing les données métier avec leur historique, et les machines à états les flux de travail, comme la validation d'une commande.

Bonnes pratiques

Trois règles mettent de l'ordre dans le travail : sépare les types d'état, teste chaque pattern séparément et soigne les performances :

1// 1. Separation of Concerns - séparation des responsabilités
2interface StatePattern {
3  // UI State - Flux/Redux
4  uiState: {
5    loading: boolean;
6    errors: string[];
7    notifications: any[];
8  };
9
10  // Business State - Event Sourcing
11  businessEvents: Event[];
12
13  // Workflow State - State Machines
14  workflows: Record<string, string>; // workflowId -> currentState
15}
16
17// 2. Tester les patterns
18class StatePatternTests {
19  testFluxActions(): void {
20    // Tester les action creators, les reducers et les stores
21  }
22
23  testEventSourcing(): void {
24    // Tester l'application des events et les projecteurs
25  }
26
27  testStateMachines(): void {
28    // Tester les transitions d'état, les gardes et les actions
29  }
30}
31
32// 3. Performances
33class PerformanceOptimizations {
34  // Mémoïsation des sélecteurs Flux
35  memoizedSelectors = new Map();
36
37  // Snapshotting pour l'Event Sourcing
38  snapshots = new Map();
39
40  // Cache des instances de machines à états
41  machineInstances = new Map();
42}

La mémoïsation des sélecteurs économise des calculs dans Flux, et les snapshots de l'Event Sourcing permettent de reconstituer l'état à partir du dernier instantané plutôt que du tout premier event.

Récapitulatif des patterns de gestion d'état

Flux offre un flux unidirectionnel simple, un débogage facile et le suivi des changements, il convient donc à l'état de l'UI, même s'il peut être surdimensionné pour une application simple. L'Event Sourcing offre un historique complet, la reconstitution de l'état à n'importe quel moment et un audit naturel, au prix d'une plus grande complexité et d'espace disque. Les machines à états modélisent clairement les flux complexes, excluent les états invalides et se visualisent et se testent facilement, même si elles demandent un apprentissage et peuvent être excessives pour des états simples.

Ma recommandation : dans une petite application, un état de base suffit (useState, signals), dans une application moyenne, Flux/Redux pour l'UI, dans une grande, une combinaison de patterns, et en entreprise, l'Event Sourcing et les machines à états pour les processus critiques. Dans l'éditeur ci-dessous, tu construiras le système de sécurité du parc, et dans la leçon suivante, nous nous attaquerons au code splitting et au tree shaking.

Retiens : l'état du parc, c'est sa mémoire, alors ne le modifie qu'en passant par une seule porte bien gardée.

Code de cette leçon : index.js
1// Système de sécurité du parc
2// Exercice : implémente un système de surveillance des capteurs
3
4class SecuritySystem {
5  constructor() {
6    this.sensors = new Map();
7    this.alerts = [];
8    this.securityLevel = "GREEN";
9  }
10
11  // Implémente les méthodes :
12  // addSensor(zone, type) - ajoute un capteur à une zone
13  // triggerAlert(zone, severity) - déclenche une alerte
14  // getStatus() - retourne l'état de la sécurité
15
16  addSensor(zone, type) {
17    // Ton code ici
18  }
19
20  triggerAlert(zone, severity) {
21    // Ton code ici
22  }
23
24  getStatus() {
25    // Ton code ici
26  }
27}
28
29const security = new SecuritySystem();
30security.addSensor("Secteur A", "motion");
31console.log("Système de sécurité actif");

Tu as repéré une erreur dans cette leçon ?

Teste tes connaissances

Réponds aux questions de cette leçon. Choisis une réponse pour voir tout de suite si elle est correcte.

  1. 1. Que signifie le principe de responsabilité unique (Single Responsibility Principle, SRP) ?

  2. 2. Que signifie le principe ouvert/fermé (Open/Closed) ?

Tâches pratiques dans le jeu

  • Éditeur de code

    Le fichier index.js contient une classe VisitorManagementSystem qui garde les visiteurs dans une Map sous leur numéro de billet. Complète les blancs : ___BLANK1___ est la méthode de Map qui retourne le visiteur pour un numéro de billet, ___BLANK2___ est la méthode de tableau qui transforme la liste des visiteurs en un seul objet avec le nombre de personnes dans chaque zone (elle part d'un objet vide {}), et ___BLANK3___ est la propriété de Map qui donne le nombre de visiteurs enregistrés. trackVisitorLocation doit retourner false pour un billet inconnu, et getStats() doit retourner { total, byZone }, par ex. { total: 2, byZone: { entrance: 1, raptors: 1 } }.

  • Tri vertical

    Range les étapes d'implémentation du pattern Singleton :

  • Éditeur de code

    Le fichier index.js contient une classe GeneticLaboratory. Complète les blancs : ___BLANK1___ est la méthode d'expression régulière qui vérifie si une chaîne correspond au motif (la séquence ne contient que les lettres A, C, G et T) et retourne true ou false, ___BLANK2___ est le champ du résultat d'analyzeDNA qui indique si l'ADN est valide, et ___BLANK3___ est une expression qui retourne un nouveau tableau avec toutes les espèces enregistrées dans la Map this.species. analyzeDNA doit retourner { valid, length, gcContent }, synthesize ne doit pas créer d'espèce à partir d'un ADN invalide (elle retourne alors null), et getSpeciesDatabase retourne un tableau d'espèces.

Articles utiles