Skip to content

Behavioral Design Patterns in TypeScript: Observer, Strategy, Command, State

Observer, Strategy, Command, State, and Mediator in TypeScript: which ones the language already covers, and when RxJS, Redux, or XState earn their place.

Ayhan Sipahi Ayhan Sipahi

Behavioral patterns define how objects communicate and distribute responsibilities. The Gang of Four documented Observer, Strategy, Command, State, and Mediator for C++ and Smalltalk, languages where each one cost a page of boilerplate before it did anything useful. TypeScript changed that arithmetic. First-class functions, closures, and discriminated unions cover several of these patterns outright, and RxJS, Redux, and XState absorbed most of the rest into framework conventions.

The default that falls out is simple: implement the pattern with a language feature first, and escalate to a library only when a named capability forces it. A function is enough for Strategy, a discriminated union for State, a subscribe list for Observer. Each escalation has its own trigger: operator composition, cancellation, and backpressure justify RxJS; serializable, replayable action history justifies Redux; guarded transitions that reviewers need to read as a chart justify XState, and absent those triggers, the library only adds overhead for something the language already gives you.

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 is probably the most influential pattern in modern web development, even though it rarely appears under that name.

Subject-Observer Boilerplate#

Observer’s original form requires explicit subject-observer relationships:

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));
  }
}

// Usage
class 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 });

The subject-observer pair covers the basic case, but backpressure handling, error propagation, completion signals, and operator composition are missing entirely.

Node.js EventEmitter Evolution#

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

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 Takes Observer Further#

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

import { Observable, Subject, of } from 'rxjs';
import {
  debounceTime,
  distinctUntilChanged,
  map,
  filter,
  catchError
} from 'rxjs';

// Search input with reactive operators
const 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 values
searchInput$.next('rea');
searchInput$.next('react');
searchInput$.next('reactive');

None of the following exists in EventEmitter:

  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

Streaming Prices Over a WebSocket#

A live stock price feed puts those operators to work at once:

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 Observable
function 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 chart
stockPrices$.pipe(
  filter(price => price.symbol === 'AAPL'),
  map(price => price.value)
).subscribe(value => {
  updateChart('AAPL', value);
});

// Observer 2: Calculate portfolio total
stockPrices$.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 changes
stockPrices$.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 unmounts
const unsubscribe$ = new Subject<void>();
stockPrices$.pipe(
  takeUntil(unsubscribe$)
).subscribe();

// Later: cleanup all subscriptions
function cleanup() {
  unsubscribe$.next();
  unsubscribe$.complete();
}

Unsubscribing closes the socket, and share() keeps every observer on one connection.

Redux and Zustand as State Observers#

Redux implements Observer pattern for application state management:

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 changes
const unsubscribe = store.subscribe(() => {
  const state = store.getState();
  console.log('State changed:', state);
  // Update UI based on new state
});

// Dispatch actions
store.dispatch(increment());
store.dispatch(setUser({ id: '1', name: 'John' }));

// Later: cleanup
unsubscribe();

React-Redux connects components to the store as observers.

Note

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

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 changes
export default connect(
  (state: AppState) => ({
    count: state.count
  }),
  (dispatch) => ({
    increment: () => dispatch({ type: 'INCREMENT' }),
    decrement: () => dispatch({ type: 'DECREMENT' })
  })
)(Counter);

Zustand keeps the same subscribe-and-notify idea with hooks and far less boilerplate:

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 state
function 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>
  );
}

The store above keeps every capability React-Redux offered, with less code around it. Components subscribe through hooks and re-render only when the state they select changes. There are no action creators, no reducers, and no connect HOC, while type inference still works end to end and the middleware layer covers DevTools, persist, and immer.

Choosing an Observer Variant#

ToolReach for it when
RxJSComplex async coordination across multiple streams, advanced operators (debounce, throttle, retry), WebSocket or SSE connections, event stream processing, or backpressure handling is critical
ReduxLarge application with complex state, time-travel debugging, many components accessing the same state, or the middleware ecosystem (sagas, thunks) matters and the team knows Redux patterns
ZustandMedium-sized application that wants simplicity over features, no need for Redux DevTools, hooks preferred over connect HOC, TypeScript support a priority
Plain EventEmitterNode.js backend services, simple pub/sub within a module, no need for operators or backpressure, minimal dependencies preferred

Strategy Without the Class Hierarchy#

Strategy pattern picks an algorithm at runtime. TypeScript’s first-class functions mean it rarely needs a class hierarchy to do that: a plain function usually works better.

Interface-Based Payment Strategies#

Interfaces and concrete implementations handle this the standard way:

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 strategy
class PaymentProcessor {
  constructor(private strategy: PaymentStrategy) {}

  setStrategy(strategy: PaymentStrategy): void {
    this.strategy = strategy;
  }

