Skip to content

Behavioral Patterns in the Age of Reactive Programming

Exploring how Observer, Strategy, Command, State, and Mediator patterns have evolved with RxJS, Redux, XState, and modern reactive programming paradigms in TypeScript.

Abstract

Behavioral patterns define how objects communicate and distribute responsibilities. The Gang of Four documented Observer, Strategy, Command, State, and Mediator patterns for C++ and Smalltalk - languages where implementing these patterns required significant boilerplate. Modern TypeScript with RxJS, Redux, and React hooks has fundamentally changed how we implement these patterns. Some have been absorbed into framework conventions, others have evolved into reactive paradigms, and a few remain surprisingly relevant in their classic form.

This post examines how behavioral patterns manifest in modern JavaScript/TypeScript applications. We'll explore the evolution from classic Observer to RxJS Observables, how Strategy pattern simplifies with first-class functions, why Redux actions are Command pattern in disguise, how XState makes State pattern practical, and when Mediator pattern prevents tight coupling. The goal: understand when these patterns add value versus when they're unnecessary complexity.

Observer Pattern: From Callbacks to Reactive Streams

The Observer pattern enables one-to-many dependency relationships where changes in one object trigger updates in dependent objects. It's perhaps the most influential pattern in modern web development, though you might not recognize it.

Classic Implementation

The textbook Observer pattern requires explicit subject-observer relationships:

typescript
interface Observer {  update(data: any): void;}
class Subject {  private observers: Observer[] = [];
  subscribe(observer: Observer): void {    this.observers.push(observer);  }
  unsubscribe(observer: Observer): void {    const index = this.observers.indexOf(observer);    if (index !== -1) {      this.observers.splice(index, 1);    }  }
  notify(data: any): void {    this.observers.forEach(o => o.update(data));  }}
// Usageclass ConcreteObserver implements Observer {  update(data: any): void {    console.log('Received update:', data);  }}
const subject = new Subject();const observer = new ConcreteObserver();subject.subscribe(observer);subject.notify({ value: 42 });

This works, but it's verbose and lacks features modern applications need: backpressure handling, error propagation, completion signals, operator composition.

Node.js EventEmitter Evolution

Node.js introduced EventEmitter, a more flexible observer implementation:

typescript
import { EventEmitter } from 'events';
class DataStream extends EventEmitter {  start(): void {    setInterval(() => {      this.emit('data', { timestamp: Date.now() });    }, 1000);  }}
const stream = new DataStream();
stream.on('data', (data) => {  console.log('Received:', data);});
stream.on('error', (error) => {  console.error('Error:', error);});
stream.start();

EventEmitter improved on classic Observer with:

  • Named events instead of single notification method
  • Multiple listeners per event
  • Error event support
  • Familiar .on() and .emit() API

But it still lacks capabilities for modern async scenarios: cancellation, operators like debounce or map, automatic cleanup.

RxJS: Observer Pattern Evolved

RxJS (Reactive Extensions for JavaScript) represents the full evolution of Observer pattern into reactive programming:

