Cours JavaScript et TypeScript · Module 9: Design patterns et architecture
Les patterns de gestion d'état en JavaScript/TypeScript
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. Que signifie le principe de responsabilité unique (Single Responsibility Principle, SRP) ?
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.