  async processPayment(amount: number): Promise<void> {
    await this.strategy.pay(amount);
  }
}

// Usage
const processor = new PaymentProcessor(
  new CreditCardStrategy('4111-1111-1111-1111', '123')
);
await processor.processPayment(100);

processor.setStrategy(new PayPalStrategy('user@example.com'));
await processor.processPayment(50);

Three classes for three payment methods, none holding more than a constructor and a pay method, is a lot of ceremony for what a function could do directly.

Functions as Strategies#

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

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 function
class 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 needed
async function processPayment(
  amount: number,
  strategy: PaymentStrategy
): Promise<void> {
  await strategy(amount);
}

// Usage
await processPayment(100, creditCardPayment);
await processPayment(50, paypalPayment);

Function strategies drop the class boilerplate. Mocking a function is simpler than mocking a class, and higher-order functions compose them directly.

Closures also give a strategy private state:

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);
  };
}

// Usage
const creditCard = createCreditCardPayment('4111-1111-1111-1111', '123');
const paypal = createPayPalPayment('user@example.com');

await processPayment(100, creditCard);
await processPayment(50, paypal);

Strategy via React Props#

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

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 strategies
interface 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>

Form Validation With Composed Strategies#

Form validation benefits from composable strategy pattern:

type ValidationStrategy = (value: string) => string | null;

// Individual validation strategies
const 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 strategies
function 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 strategies
interface 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 strategies
function 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>
  );
}

The composition function is the only new abstraction here. Validators stay reusable across forms, each one tests on its own, and the field configuration reads as a declarative list of rules.

Strategy pattern makes sense when algorithm selection happens at runtime, several implementations share the same interface, behavior changes with configuration, or tests need to swap implementations. Skip it when only one implementation exists, the logic is a simple conditional that reads fine as if, or the functions resist being abstracted into a common shape.

Encapsulating Undo, Redo, and Redux Actions#

Wrapping a request as an object, the core idea behind Command pattern, is what makes parameterization, queuing, logging, and undo possible. Redux actions, undo/redo stacks, and task queues all lean on it, whether or not anyone calls it by that name.

Command Objects With Execute and Undo#

Command objects with execute() and undo() methods are the standard shape:

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/redo
class 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;
  }
}

// Usage
const 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:

// 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 commands
store.dispatch(addTodo('Buy milk'));
store.dispatch(addTodo('Walk dog'));
store.dispatch(toggleTodo(/* id */));

Structuring actions this way has several effects at once: the plain-object shape means they can be logged or stored, replayed for time-travel debugging, intercepted and transformed by middleware, and stored as history for undo and redo.

Intercepting Commands With Middleware#

Middleware intercepts commands before they reach reducers:

import { Middleware } from 'redux';

// Logging middleware
const logger: Middleware = store => next => action => {
  console.log('Dispatching:', action);
  const result = next(action);
  console.log('Next state:', store.getState());
  return result;
};

// Analytics middleware
const analytics: Middleware = store => next => action => {
  // Track user actions
  if (action.type.startsWith('USER_')) {
    trackEvent(action.type, action.payload);
  }
  return next(action);
};

// Error handling middleware
const 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 Toolkit
import { configureStore } from '@reduxjs/toolkit';

const store = configureStore({
  reducer,
  middleware: (getDefaultMiddleware) =>
    getDefaultMiddleware().concat(logger, analytics, errorHandler)
});

Redux Thunk sits in the same chain and handles commands that carry side effects:

import { ThunkAction } from 'redux-thunk';
import { AnyAction } from 'redux';

type AppThunk<ReturnType = void> = ThunkAction<
  ReturnType,
  AppState,
  unknown,
  AnyAction
>;

// Async command
const 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 command
store.dispatch(fetchUser('123'));

A Rich Text Editor’s Undo Stack#

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

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 grouping
class 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 grouping
class 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 component
class 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);
  }
}

That complexity buys undo for every edit operation, grouping so a paste undoes as one action, and room to add new command types without touching the history class.

Undo/redo, queued or scheduled operations, logging and auditing, macro recording, and transaction support all call for Command. Simple CRUD without undo needs none of it, and real-time collaboration is better served by CRDTs.

State Machines Over Boolean Flags#

State pattern lets an object’s behavior shift with its internal state, which in UI code means impossible state combinations disappear and transitions stop being implicit.

Boolean State Hell#

Complex UI often leads to multiple boolean flags:

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
}

Boolean states like these make impossible combinations possible: isSubmitting and isSuccess can both be true. Transitions stay unclear too, since nothing in the code says how validating becomes submitting, and testing turns into a combinatorial explosion.

Discriminated Unions as State Pattern#

TypeScript discriminated unions prevent impossible states:

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>
        </>
      );
  }
}

