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.
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:
- 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
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#
| Tool | Reach for it when |
|---|---|
| RxJS | Complex async coordination across multiple streams, advanced operators (debounce, throttle, retry), WebSocket or SSE connections, event stream processing, or backpressure handling is critical |
| Redux | Large 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 |
| Zustand | Medium-sized application that wants simplicity over features, no need for Redux DevTools, hooks preferred over connect HOC, TypeScript support a priority |
| Plain EventEmitter | Node.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#
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
idletosuccess - 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.
Related Posts#
- Creational Patterns in Modern TypeScript - Part 1 of this series
- Structural Patterns Meet Component Composition - Part 2 of this series
References#
- Behavioral Design Patterns (opens in new tab) - refactoring.guru catalog of Observer, Strategy, Command, State, and Mediator patterns
- RxJS Documentation (opens in new tab) - Official reactive programming library for JavaScript
- ReactiveX - Observable (opens in new tab) - Cross-language Observable specification and operator reference
- XState Documentation (opens in new tab) - XState v5 reference for
setup(), actor logic, andassignused in the state machine examples - Redux Toolkit (opens in new tab) - Official Redux toolset covering
configureStore,createSlice, and middleware setup - Design Patterns: Elements of Reusable Object-Oriented Software (opens in new tab) - Original GoF book defining the 23 classic patterns
- The Catalog of Design Patterns (opens in new tab) - refactoring.guru full pattern catalog with TypeScript examples
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
Match architecture weight to each runtime's init-amortization: lean handlers on single-purpose Lambda, more on a Lambdalith, full OOP/DI only on long-lived runtimes.
architecture · lambda · serverless +3
A practical guide to learning Effect incrementally and integrating it with AWS Lambda, with real code examples, common pitfalls, and production patterns.
typescript · functional-programming · lambda +4
A small, complete URL shortener on AWS Lambda and DynamoDB with Effect and SST v4, showing schema-at-the-boundary, layers, and tagged-error mapping.
functional-programming · lambda · typescript +2
A pragmatic guide for designers working with async backends: three interaction patterns, when to use each, and four anti-patterns to push back against.
event-driven · state-management · design-patterns +2
How SOLID principles apply to modern JavaScript: practical examples with TypeScript, React hooks, and functional patterns, plus when they're overkill.
typescript · javascript · react +4