typescript
import { Observable, Subject, of } from 'rxjs';import {  debounceTime,  distinctUntilChanged,  map,  filter,  catchError} from 'rxjs';
// Search input with reactive operatorsconst searchInput$ = new Subject<string>();
searchInput$.pipe(  debounceTime(300),  // Wait 300ms after typing stops  distinctUntilChanged(),  // Only if value changed  map(term => term.toLowerCase()),  filter(term => term.length >= 3), // Min 3 characters  catchError(error => {    console.error('Search error:', error);    return of([]); // Must return Observable  })).subscribe(term => {  console.log('Searching for:', term);  // Fetch search results});
// Emit valuessearchInput$.next('rea');searchInput$.next('react');searchInput$.next('reactive');

What makes RxJS powerful:

  1. Composable operators: Chain transformations declaratively
  2. Error handling: Errors propagate through the stream
  3. Completion signals: Know when stream finishes
  4. Backpressure: Handle fast producers with operators like throttle
  5. Cancellation: Unsubscribe stops execution
  6. Hot vs Cold: Control when execution starts

Real-World Scenario: WebSocket Dashboard

A real-time stock price dashboard demonstrates RxJS's Observer evolution:

typescript
import { Observable, merge, interval, Subject } from 'rxjs';import {  filter,  map,  scan,  share,  retry,  takeUntil} from 'rxjs';
interface StockPrice {  symbol: string;  value: number;  timestamp: number;}
// WebSocket as Observablefunction createStockStream(url: string): Observable<StockPrice> {  return new Observable<StockPrice>(subscriber => {    const ws = new WebSocket(url);
    ws.onmessage = (event) => {      try {        const data = JSON.parse(event.data);        subscriber.next(data);      } catch (error) {        subscriber.error(error);      }    };
    ws.onerror = (error) => {      subscriber.error(error);    };
    ws.onclose = () => {      subscriber.complete();    };
    // Cleanup function    return () => {      console.log('Closing WebSocket connection');      ws.close();    };  });}
const stockPrices$ = createStockStream('wss://stocks.example.com').pipe(  retry(3), // Retry on connection failure  share()  // Share connection among subscribers);
// Multiple observers with different processing// Observer 1: Update AAPL chartstockPrices$.pipe(  filter(price => price.symbol === 'AAPL'),  map(price => price.value)).subscribe(value => {  updateChart('AAPL', value);});
// Observer 2: Calculate portfolio totalstockPrices$.pipe(  scan((acc, price) => {    acc[price.symbol] = price.value;    return acc;  }, {} as Record<string, number>),  map(prices => calculatePortfolioValue(prices))).subscribe(total => {  updatePortfolioDisplay(total);});
// Observer 3: Alert on significant changesstockPrices$.pipe(  filter(price => Math.abs(price.value - getPreviousPrice(price.symbol)) > 10),  map(price => ({    symbol: price.symbol,    change: price.value - getPreviousPrice(price.symbol)  }))).subscribe(alert => {  showNotification(`${alert.symbol} moved ${alert.change}`);});
// Cleanup after component unmountsconst unsubscribe$ = new Subject<void>();stockPrices$.pipe(  takeUntil(unsubscribe$)).subscribe();
// Later: cleanup all subscriptionsfunction cleanup() {  unsubscribe$.next();  unsubscribe$.complete();}

Key improvements over classic Observer:

  • Automatic cleanup: Unsubscribing closes WebSocket
  • Error handling: Connection failures trigger retry logic
  • Shared connection: Multiple subscribers use single WebSocket
  • Declarative filters: Each observer processes only relevant data
  • Coordinated cleanup: takeUntil ensures no memory leaks

Redux: Observer Pattern for State

Redux implements Observer pattern for application state management:

typescript
import { configureStore, createSlice, PayloadAction } from '@reduxjs/toolkit';
interface User {  id: string;  name: string;}
interface AppState {  count: number;  user: User | null;}
// Modern Redux Toolkit approach (createStore is deprecated)const appSlice = createSlice({  name: 'app',  initialState: { count: 0, user: null } as AppState,  reducers: {    increment: (state) => {      state.count += 1;    },    decrement: (state) => {      state.count -= 1;    },    setUser: (state, action: PayloadAction<User>) => {      state.user = action.payload;    }  }});
const { increment, decrement, setUser } = appSlice.actions;
const store = configureStore({  reducer: appSlice.reducer});
// Subscribe (observe) state changesconst unsubscribe = store.subscribe(() => {  const state = store.getState();  console.log('State changed:', state);  // Update UI based on new state});
// Dispatch actionsstore.dispatch(increment());store.dispatch(setUser({ id: '1', name: 'John' }));
// Later: cleanupunsubscribe();

React-Redux connects components as observers:

Note: Modern React-Redux prefers hooks (useSelector/useDispatch) over the connect HOC. The example below shows the older pattern for educational purposes.

typescript
import { connect } from 'react-redux';
interface CounterProps {  count: number;  increment: () => void;  decrement: () => void;}
function Counter({ count, increment, decrement }: CounterProps) {  return (    <div>      <button onClick={decrement}>-</button>      <span>{count}</span>      <button onClick={increment}>+</button>    </div>  );}
// Component observes store, re-renders on changesexport default connect(  (state: AppState) => ({    count: state.count  }),  (dispatch) => ({    increment: () => dispatch({ type: 'INCREMENT' }),    decrement: () => dispatch({ type: 'DECREMENT' })  }))(Counter);

Modern Alternative: Zustand

Zustand simplifies Observer pattern with hooks and minimal boilerplate:

typescript
import { create } from 'zustand';
interface StoreState {  count: number;  user: User | null;  increment: () => void;  decrement: () => void;  setUser: (user: User) => void;}
// Create store (subject)const useStore = create<StoreState>((set) => ({  count: 0,  user: null,  increment: () => set((state) => ({ count: state.count + 1 })),  decrement: () => set((state) => ({ count: state.count - 1 })),  setUser: (user) => set({ user })}));
// Component automatically observes relevant statefunction Counter() {  // Only re-renders when count changes  const count = useStore(state => state.count);  const increment = useStore(state => state.increment);  const decrement = useStore(state => state.decrement);
  return (    <div>      <button onClick={decrement}>-</button>      <span>{count}</span>      <button onClick={increment}>+</button>    </div>  );}

Zustand's Observer benefits:

  • Automatic subscriptions: Components subscribe via hooks
  • Granular updates: Only re-render when selected state changes
  • No boilerplate: No actions, reducers, or connect HOCs
  • TypeScript friendly: Full type inference
  • Middleware support: DevTools, persist, immer integration

When to Use Each Observer Variant

Use RxJS when:

  • Complex async coordination (combine multiple streams)
  • Need advanced operators (debounce, throttle, retry)
  • WebSocket or SSE connections
  • Event stream processing
  • Backpressure handling critical

Use Redux when:

  • Large application with complex state
  • Need time-travel debugging
  • Many components access same state
  • Middleware ecosystem valuable (sagas, thunks)
  • Team familiar with Redux patterns

Use Zustand when:

  • Medium-sized application
  • Want simplicity over features
  • Don't need Redux DevTools
  • Prefer hooks over connect HOC
  • TypeScript support priority

Use plain EventEmitter when:

  • Node.js backend services
  • Simple pub/sub within module
  • No need for operators or backpressure
  • Want minimal dependencies

Strategy Pattern: Composition Over Classes

Strategy pattern enables selecting algorithms at runtime. In languages with first-class functions, Strategy pattern often doesn't need classes - functions work better.

Classic Strategy Pattern

The textbook approach uses interfaces and concrete implementations:

typescript
interface PaymentStrategy {  pay(amount: number): Promise<void>;}
class CreditCardStrategy implements PaymentStrategy {  constructor(    private cardNumber: string,    private cvv: string  ) {}
  async pay(amount: number): Promise<void> {    console.log(`Charging $${amount} to card ${this.cardNumber}`);    // Credit card API call    await this.processCreditCard(amount);  }
  private async processCreditCard(amount: number): Promise<void> {    // Implementation details  }}
class PayPalStrategy implements PaymentStrategy {  constructor(private email: string) {}
  async pay(amount: number): Promise<void> {    console.log(`Charging $${amount} via PayPal to ${this.email}`);    // PayPal API call    await this.processPayPal(amount);  }
  private async processPayPal(amount: number): Promise<void> {    // Implementation details  }}
class CryptoStrategy implements PaymentStrategy {  constructor(private walletAddress: string) {}
  async pay(amount: number): Promise<void> {    console.log(`Charging $${amount} to wallet ${this.walletAddress}`);    // Crypto payment processing    await this.processCrypto(amount);  }
  private async processCrypto(amount: number): Promise<void> {    // Implementation details  }}
// Context class uses strategyclass PaymentProcessor {  constructor(private strategy: PaymentStrategy) {}
  setStrategy(strategy: PaymentStrategy): void {    this.strategy = strategy;  }
  async processPayment(amount: number): Promise<void> {    await this.strategy.pay(amount);  }}
// Usageconst processor = new PaymentProcessor(  new CreditCardStrategy('4111-1111-1111-1111', '123'));await processor.processPayment(100);
processor.setStrategy(new PayPalStrategy('[email protected]'));await processor.processPayment(50);

This works, but it's verbose. Do we really need classes for each payment method?

Functions as Strategies

JavaScript/TypeScript has first-class functions. Strategies can be simple functions:

typescript
type PaymentStrategy = (amount: number) => Promise<void>;
const creditCardPayment: PaymentStrategy = async (amount) => {  console.log(`Charging $${amount} to credit card`);  // Credit card processing};
const paypalPayment: PaymentStrategy = async (amount) => {  console.log(`Charging $${amount} via PayPal`);  // PayPal processing};
const cryptoPayment: PaymentStrategy = async (amount) => {  console.log(`Charging $${amount} with crypto`);  // Crypto processing};
// Context accepts functionclass PaymentProcessor {  constructor(private strategy: PaymentStrategy) {}
  setStrategy(strategy: PaymentStrategy): void {    this.strategy = strategy;  }
  async processPayment(amount: number): Promise<void> {    await this.strategy(amount);  }}
// Even simpler: no class neededasync function processPayment(  amount: number,  strategy: PaymentStrategy): Promise<void> {  await strategy(amount);}
// Usageawait processPayment(100, creditCardPayment);await processPayment(50, paypalPayment);

Benefits of function strategies:

  • Less boilerplate (no class definitions)
  • More flexible (closures capture context)
  • Easier testing (mock functions simpler than mock classes)
  • Natural composition (higher-order functions)

Strategy with Closures

Closures enable strategies with private state:

typescript
type PaymentStrategy = (amount: number) => Promise<void>;
function createCreditCardPayment(  cardNumber: string,  cvv: string): PaymentStrategy {  // Closure captures card details  return async (amount: number) => {    console.log(`Charging $${amount} to card ending in ${cardNumber.slice(-4)}`);    // Process with captured credentials    await processCreditCard(cardNumber, cvv, amount);  };}
function createPayPalPayment(email: string): PaymentStrategy {  return async (amount: number) => {    console.log(`Charging $${amount} to PayPal account ${email}`);    await processPayPal(email, amount);  };}
// Usageconst creditCard = createCreditCardPayment('4111-1111-1111-1111', '123');const paypal = createPayPalPayment('[email protected]');
await processPayment(100, creditCard);await processPayment(50, paypal);

React: Strategy via Props

In React, strategies often manifest as render props or component props:

typescript
interface DataGridProps<T> {  data: T[];  renderRow: (item: T, index: number) => JSX.Element; // Strategy  renderEmpty?: () => JSX.Element;  renderLoading?: () => JSX.Element;}
function DataGrid<T>({  data,  renderRow,  renderEmpty,  renderLoading}: DataGridProps<T>) {  if (loading && renderLoading) {    return renderLoading();  }
  if (data.length === 0 && renderEmpty) {    return renderEmpty();  }
  return (    <div className="data-grid">      {data.map((item, i) => (        <div key={i} className="grid-row">          {renderRow(item, i)}        </div>      ))}    </div>  );}
// Different rendering strategiesinterface User {  id: string;  name: string;  email: string;  avatar: string;}
// Strategy 1: Card layout<DataGrid  data={users}  renderRow={(user) => (    <div className="user-card">      <img src={user.avatar} alt={user.name} />      <h3>{user.name}</h3>      <p>{user.email}</p>    </div>  )}/>
// Strategy 2: List layout<DataGrid  data={users}  renderRow={(user) => (    <div className="user-list-item">      <span>{user.name}</span>      <span>{user.email}</span>    </div>  )}/>
// Strategy 3: Table row<table>  <DataGrid    data={users}    renderRow={(user) => (      <>        <td>{user.name}</td>        <td>{user.email}</td>      </>    )}  /></table>

Real Scenario: Form Validation

Form validation benefits from composable strategy pattern:

typescript
type ValidationStrategy = (value: string) => string | null;
// Individual validation strategiesconst required: ValidationStrategy = (value) => {  return value.trim() ? null : 'This field is required';};
const email: ValidationStrategy = (value) => {  const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;  return emailRegex.test(value) ? null : 'Invalid email address';};
const minLength = (min: number): ValidationStrategy => (value) => {  return value.length >= min    ? null    : `Must be at least ${min} characters`;};
const maxLength = (max: number): ValidationStrategy => (value) => {  return value.length <= max    ? null    : `Must be at most ${max} characters`;};
const phone: ValidationStrategy = (value) => {  const phoneRegex = /^\d{10}$/;  return phoneRegex.test(value) ? null : 'Invalid phone number';};
// Compose multiple strategiesfunction composeValidations(...strategies: ValidationStrategy[]): ValidationStrategy {  return (value: string) => {    for (const strategy of strategies) {      const error = strategy(value);      if (error) return error;    }    return null;  };}
// Field configurations with composed strategiesinterface FieldConfig {  name: string;  label: string;  validate: ValidationStrategy;}
const formConfig: FieldConfig[] = [  {    name: 'email',    label: 'Email Address',    validate: composeValidations(required, email)  },  {    name: 'password',    label: 'Password',    validate: composeValidations(      required,      minLength(8),      maxLength(128)    )  },  {    name: 'phone',    label: 'Phone Number',    validate: composeValidations(required, phone)  }];
// Form component uses strategiesfunction RegistrationForm() {  const [values, setValues] = useState<Record<string, string>>({});  const [errors, setErrors] = useState<Record<string, string>>({});
  const handleChange = (name: string, value: string) => {    setValues(prev => ({ ...prev, [name]: value }));
    // Validate using strategy    const field = formConfig.find(f => f.name === name);    if (field) {      const error = field.validate(value);      setErrors(prev => ({ ...prev, [name]: error || '' }));    }  };
  const handleSubmit = (e: React.FormEvent) => {    e.preventDefault();
    // Validate all fields    const newErrors: Record<string, string> = {};    formConfig.forEach(field => {      const error = field.validate(values[field.name] || '');      if (error) newErrors[field.name] = error;    });
    if (Object.keys(newErrors).length > 0) {      setErrors(newErrors);      return;    }
    // Submit form    console.log('Form valid:', values);  };
  return (    <form onSubmit={handleSubmit}>      {formConfig.map(field => (        <div key={field.name}>          <label>{field.label}</label>          <input            value={values[field.name] || ''}            onChange={(e) => handleChange(field.name, e.target.value)}          />          {errors[field.name] && (            <span className="error">{errors[field.name]}</span>          )}        </div>      ))}      <button type="submit">Register</button>    </form>  );}

This approach offers:

  • Reusable validators: Use across multiple forms
  • Composable: Combine strategies easily
  • Type-safe: TypeScript validates strategy signatures
  • Testable: Test validators independently
  • Declarative: Configuration describes validation rules

When Strategy Pattern Adds Value

Use Strategy pattern when:

  • Algorithm selection happens at runtime
  • Multiple implementations of same interface exist
  • Behavior changes based on configuration
  • Testing requires swapping implementations

Don't use Strategy pattern when:

  • Only one implementation exists
  • Logic is simple conditional (just use if)
  • Overhead exceeds benefit
  • Functions can't be easily abstracted

Command Pattern: Redux Actions and Undo/Redo

Command pattern encapsulates requests as objects, enabling parameterization, queuing, logging, and undo operations. In modern applications, Command pattern appears in Redux actions, undo/redo systems, and task queues.

Classic Command Pattern

The textbook approach uses command objects with execute() and undo() methods:

typescript
interface Command {  execute(): void;  undo(): void;}
class TodoList {  private todos: string[] = [];
  add(todo: string): void {    this.todos.push(todo);  }
  remove(index: number): void {    this.todos.splice(index, 1);  }
  getTodos(): string[] {    return [...this.todos];  }}
class AddTodoCommand implements Command {  private index: number = -1;
  constructor(    private todoList: TodoList,    private todo: string  ) {}
  execute(): void {    this.todoList.add(this.todo);    this.index = this.todoList.getTodos().length - 1;  }
  undo(): void {    if (this.index !== -1) {      this.todoList.remove(this.index);    }  }}
class RemoveTodoCommand implements Command {  private removedTodo: string = '';  private removedIndex: number = -1;
  constructor(    private todoList: TodoList,    private index: number  ) {}
  execute(): void {    const todos = this.todoList.getTodos();    this.removedTodo = todos[this.index];    this.removedIndex = this.index;    this.todoList.remove(this.index);  }
  undo(): void {    // Can't easily restore at exact position    this.todoList.add(this.removedTodo);  }}
// Command history for undo/redoclass CommandHistory {  private history: Command[] = [];  private current: number = -1;
  execute(command: Command): void {    // Execute command    command.execute();
    // Remove any commands after current position    this.history = this.history.slice(0, this.current + 1);
    // Add command to history    this.history.push(command);    this.current++;  }
  undo(): void {    if (this.canUndo()) {      this.history[this.current].undo();      this.current--;    }  }
  redo(): void {    if (this.canRedo()) {      this.current++;      this.history[this.current].execute();    }  }
  canUndo(): boolean {    return this.current >= 0;  }
  canRedo(): boolean {    return this.current < this.history.length - 1;  }}
// Usageconst todoList = new TodoList();const history = new CommandHistory();
history.execute(new AddTodoCommand(todoList, 'Buy milk'));history.execute(new AddTodoCommand(todoList, 'Walk dog'));console.log(todoList.getTodos()); // ['Buy milk', 'Walk dog']
history.undo();console.log(todoList.getTodos()); // ['Buy milk']
history.redo();console.log(todoList.getTodos()); // ['Buy milk', 'Walk dog']

Redux Actions as Commands

Redux actions are command objects. They encapsulate state changes as serializable data:

typescript
// Action types (command types)interface AddTodoAction {  type: 'ADD_TODO';  payload: {    id: string;    text: string;  };}
interface RemoveTodoAction {  type: 'REMOVE_TODO';  payload: {    id: string;  };}
interface ToggleTodoAction {  type: 'TOGGLE_TODO';  payload: {    id: string;  };}
type TodoAction = AddTodoAction | RemoveTodoAction | ToggleTodoAction;
// Action creators (command factories)function addTodo(text: string): AddTodoAction {  return {    type: 'ADD_TODO',    payload: {      id: crypto.randomUUID(), // Note: crypto.randomUUID() requires HTTPS or localhost      text    }  };}
function removeTodo(id: string): RemoveTodoAction {  return {    type: 'REMOVE_TODO',    payload: { id }  };}
function toggleTodo(id: string): ToggleTodoAction {  return {    type: 'TOGGLE_TODO',    payload: { id }  };}
// Reducer (command handler)interface TodoState {  todos: Array<{ id: string; text: string; completed: boolean }>;}
function todoReducer(  state: TodoState = { todos: [] },  action: TodoAction): TodoState {  switch (action.type) {    case 'ADD_TODO':      return {        ...state,        todos: [          ...state.todos,          {            id: action.payload.id,            text: action.payload.text,            completed: false          }        ]      };
    case 'REMOVE_TODO':      return {        ...state,        todos: state.todos.filter(todo => todo.id !== action.payload.id)      };
    case 'TOGGLE_TODO':      return {        ...state,        todos: state.todos.map(todo =>          todo.id === action.payload.id            ? { ...todo, completed: !todo.completed }            : todo        )      };
    default:      return state;  }}
// Store (command executor)import { configureStore } from '@reduxjs/toolkit';
const store = configureStore({  reducer: todoReducer});
// Dispatch commandsstore.dispatch(addTodo('Buy milk'));store.dispatch(addTodo('Walk dog'));store.dispatch(toggleTodo(/* id */));

Redux commands enable:

  • Serialization: Actions are plain objects, can be logged/stored
  • Time-travel debugging: Replay action history
  • Middleware: Intercept and transform commands
  • Undo/redo: Store action history, replay or reverse

Redux Middleware: Command Pipeline

Middleware intercepts commands before they reach reducers:

typescript
import { Middleware } from 'redux';
// Logging middlewareconst logger: Middleware = store => next => action => {  console.log('Dispatching:', action);  const result = next(action);  console.log('Next state:', store.getState());  return result;};
// Analytics middlewareconst analytics: Middleware = store => next => action => {  // Track user actions  if (action.type.startsWith('USER_')) {    trackEvent(action.type, action.payload);  }  return next(action);};
// Error handling middlewareconst errorHandler: Middleware = store => next => action => {  try {    return next(action);  } catch (error) {    console.error('Action error:', error);    store.dispatch({      type: 'ERROR_OCCURRED',      payload: { error: error.message }    });  }};
// Apply middleware with Redux Toolkitimport { configureStore } from '@reduxjs/toolkit';
const store = configureStore({  reducer,  middleware: (getDefaultMiddleware) =>    getDefaultMiddleware().concat(logger, analytics, errorHandler)});

Redux Thunk: Async Commands

Redux Thunk enables async commands with side effects:

typescript
import { ThunkAction } from 'redux-thunk';import { AnyAction } from 'redux';
type AppThunk<ReturnType = void> = ThunkAction<  ReturnType,  AppState,  unknown,  AnyAction>;
// Async commandconst fetchUser = (userId: string): AppThunk => async (dispatch) => {  // Dispatch request command  dispatch({    type: 'FETCH_USER_REQUEST',    payload: { userId }  });
  try {    // Perform side effect    const response = await fetch(`/api/users/${userId}`);    const user = await response.json();
    // Dispatch success command    dispatch({      type: 'FETCH_USER_SUCCESS',      payload: { user }    });  } catch (error) {    // Dispatch error command    dispatch({      type: 'FETCH_USER_FAILURE',      payload: { error: error.message }    });  }};
// Dispatch async commandstore.dispatch(fetchUser('123'));

Real Scenario: Rich Text Editor with Undo/Redo

A rich text editor demonstrates Command pattern's undo/redo value:

typescript
interface EditorCommand {  execute(editor: Editor): void;  undo(editor: Editor): void;  description: string;}
interface Editor {  content: string;  selectionStart: number;  selectionEnd: number;  insertText(position: number, text: string): void;  deleteText(position: number, length: number): string;  setSelection(start: number, end: number): void;}
class InsertTextCommand implements EditorCommand {  description: string;
  constructor(    private position: number,    private text: string  ) {    this.description = `Insert "${text}" at position ${position}`;  }
  execute(editor: Editor): void {    editor.insertText(this.position, this.text);    editor.setSelection(      this.position + this.text.length,      this.position + this.text.length    );  }
  undo(editor: Editor): void {    editor.deleteText(this.position, this.text.length);    editor.setSelection(this.position, this.position);  }}
class DeleteTextCommand implements EditorCommand {  private deletedText: string = '';  description: string;
  constructor(    private position: number,    private length: number  ) {    this.description = `Delete ${length} characters at position ${position}`;  }
  execute(editor: Editor): void {    this.deletedText = editor.deleteText(this.position, this.length);    editor.setSelection(this.position, this.position);  }
  undo(editor: Editor): void {    editor.insertText(this.position, this.deletedText);    editor.setSelection(      this.position + this.deletedText.length,      this.position + this.deletedText.length    );  }}
class FormatTextCommand implements EditorCommand {  private previousFormat: string = '';  description: string;
  constructor(    private start: number,    private end: number,    private format: 'bold' | 'italic' | 'underline'  ) {    this.description = `Apply ${format} from ${start} to ${end}`;  }
  execute(editor: Editor): void {    this.previousFormat = editor.getFormat(this.start, this.end);    editor.applyFormat(this.start, this.end, this.format);  }
  undo(editor: Editor): void {    editor.applyFormat(this.start, this.end, this.previousFormat);  }}
// Command history with groupingclass EditorHistory {  private history: EditorCommand[] = [];  private current: number = -1;  private groupedCommands: EditorCommand[] = [];  private isGrouping: boolean = false;
  execute(command: EditorCommand, editor: Editor): void {    command.execute(editor);
    if (this.isGrouping) {      this.groupedCommands.push(command);      return;    }
    this.addToHistory(command);  }
  private addToHistory(command: EditorCommand): void {    // Remove any commands after current position    this.history = this.history.slice(0, this.current + 1);    this.history.push(command);    this.current++;  }
  beginGroup(): void {    this.isGrouping = true;    this.groupedCommands = [];  }
  endGroup(): void {    this.isGrouping = false;    if (this.groupedCommands.length > 0) {      const group = new CompositeCommand(this.groupedCommands);      this.addToHistory(group);    }  }
  undo(editor: Editor): void {    if (this.canUndo()) {      this.history[this.current].undo(editor);      this.current--;    }  }
  redo(editor: Editor): void {    if (this.canRedo()) {      this.current++;      this.history[this.current].execute(editor);    }  }
  canUndo(): boolean {    return this.current >= 0;  }
  canRedo(): boolean {    return this.current < this.history.length - 1;  }
  getHistory(): string[] {    return this.history.map(cmd => cmd.description);  }}
// Composite command for groupingclass CompositeCommand implements EditorCommand {  description: string;
  constructor(private commands: EditorCommand[]) {    this.description = `Group of ${commands.length} commands`;  }
  execute(editor: Editor): void {    this.commands.forEach(cmd => cmd.execute(editor));  }
  undo(editor: Editor): void {    // Undo in reverse order    for (let i = this.commands.length - 1; i >= 0; i--) {      this.commands[i].undo(editor);    }  }}
// Usage in editor componentclass RichTextEditor {  private history = new EditorHistory();
  insertText(position: number, text: string): void {    const command = new InsertTextCommand(position, text);    this.history.execute(command, this.editor);  }
  deleteText(position: number, length: number): void {    const command = new DeleteTextCommand(position, length);    this.history.execute(command, this.editor);  }
  applyFormat(start: number, end: number, format: 'bold' | 'italic' | 'underline'): void {    const command = new FormatTextCommand(start, end, format);    this.history.execute(command, this.editor);  }
  // Group multiple commands as single undoable action  paste(text: string): void {    this.history.beginGroup();
    // Delete selection if exists    if (this.editor.selectionStart !== this.editor.selectionEnd) {      this.deleteText(        this.editor.selectionStart,        this.editor.selectionEnd - this.editor.selectionStart      );    }
    // Insert pasted text    this.insertText(this.editor.selectionStart, text);
    this.history.endGroup();  }
  undo(): void {    this.history.undo(this.editor);  }
  redo(): void {    this.history.redo(this.editor);  }}

This implementation provides:

  • Fine-grained undo: Each edit operation undoable
  • Grouped commands: Paste operation undoes as single action
  • Command history: View list of all actions
  • Redo support: Undo mistakes in undo
  • Extensible: Add new command types easily

When Command Pattern Adds Value

Use Command pattern when:

  • Undo/redo functionality required
  • Operations need queuing or scheduling
  • Logging/auditing actions necessary
  • Macro recording needed
  • Transaction support required

Don't use Command pattern when:

  • Simple CRUD operations without undo
  • No need for action history
  • Overhead exceeds benefit
  • Real-time collaboration (use CRDT instead)

State Pattern: Finite State Machines

State pattern allows objects to alter behavior when internal state changes. In UI development, State pattern prevents impossible state combinations and makes transitions explicit.

The Problem: Boolean State Hell

Complex UI often leads to multiple boolean flags:

typescript
function Form() {  const [isValidating, setIsValidating] = useState(false);  const [isSubmitting, setIsSubmitting] = useState(false);  const [hasError, setHasError] = useState(false);  const [isSuccess, setIsSuccess] = useState(false);
  // What if isSubmitting && hasError are both true?  // What if isSuccess && isValidating are both true?  // Many impossible combinations possible}

Problems with boolean states:

  • Impossible combinations possible (isSubmitting and isSuccess both true)
  • Unclear transitions (how do we go from validating to submitting?)
  • Testing explosion (test all combinations)
  • Hard to reason about behavior

Discriminated Unions as State Pattern

TypeScript discriminated unions prevent impossible states:

typescript
type FormState =  | { status: 'idle' }  | { status: 'validating' }  | { status: 'submitting'; progress: number }  | { status: 'success'; data: SubmitResult }  | { status: 'error'; error: Error };
function Form() {  const [state, setState] = useState<FormState>({ status: 'idle' });
  const handleSubmit = async () => {    // Transition to validating    setState({ status: 'validating' });
    try {      await validateForm();
      // Transition to submitting      setState({ status: 'submitting', progress: 0 });
      const result = await submitForm((progress) => {        setState({ status: 'submitting', progress });      });
      // Transition to success      setState({ status: 'success', data: result });    } catch (error) {      // Transition to error      setState({ status: 'error', error });    }  };
  // Render based on state  switch (state.status) {    case 'idle':      return <FormFields onSubmit={handleSubmit} />;
    case 'validating':      return <Spinner message="Validating..." />;
    case 'submitting':      return <ProgressBar progress={state.progress} />;
    case 'success':      return <SuccessMessage data={state.data} />;
    case 'error':      return (        <>          <ErrorMessage error={state.error} />          <button onClick={handleSubmit}>Retry</button>        </>      );  }}

Benefits of discriminated unions:

  • Impossible states impossible: Can't be validating and success simultaneously
  • Type-safe: TypeScript ensures all states handled
  • Clear transitions: Explicit state changes
  • Associated data: Each state has relevant data (progress only when submitting)

XState: Explicit State Machines

XState provides declarative state machine definition:

typescript
import { setup, createActor } from 'xstate';
// XState v5 uses setup() for type-safe machine definitionconst formMachine = setup({  types: {    context: {} as {      formData: any;      error: any;    },    events: {} as      | { type: 'SUBMIT' }      | { type: 'RETRY' }      | { type: 'CANCEL' }  },  actions: {    setFormData: ({ context }, params: { data: any }) => {      context.formData = params.data;    },    setError: ({ context }, params: { data: any }) => {      context.error = params.data;    }  },  actors: {    validateForm: async () => {      // Validation logic      await new Promise(resolve => setTimeout(resolve, 1000));    },    submitForm: async () => {      // Submission logic      await new Promise(resolve => setTimeout(resolve, 2000));      return { success: true };    }  }}).createMachine({  id: 'form',  initial: 'idle',  context: {    formData: null,    error: null  },  states: {    idle: {      on: {        SUBMIT: 'validating'      }    },    validating: {      invoke: {        src: 'validateForm',        onDone: {          target: 'submitting'        },        onError: {          target: 'error',          actions: {            type: 'setError',            params: ({ event }) => ({ data: event.error })          }        }      }    },    submitting: {      invoke: {        src: 'submitForm',        onDone: {          target: 'success',          actions: {            type: 'setFormData',            params: ({ event }) => ({ data: event.output })          }        },        onError: {          target: 'error',          actions: {            type: 'setError',            params: ({ event }) => ({ data: event.error })          }        }      }    },    success: {      type: 'final'    },    error: {      on: {        RETRY: 'validating',        CANCEL: 'idle'      }    }  }});
// Use in Reactimport { useMachine } from '@xstate/react';
function Form() {  const [state, send] = useMachine(formMachine);
  return (    <form onSubmit={(e) => {      e.preventDefault();      send('SUBMIT');    }}>      {state.matches('idle') && (        <div>          <input name="email" />          <button type="submit">Submit</button>        </div>      )}
      {state.matches('validating') && (        <Spinner message="Validating form..." />      )}
      {state.matches('submitting') && (        <Spinner message="Submitting..." />      )}
      {state.matches('success') && (        <SuccessMessage data={state.context.formData} />      )}
      {state.matches('error') && (        <div>          <ErrorMessage error={state.context.error} />          <button onClick={() => send('RETRY')}>Retry</button>          <button onClick={() => send('CANCEL')}>Cancel</button>        </div>      )}    </form>  );}

Visualizing State Machines

XState machines are visualizable with Mermaid diagrams:

XState advantages:

  • Visual design: Design state machine visually, generate code
  • Impossible transitions prevented: Can't go from idle to success
  • Test generation: Generate tests from state machine
  • Actor model: State machines can spawn and communicate
  • History states: Remember previous state when returning

Real Scenario: Multi-Step Wizard

Multi-step forms benefit from explicit state machines:

typescript
import { setup, assign } from 'xstate';
interface WizardContext {  step1Data: any;  step2Data: any;  step3Data: any;  error: string | null;}
type WizardEvent =  | { type: 'NEXT'; data: any }  | { type: 'PREVIOUS' }  | { type: 'SUBMIT' }  | { type: 'RESET' };
// XState v5 with setup()const wizardMachine = setup({  types: {    context: {} as WizardContext,    events: {} as WizardEvent  },  actors: {    submitWizard: async ({ context }: { context: WizardContext }) => {      const response = await fetch('/api/wizard', {        method: 'POST',        body: JSON.stringify({          step1: context.step1Data,          step2: context.step2Data,          step3: context.step3Data        })      });
      if (!response.ok) {        throw new Error('Submission failed');      }
      return response.json();    }  }}).createMachine({  id: 'wizard',  initial: 'step1',  context: {    step1Data: null,    step2Data: null,    step3Data: null,    error: null  },  states: {    step1: {      on: {        NEXT: {          target: 'step2',          actions: assign({            step1Data: ({ event }) => event.data          })        }      }    },    step2: {      on: {        NEXT: {          target: 'step3',          actions: assign({            step2Data: ({ event }) => event.data          })        },        PREVIOUS: 'step1'      }    },    step3: {      on: {        PREVIOUS: 'step2',        SUBMIT: 'submitting'      }    },    submitting: {      invoke: {        src: 'submitWizard',        onDone: 'success',        onError: {          target: 'step3',          actions: assign({            error: ({ event }) => event.error.message          })        }      }    },    success: {      type: 'final'    }  }});
// React componentfunction Wizard() {  const [state, send] = useMachine(wizardMachine);
  const handleNext = (data: any) => {    send({ type: 'NEXT', data });  };
  const handlePrevious = () => {    send('PREVIOUS');  };
  const handleSubmit = () => {    send('SUBMIT');  };
  return (    <div>      {state.matches('step1') && (        <Step1 onNext={handleNext} />      )}
      {state.matches('step2') && (        <Step2          initialData={state.context.step1Data}          onNext={handleNext}          onPrevious={handlePrevious}        />      )}
      {state.matches('step3') && (        <Step3          data={{            step1: state.context.step1Data,            step2: state.context.step2Data          }}          error={state.context.error}          onSubmit={handleSubmit}          onPrevious={handlePrevious}        />      )}
      {state.matches('submitting') && (        <Spinner message="Submitting wizard..." />      )}
      {state.matches('success') && (        <SuccessMessage message="Wizard completed!" />      )}    </div>  );}

When to Use State Machines

Use state machines when:

  • Complex UI workflows with many states
  • Need to prevent impossible states
  • State transitions have business rules
  • Visual design helpful for team communication
  • Testing state transitions critical

Don't use state machines when:

  • Simple forms with 2-3 states
  • Overhead exceeds benefit
  • Team unfamiliar with state machines
  • Discriminated unions sufficient

Mediator Pattern: Decoupling Components

Mediator pattern centralizes complex communications between objects, preventing direct references and reducing coupling. In modern applications, Mediator appears as event buses, React Context, and state management libraries.

The Problem: Tight Coupling

Without mediator, components reference each other directly:

typescript
class UserList {  constructor(private userDetails: UserDetails) {}
  selectUser(user: User): void {    // Direct dependency on UserDetails    this.userDetails.display(user);  }}
class UserDetails {  display(user: User): void {    // Display user details  }}
// Hard to test UserList without UserDetails// Hard to reuse UserList with different detail component

Event Bus as Mediator

An event bus decouples components:

typescript
type EventCallback = (data: any) => void;
class EventBus {  private events = new Map<string, EventCallback[]>();
  subscribe(event: string, callback: EventCallback): () => void {    if (!this.events.has(event)) {      this.events.set(event, []);    }
    this.events.get(event)!.push(callback);
    // Return unsubscribe function    return () => {      const callbacks = this.events.get(event);      if (callbacks) {        const index = callbacks.indexOf(callback);        if (index !== -1) {          callbacks.splice(index, 1);        }      }    };  }
  publish(event: string, data?: any): void {    const callbacks = this.events.get(event);    if (callbacks) {      callbacks.forEach(callback => callback(data));    }  }
  clear(): void {    this.events.clear();  }}
// Components communicate through mediatorclass UserList {  constructor(private eventBus: EventBus) {}
  selectUser(user: User): void {    // Publish event instead of direct call    this.eventBus.publish('user:selected', user);  }}
class UserDetails {  constructor(private eventBus: EventBus) {    // Subscribe to events    this.eventBus.subscribe('user:selected', this.display.bind(this));  }
  display(user: User): void {    console.log('Displaying user:', user);  }}
class UserStats {  constructor(private eventBus: EventBus) {    this.eventBus.subscribe('user:selected', this.loadStats.bind(this));  }
  loadStats(user: User): void {    console.log('Loading stats for:', user);  }}
// Usageconst eventBus = new EventBus();const userList = new UserList(eventBus);const userDetails = new UserDetails(eventBus);const userStats = new UserStats(eventBus);
// Components don't know about each otheruserList.selectUser({ id: '1', name: 'John' });

React Context as Mediator

React Context provides mediator pattern for component trees:

typescript
interface AppContextValue {  selectedUser: User | null;  setSelectedUser: (user: User | null) => void;  notifications: Notification[];  addNotification: (notification: Notification) => void;}
const AppContext = createContext<AppContextValue>(null!);
function AppProvider({ children }: { children: ReactNode }) {  const [selectedUser, setSelectedUser] = useState<User | null>(null);  const [notifications, setNotifications] = useState<Notification[]>([]);
  const addNotification = (notification: Notification) => {    setNotifications(prev => [...prev, notification]);  };
  return (    <AppContext.Provider value={{      selectedUser,      setSelectedUser,      notifications,      addNotification    }}>      {children}    </AppContext.Provider>  );}
// Components communicate through contextfunction UserList() {  const { setSelectedUser } = useContext(AppContext);  const users = useUsers();
  return (    <ul>      {users.map(user => (        <li key={user.id} onClick={() => setSelectedUser(user)}>          {user.name}        </li>      ))}    </ul>  );}
function UserDetails() {  const { selectedUser } = useContext(AppContext);
  if (!selectedUser) {    return <div>Select a user</div>;  }
  return (    <div>      <h2>{selectedUser.name}</h2>      <p>{selectedUser.email}</p>    </div>  );}
function UserStats() {  const { selectedUser } = useContext(AppContext);
  if (!selectedUser) return null;
  return <div>Stats for {selectedUser.name}</div>;}
// App structurefunction App() {  return (    <AppProvider>      <UserList />      <UserDetails />      <UserStats />    </AppProvider>  );}

Redux as Global Mediator

Redux centralizes all application communication through actions:

typescript
// All components communicate via storefunction UserList() {  const dispatch = useDispatch();  const users = useSelector(state => state.users);
  const handleSelect = (user: User) => {    // Components don't call each other directly    dispatch(selectUser(user));    dispatch(fetchUserDetails(user.id));    dispatch(loadUserStats(user.id));  };
  return (    <ul>      {users.map(user => (        <li key={user.id} onClick={() => handleSelect(user)}>          {user.name}        </li>      ))}    </ul>  );}
function UserDetails() {  const selectedUser = useSelector(state => state.selectedUser);  const userDetails = useSelector(state => state.userDetails);
  if (!selectedUser) return null;
  return (    <div>      <h2>{selectedUser.name}</h2>      {userDetails && <DetailedInfo details={userDetails} />}    </div>  );}
function UserStats() {  const userStats = useSelector(state => state.userStats);
  if (!userStats) return null;
  return <StatsDisplay stats={userStats} />;}

When to Use Mediator Pattern

Use event bus when:

  • Components in same module need communication
  • Lightweight pub/sub needed
  • No global state requirements
  • Quick prototype or simple app

Use React Context when:

  • Components in same subtree communicate
  • Prop drilling becomes painful
  • Theme, auth, or locale sharing
  • Medium-sized component trees

Use Redux when:

  • Large application with complex state
  • Many components access same state
  • Need DevTools and time-travel
  • Middleware valuable (sagas, thunks)
  • Team experienced with Redux

Avoid Mediator when:

  • Direct communication simpler (parent-child props)
  • Only 2-3 components involved
  • Adding unnecessary indirection
  • Debugging becomes harder

Key Takeaways

  1. Observer evolved into Reactive Programming: RxJS, Redux, and React hooks represent Observer pattern's evolution with better composition, error handling, and backpressure management.

  2. Strategy pattern uses functions, not classes: JavaScript's first-class functions make Strategy pattern simpler - pass functions as strategies instead of creating strategy classes.

  3. Command powers Redux and undo/redo: Redux actions are Command pattern. The pattern remains valuable for undo/redo, action logging, and operation queuing.

  4. State machines prevent impossible states: XState and discriminated unions implement State pattern to prevent boolean state hell and make transitions explicit.

  5. Mediator reduces coupling: Event buses, React Context, and Redux all implement Mediator pattern at different scales - choose based on application size and communication complexity.

  6. Context matters: These patterns aren't universally good. RxJS adds value for complex async coordination but is overkill for simple events. State machines help complex workflows but overcomplicate simple forms. Choose based on actual needs.

  7. Modern implementations are cleaner: Today's behavioral patterns leverage language features (first-class functions, closures, modules) for cleaner implementations than classic GoF versions required in C++/Smalltalk.

References

Modern Perspective on Classic Design Patterns

A comprehensive series examining how classic Gang of Four design patterns have evolved in modern TypeScript, React, and functional programming contexts. Learn when classic patterns still apply, when they've been superseded, and how to recognize underlying principles in modern codebases.

Progress3/4 posts completed

Related Posts