Discriminated unions rule out impossible states outright (the type can’t be validating and success simultaneously), let TypeScript enforce that every state gets handled, keep transitions explicit, and attach only the data relevant to each state (progress only when submitting).

Explicit State Machines with XState#

XState provides declarative state machine definition:

import { setup, fromPromise, assign } from 'xstate';

// XState v5 declares actor logic and types up front with setup()
const formMachine = setup({
  types: {
    context: {} as {
      formData: any;
      error: any;
    },
    events: {} as
      | { type: 'SUBMIT' }
      | { type: 'RETRY' }
      | { type: 'CANCEL' }
  },
  actors: {
    validateForm: fromPromise(async () => {
      // Validation logic
      await new Promise(resolve => setTimeout(resolve, 1000));
    }),
    submitForm: fromPromise(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: assign({ error: ({ event }) => event.error })
        }
      }
    },
    submitting: {
      invoke: {
        src: 'submitForm',
        onDone: {
          target: 'success',
          actions: assign({ formData: ({ event }) => event.output })
        },
        onError: {
          target: 'error',
          actions: assign({ error: ({ event }) => event.error })
        }
      }
    },
    success: {
      type: 'final'
    },
    error: {
      on: {
        RETRY: 'validating',
        CANCEL: 'idle'
      }
    }
  }
});

// Use in React
import { useMachine } from '@xstate/react';

function Form() {
  const [state, send] = useMachine(formMachine);

  return (
    <form onSubmit={(e) => {
      e.preventDefault();
      send({ type: '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({ type: 'RETRY' })}>Retry</button>
          <button onClick={() => send({ type: 'CANCEL' })}>Cancel</button>
        </div>
      )}
    </form>
  );
}

Visualizing State Machines#

SUBMIT

Validation Success

Validation Failed

Submit Success

Submit Failed

RETRY

CANCEL

Idle

Validating

Submitting

Error

Success

Compared to the discriminated union above, the explicit machine adds:

  • 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

Wizard Steps as Machine States#

Multi-step forms benefit from explicit state machines:

import { setup, fromPromise, 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: {
    // Actor logic receives its data through `input`
    submitWizard: fromPromise(async ({ input }: { input: WizardContext }) => {
      const response = await fetch('/api/wizard', {
        method: 'POST',
        body: JSON.stringify({
          step1: input.step1Data,
          step2: input.step2Data,
          step3: input.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',
        input: ({ context }) => context,
        onDone: 'success',
        onError: {
          target: 'step3',
          actions: assign({
            error: ({ event }) => (event.error as Error).message
          })
        }
      }
    },
    success: {
      type: 'final'
    }
  }
});

// React component
function Wizard() {
  const [state, send] = useMachine(wizardMachine);

  const handleNext = (data: any) => {
    send({ type: 'NEXT', data });
  };

  const handlePrevious = () => {
    send({ type: 'PREVIOUS' });
  };

  const handleSubmit = () => {
    send({ type: '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>
  );
}

Skip state machines for simple forms with two or three states, when the team is unfamiliar with them, or when a discriminated union already rules out the same impossible combinations. Reach for them once a workflow accumulates enough states that transitions carry business rules worth reviewing visually, or once testing those transitions directly becomes critical.

Decoupling Components with a Mediator#

Mediator pattern centralizes communication so objects stop referencing each other directly; event buses, React Context, and state management libraries are the shapes it takes in practice.

Tight Coupling Without a Mediator#

Without mediator, components reference each other directly:

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

An Event Bus for Decoupling#

An event bus decouples components:

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 mediator
class 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);
  }
}

// Usage
const eventBus = new EventBus();
const userList = new UserList(eventBus);
const userDetails = new UserDetails(eventBus);
const userStats = new UserStats(eventBus);

// Components don't know about each other
userList.selectUser({ id: '1', name: 'John' });

Sharing State Through React Context#

React Context provides mediator pattern for component trees:

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 context
function 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 structure
function App() {
  return (
    <AppProvider>
      <UserList />
      <UserDetails />
      <UserStats />
    </AppProvider>
  );
}

Centralizing Communication in Redux#

Redux centralizes all application communication through actions:

// All components communicate via store
function 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} />;
}

Direct communication stays simplest when only two or three components are involved: skip Mediator if parent-child props already work, since the added indirection only makes debugging harder without buying anything back. Past that scale, the right tool depends on reach. An event bus covers same-module pub/sub in a prototype with no global-state requirement, and React Context covers a single subtree once prop drilling for theme, auth, or locale gets painful. Redux earns its weight in large applications where many components read the same state, DevTools and time-travel debugging matter, and the team already knows the middleware ecosystem.

Language Feature or Library#

The language-first default holds for most application code, where nothing forces a dependency and nobody joining the codebase needs a library explained first. Override it when the escalation buys a capability you would otherwise hand-roll badly.

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.

Progress 3/4 posts completed

Related posts