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:
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:
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:
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:
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:
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.
Modern Alternative: Zustand
Zustand simplifies Observer pattern with hooks and minimal boilerplate:
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:
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:
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:
React: Strategy via Props
In React, strategies often manifest as render props or component props:
Real Scenario: Form Validation
Form validation benefits from composable strategy pattern:
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:
Redux Actions as Commands
Redux actions are command objects. They encapsulate state changes as serializable data:
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:
Redux Thunk: Async Commands
Redux Thunk enables async commands with side effects:
Real Scenario: Rich Text Editor with Undo/Redo
A rich text editor demonstrates Command pattern's undo/redo value:
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:
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:
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:
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:
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:
Event Bus as Mediator
An event bus decouples components:
React Context as Mediator
React Context provides mediator pattern for component trees:
Redux as Global Mediator
Redux centralizes all application communication through actions:
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
References
- typescriptlang.org - TypeScript Handbook and language reference.
- github.com - TypeScript project wiki (FAQ and design notes).
- developer.mozilla.org - MDN Web Docs (web platform reference).
- semver.org - Semantic Versioning specification.
- ietf.org - IETF RFC index (protocol standards).
- arxiv.org - arXiv software engineering recent submissions (research context).
- cheatsheetseries.owasp.org - OWASP Cheat Sheet Series (applied security guidance).
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.