JavaScript and TypeScript course Β· Module 9: Design Patterns and Architecture
State Management Patterns in JavaScript/TypeScript
In this lesson5
In the park's control room the state changes every second: open gates, fed dinosaurs, alarms. When every module changes this data in its own way, after a failure nobody knows who opened the Velociraptor paddock gate, or when. State management patterns help here, and you will learn three of them: Flux, Event Sourcing and State Machines.
Flux Architecture - Unidirectional data flow
Flux is an application architecture pattern that introduces a unidirectional data flow, which makes the application state more predictable and easier to debug. It was proposed by Facebook: an action goes to the dispatcher, the dispatcher passes it to the stores, and the view refreshes after the change.
Basic Flux components
An action (Action) is an object with a type field and an optional payload, the dispatcher distributes actions, the store holds the state and notifies subscribers, and action creators are functions that create actions:
1// Types for Flux
2type Action<T = any> = {
3 type: string;
4 payload?: T;
5};
6
7type Listener = () => void;
8
9// Store - holds application state
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 // Return unsubscribe function
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 - central hub for all 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 - functions that create 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// Application state
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// TodoStore implementation
99class TodoStore extends Store<TodoState> {
100 constructor(dispatcher: Dispatcher) {
101 super({
102 todos: [],
103 filter: 'all'
104 });
105
106 // Register with 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 // No state changes for unknown actions
165 break;
166 }
167 }
168
169 // Selectors - functions for retrieving processed data
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// Using Flux
194const dispatcher = new Dispatcher();
195const todoStore = new TodoStore(dispatcher);
196
197// Subscribing to changes
198const unsubscribe = todoStore.subscribe(() => {
199 console.log('State changed:', {
200 todos: todoStore.getFilteredTodos(),
201 stats: todoStore.getStats(),
202 filter: todoStore.getState().filter
203 });
204});
205
206// Executing actions
207dispatcher.dispatch(TodoActions.addTodo('Learn Flux'));
208dispatcher.dispatch(TodoActions.addTodo('Implement TodoApp'));
209dispatcher.dispatch(TodoActions.toggleTodo(todoStore.getState().todos[0].id));
210dispatcher.dispatch(TodoActions.setFilter('active'));setState is protected, so a new state can only be set by an action that went through the dispatcher. getState still returns a reference to the state object, though, through which the state can be changed without going through the dispatcher. That is why it is worth freezing the new state before the store notifies its subscribers: Object.freeze is shallow, so nested arrays such as todos get frozen too. Every element has a single responsibility (SRP), and a store usually exists as a single instance, like a Singleton. In Redux, the logic from handleAction moves into a reducer: a pure function that creates a new state from the current state and an action.
Advanced Flux with Middleware
Middleware is a layer that every action passes through before it reaches the store, like an airlock in front of a paddock. Here it adds logging, debugging and handling of asynchronous actions:
1// Middleware for 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// Logging middleware
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// Debug middleware
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// Async action handling middleware
53const asyncMiddleware: Middleware = (action, next) => {
54 if (typeof action.payload === 'function') {
55 // This is an async action
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 // API call simulation
70 setTimeout(() => {
71 dispatch({
72 type: 'LOAD_TODOS_SUCCESS',
73 payload: {
74 todos: [
75 { id: '1', text: 'Todo from API', completed: false, createdAt: new Date() }
76 ]
77 }
78 });
79 }, 1000);
80 }
81 })
82};
83
84// Using Enhanced Dispatcher
85const enhancedDispatcher = new EnhancedDispatcher();
86enhancedDispatcher.use(loggingMiddleware);
87enhancedDispatcher.use(debugMiddleware);
88enhancedDispatcher.use(asyncMiddleware);
89
90const enhancedTodoStore = new TodoStore(enhancedDispatcher);Each middleware decides whether to call next, so it can stop, delay or enrich an action. You add new behavior with use, without editing the class, in line with the open/closed principle.
Event Sourcing - Event history as source of truth
Event Sourcing is a pattern in which the application state is determined by a sequence of events, instead of storing the current state directly. It is like a ranger's logbook: you do not overwrite the board with the paddock status, you record every event and rebuild the state by reading the log from the beginning.
Basic Event Sourcing implementation
An aggregate (Aggregate) is an object whose state we build from events, here User. Methods such as changeEmail do not change the state, they only return new events, and the change itself is made by apply:
1// Types for 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 - stores all 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 // Adding new 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// Event definitions
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 // Methods for creating 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 []; // No changes
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 []; // Already inactive
111 }
112
113 return [UserEvents.UserDeactivated({ reason })];
114 }
115
116 reactivate(): any[] {
117 if (this.isActive) {
118 return []; // Already active
119 }
120
121 return [UserEvents.UserReactivated()];
122 }
123
124 // Applying events to change state
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 // Reconstructing state from 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 for User Aggregate
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 // Will be set in EventStore
176 }));
177
178 await this.eventStore.saveEvents(user.id, events, user.version);
179
180 // Applying the saved events (with versions assigned by the EventStore) to the aggregate
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// Using Event Sourcing
197async function demonstrateEventSourcing() {
198 const eventStore = new EventStore();
199 const userRepository = new UserRepository(eventStore);
200
201 // Creating a user
202 const { user, events } = User.create('user1', 'Jan Kowalski', 'jan@example.com');
203 await userRepository.save(user, events);
204
205 console.log('User after creation:', user);
206
207 // Changing email
208 const emailChangeEvents = user.changeEmail('jan.kowalski@example.com');
209 await userRepository.save(user, emailChangeEvents);
210
211 console.log('User after email change:', user);
212
213 // Deactivation
214 const deactivateEvents = user.deactivate('User requested account deletion');
215 await userRepository.save(user, deactivateEvents);
216
217 console.log('User after deactivation:', user);
218
219 // Reactivation
220 const reactivateEvents = user.reactivate();
221 await userRepository.save(user, reactivateEvents);
222
223 console.log('User after reactivation:', user);
224
225 // Reconstructing state from events
226 const reconstructedUser = await userRepository.getById('user1');
227 console.log('Reconstructed user:', reconstructedUser);
228
229 // History of all events
230 const allEvents = await eventStore.getAllEvents();
231 console.log('Event history:', allEvents);
232}
233
234demonstrateEventSourcing();EventStore guards the versions: if someone added an event in the meantime, the save ends with a conflict (optimistic concurrency control). That is why the repository applies the events with the versions assigned by the store, and fromHistory rebuilds the user from the history alone.
Projectors and Read Models
Reading the whole history for every question would be slow. A projector listens to events and builds a model that is ready to read (read model) from them, for example statistics:
1// Projector - creates read models from events
2abstract class Projector {
3 abstract getProjectionName(): string;
4 abstract handle(event: Event): Promise<void>;
5}
6
7// Read Model for user statistics
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 for projectors
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 with 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 // Publishing events to projectors
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// Usage with projectors
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 // Performing operations - projectors will be automatically updated
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('Final statistics:', userStatsProjector.getStats());
113}
114
115demonstrateProjectors();A projector does not change the history, it only summarizes it, so you can add a new one at any moment and feed it the existing events.
State Machines with XState
State machines let you model complex state flows in a declarative and predictable way. A machine is always in exactly one state, and a transition happens only after an event that is allowed in that state, like a gate you cannot open during feeding.
Basic state machine
First, our own machine. Each state has an on map in which an event points to the next state, plus optional entry and exit hooks that run on entering and leaving:
1// Simple state machine implementation (without 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 // Exit current state
49 this.executeExit();
50
51 // Change state
52 const previousState = this.currentState;
53 this.currentState = nextState;
54
55 // Entry new state
56 this.executeEntry();
57
58 // Notify listeners
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// Example: State machine for order process
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// Subscribe to state changes
141orderMachine.subscribe((state) => {
142 console.log(`Current state: ${state}`);
143});
144
145// Simulate order flow
146console.log('=== Order Flow Simulation ===');
147orderMachine.send('CHECKOUT');
148orderMachine.send('PAYMENT');
149orderMachine.send('SUCCESS');
150orderMachine.send('FULFILL');
151orderMachine.send('DELIVER');An event that is not allowed in the current state, for example DELIVER in the cart, is ignored with a warning, so an invalid transition is impossible.
XState integration (conceptual)
XState is a popular state machine library. It adds context (data kept next to the state), guards that block transitions, and invoking services such as a payment:
1// Example of what a state machine would look like with XState
2// (conceptual code - requires XState installation)
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 // Add item to cart
81 },
82 removeItem: (context, event) => {
83 // Remove item from cart
84 },
85 calculateTotal: (context) => {
86 // Calculate total
87 },
88 savePaymentInfo: (context, event) => {
89 // Save payment info
90 }
91 },
92 guards: {
93 hasItems: (context) => context.items.length > 0
94 },
95 services: {
96 processPayment: (context) => {
97 // Returns Promise with payment process
98 return fetch('/api/payment', {
99 method: 'POST',
100 body: JSON.stringify(context)
101 });
102 }
103 }
104});
105*/The code is commented out because it requires installing the library, and it uses the XState 4 API. In version 5, interpret was replaced by createActor, cond is called guard, services became actors, actions and guards receive a single object ({ context, event }), and a service that returns a Promise has to be wrapped in fromPromise.
Advanced state machines with hierarchy
A machine can also hold context, like this music player with a volume and a playlist:
1// Hierarchical state machine for a music player
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// Player simulation
77console.log('=== Music Player Simulation ===');
78musicPlayerMachine.send('LOAD');
79musicPlayerMachine.send('LOADED');
80musicPlayerMachine.send('PAUSE');
81musicPlayerMachine.send('PLAY');
82musicPlayerMachine.send('NEXT');The class name overpromises: it is still a flat machine extended with context. Real hierarchy means nested states, for example playing with the substates normal and shuffle, which XState supports natively.
Comparing state management patterns
When to use which pattern?
In a large application the patterns often work together, each responsible for a different kind of state:
1// Example application using all three patterns
2
3// 1. Flux - for simple UI state management
4class UIStateManager {
5 // Managing UI component state, forms, modals
6}
7
8// 2. Event Sourcing - for business logic with history
9class OrderEventStore {
10 // Managing orders with full change history
11}
12
13// 3. State Machines - for complex flows
14class WorkflowStateMachine extends StateMachine {
15 // Managing business workflows (approval workflows, etc.)
16}
17
18// Combining patterns in one 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 for UI
31 updateUI(action: Action): void {
32 // this.uiState.dispatch(action);
33 }
34
35 // Event Sourcing for business events
36 processBusinessEvent(event: Event): void {
37 // this.orderEvents.append(event);
38 }
39
40 // State Machines for 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 handles the interface state, Event Sourcing the business data with its history, and state machines the flows, such as approving an order.
Best practices
Three rules keep the work organized: separate the kinds of state, test each pattern separately and take care of performance:
1// 1. Separation of Concerns
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. Testing patterns
18class StatePatternTests {
19 testFluxActions(): void {
20 // Test action creators, reducers, stores
21 }
22
23 testEventSourcing(): void {
24 // Test event application, projectors
25 }
26
27 testStateMachines(): void {
28 // Test state transitions, guards, actions
29 }
30}
31
32// 3. Performance considerations
33class PerformanceOptimizations {
34 // Memoization for Flux selectors
35 memoizedSelectors = new Map();
36
37 // Snapshotting for Event Sourcing
38 snapshots = new Map();
39
40 // State Machine caching
41 machineInstances = new Map();
42}Memoizing selectors saves computation in Flux, and snapshots in Event Sourcing let you rebuild the state from the latest snapshot instead of from the very first event.
Summary of state management patterns
Flux gives you a simple unidirectional flow, easy debugging and change tracking, so it fits UI state, although in simple applications it can be over-engineering. Event Sourcing gives you the full history, rebuilding the state from any moment and natural auditing, at the cost of complexity and disk space. State machines clearly model complex flows, rule out invalid states and are easy to visualize and test, although they have a learning curve and can be overkill for simple states.
My recommendation: in a small application basic state (useState, signals) is enough, in a medium one use Flux/Redux for the UI, in a large one a combination of patterns, and in enterprise systems Event Sourcing and state machines for critical processes. In the editor below you will build the park's security system, and in the next lesson we will take on code splitting and tree shaking.
Remember: the park's state is its memory, so change it only through one well-guarded gate.
Code for this lesson: index.js
1// Park Security System
2// Exercise: Implement a sensor monitoring system
3
4class SecuritySystem {
5 constructor() {
6 this.sensors = new Map();
7 this.alerts = [];
8 this.securityLevel = "GREEN";
9 }
10
11 // Implement the methods:
12 // addSensor(zone, type) - add a sensor to a zone
13 // triggerAlert(zone, severity) - trigger an alert
14 // getStatus() - return the security status
15
16 addSensor(zone, type) {
17 // Your code here
18 }
19
20 triggerAlert(zone, severity) {
21 // Your code here
22 }
23
24 getStatus() {
25 // Your code here
26 }
27}
28
29const security = new SecuritySystem();
30security.addSensor("Sector A", "motion");
31console.log("Security system active");Spotted a mistake in this lesson?
Check yourself
Answer the questions from this lesson. Pick an answer to see right away whether it is correct.
1. What does the Single Responsibility Principle (SRP) mean?
2. What does the Open/Closed Principle mean?
Hands-on tasks in the game
- Code editor
The index.js file has a VisitorManagementSystem class that keeps visitors in a Map under their ticket number. Fill in the blanks: ___BLANK1___ is the Map method that returns the visitor for a ticket number, ___BLANK2___ is the array method that turns the list of visitors into one object with the number of people in each zone (it starts from an empty object {}), and ___BLANK3___ is the Map property with the number of stored visitors. trackVisitorLocation should return false for an unknown ticket, and getStats() should return { total, byZone }, e.g. { total: 2, byZone: { entrance: 1, raptors: 1 } }.
- Vertical ordering
Arrange the stages of Singleton pattern implementation:
- Code editor
The index.js file has a GeneticLaboratory class. Fill in the blanks: ___BLANK1___ is the regular expression method that checks whether a string matches the pattern (the sequence contains only the letters A, C, G and T) and returns true or false, ___BLANK2___ is the field of the analyzeDNA result that says whether the DNA is valid, and ___BLANK3___ is an expression that returns a new array with all species stored in the Map this.species. analyzeDNA should return { valid, length, gcContent }, synthesize must not create a species from invalid DNA (it returns null then), and getSpeciesDatabase returns an array of species.