2025-11-06
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:
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 });
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:
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:
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');
What makes RxJS powerful:
- Composable operators: Chain transformations declaratively
- Error handling: Errors propagate through the stream
- Completion signals: Know when stream finishes
- Backpressure: Handle fast producers with operators like
throttle - Cancellation: Unsubscribe stops execution
- Hot vs Cold: Control when execution starts
Real-World Scenario: WebSocket Dashboard
A real-time stock price dashboard demonstrates RxJS’s Observer evolution:
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();
}
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:
takeUntilensures no memory leaks
Redux: Observer Pattern for State
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 as observers:
Note: Modern React-Redux prefers hooks (
useSelector/useDispatch) over theconnectHOC. 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);
Modern Alternative: Zustand
Zustand simplifies Observer pattern with hooks and minimal 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>
);
}
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:
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('[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:
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);
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:
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('[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:
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>
Real Scenario: Form Validation
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>
);
}
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:
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 */));
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:
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: Async Commands
Redux Thunk enables async commands with 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'));
Real Scenario: Rich Text Editor with Undo/Redo
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);
}
}
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:
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 (
isSubmittingandisSuccessboth 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:
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
validatingandsuccesssimultaneously - Type-safe: TypeScript ensures all states handled
- Clear transitions: Explicit state changes
- Associated data: Each state has relevant data (
progressonly when submitting)
XState: Explicit State Machines
XState provides declarative state machine definition:
import { setup, createActor } from 'xstate';
// XState v5 uses setup() for type-safe machine definition
const 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 React
import { 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
idletosuccess - 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:
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 component
function 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:
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:
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' });
React Context as Mediator
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>
);
}
Redux as Global Mediator
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} />;
}
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
-
Observer evolved into Reactive Programming: RxJS, Redux, and React hooks represent Observer pattern’s evolution with better composition, error handling, and backpressure management.
-
Strategy pattern uses functions, not classes: JavaScript’s first-class functions make Strategy pattern simpler - pass functions as strategies instead of creating strategy classes.
-
Command powers Redux and undo/redo: Redux actions are Command pattern. The pattern remains valuable for undo/redo, action logging, and operation queuing.
-
State machines prevent impossible states: XState and discriminated unions implement State pattern to prevent boolean state hell and make transitions explicit.
-
Mediator reduces coupling: Event buses, React Context, and Redux all implement Mediator pattern at different scales - choose based on application size and communication complexity.
-
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.
-
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.
Related Posts
- Creational Patterns in Modern TypeScript - Part 1 of this series
- Structural Patterns Meet Component Composition - Part 2 of this series
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.
All posts in this series
Related posts
Learn how SOLID principles apply to modern JavaScript development. Practical examples with TypeScript, React hooks, and functional patterns - plus when to use them and when they're overkill.
Explore the architectural evolution from rule-based chatbots to autonomous AI agents. Learn ReAct, Plan-and-Execute, and multi-agent patterns with TypeScript implementations and practical migration strategies.
Exploring how Singleton, Factory, Builder, and Prototype patterns have evolved in TypeScript. Learn when ES modules replace singletons, when factory functions beat classes, and how TypeScript's type system changes the game.
Exploring modern patterns that emerged from JavaScript and TypeScript ecosystems - hooks, compound components, render props, and repository patterns that solve problems the GoF never encountered.
Exploring how Decorator, Adapter, Facade, Composite, and Proxy patterns evolved in React and TypeScript. Learn when HOCs give way to hooks, how adapters isolate third-party APIs, and when facades simplify complexity.