JavaScript and TypeScript course Β· Module 9: Design Patterns and Architecture

State Management Patterns in JavaScript/TypeScript

19 min read
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. 1. What does the Single Responsibility Principle (SRP) mean?

  2. 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.

Useful articles