Design Patterns Beyond the Gang of Four
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.
Abstract
The Gang of Four documented patterns for C++ and Smalltalk in 1994. They couldn't have anticipated asynchronous programming, component composition, functional programming, or reactive data flow. JavaScript and TypeScript ecosystems evolved their own patterns to solve problems that didn't exist in 1994. This post catalogs modern patterns that emerged from actual web development needs: React Hooks for stateful logic sharing, Compound Components for flexible APIs, Repository Pattern for data access abstraction, and ES Modules as natural design patterns. These aren't adaptations of classic patterns - they're new solutions for new problems.
The Evolution of Pattern Thinking
Working with React codebases over the years taught me something important: the Gang of Four patterns solve specific problems in OOP languages with limited type systems. JavaScript and TypeScript have different constraints and capabilities. First-class functions, closures, ES modules, async/await, and JSX enable patterns that would be impossible or impractical in C++ or Java.
The patterns in this post aren't rebranded GoF patterns. They emerged organically from the React and TypeScript communities solving real problems:
- How do we share stateful logic without HOC wrapper hell?
- How do we build flexible component APIs without props explosion?
- How do we abstract data access for testability?
- How do we encapsulate module-level state?
Let's explore the patterns that modern codebases actually use.
Hooks Pattern: Stateful Logic Without Wrappers
The Problem Hooks Solved
Before hooks, sharing stateful logic in React required Higher-Order Components or Render Props. Both approaches created problems:
Debugging this is painful. React DevTools shows six levels of nesting before your actual component. Props collision becomes common when multiple HOCs add similarly-named props.
Custom Hooks: A New Pattern
Hooks extract reusable logic into functions that share React's state and lifecycle:
The difference is substantial. No wrapper nesting, clear data flow, and excellent TypeScript inference. Each hook's return type flows through automatically.
Composing Complex Logic
Hooks compose naturally. Here's a realistic example from working with form state management:
This pattern works because hooks compose through function composition. Each hook returns values that other hooks can consume. No inheritance hierarchies, no wrapper components.
Rules and Constraints
Hooks have specific rules enforced by ESLint:
- Only call hooks at the top level: No hooks in conditionals, loops, or nested functions
- Only call hooks from React functions: Functional components or other hooks
- Dependencies must be exhaustive:
useEffectanduseCallbackdependencies must include all referenced values
These constraints enable React to preserve hook state across re-renders. The implementation relies on call order:
The rules feel restrictive initially but enable powerful composition patterns.
Compound Components: Flexible APIs Through Context
The Problem: Props Explosion
Building reusable components often leads to props explosion. Consider a Select component:
Every new feature adds props. The component API becomes unwieldy.
Compound Components Pattern
Compound components distribute props across multiple components that share implicit state:
The compound pattern provides flexibility without props explosion. Consumers control rendering while the library manages state and behavior.
Real-World Examples
This pattern appears throughout the React ecosystem:
Radix UI uses compound components extensively:
Headless UI follows the same pattern for accessible components:
The pattern works well for component libraries where flexibility matters more than simplicity.
Repository Pattern: Abstracting Data Access
The Problem: Scattered Data Logic
Without abstraction, data access scatters throughout the application:
This approach has problems:
- Switching ORMs requires changes throughout codebase
- Testing requires mocking Prisma directly
- No clear separation between business logic and data access
- Hard to implement caching or logging uniformly
Repository Pattern Implementation
Repository pattern centralizes data access behind interfaces:
Benefits and Trade-offs
Benefits:
- Testability: Swap implementations without mocking ORM internals
- Flexibility: Switch from Prisma to TypeORM by implementing interface
- Centralized logic: Query patterns, caching, logging in one place
- Clear boundaries: Business logic doesn't know about database details
Trade-offs:
- More code: Each entity needs repository interface and implementation
- Learning curve: Team must understand repository pattern
- Abstraction cost: Can't access ORM-specific features easily
- Over-engineering risk: Simple CRUD apps might not need this
Working with teams on data-heavy applications taught me the repository pattern pays off when:
- Multiple data sources (Postgres + Redis + S3)
- Complex querying logic that shouldn't live in services
- High test coverage requirements
- Potential ORM migration in the future
For simple applications with straightforward data access, direct ORM usage is often cleaner.
Provider Pattern: Context-Based Dependency Injection
The Classic Problem: Prop Drilling
Passing props through multiple component layers gets tedious:
Three intermediate components don't use theme, user, or config - they just pass them down. This creates coupling and makes refactoring painful.
Provider Pattern with Context
React Context provides values deep in the component tree without prop drilling:
Components at any depth access theme without intermediate components knowing about it.
Combining Multiple Providers
Real applications have multiple contexts:
This nesting works but gets verbose. A common pattern combines providers:
Performance Considerations
Context re-renders all consumers when value changes. Optimize with memoization:
For frequently updating values, split contexts:
Module Pattern: ES Modules as Design Pattern
Pre-ES6: IIFE and Revealing Module
Before ES modules, JavaScript used IIFEs for encapsulation:
This worked but required boilerplate and lacked static analysis.
Modern: ES Modules
ES modules provide natural encapsulation:
Benefits over IIFE:
- Static analysis: TypeScript and bundlers understand imports
- Tree shaking: Unused exports eliminated at build time
- Better tooling: Auto-imports, go-to-definition work correctly
- Type safety: TypeScript enforces module boundaries
- No boilerplate: No wrapping function needed
Module-Scoped Singletons
ES modules naturally implement singleton pattern:
Module caching ensures db is the same instance everywhere. This is cleaner than classic singleton pattern with private constructor and static getInstance method.
Module Initialization
Modules execute once when first imported. Use this for initialization:
Container/Presenter: Separation Reconsidered
The Classic Pattern
Container/Presenter (also called Smart/Dumb or Stateful/Stateless) separates data fetching from presentation:
Benefits of separation:
- Testability: UserCard easy to test with mock props
- Reusability: UserCard works with any user object
- Storybook: Show UserCard in all states without data fetching
Modern Alternative: Co-location
Hooks enable co-locating data and presentation without sacrifice:
Testing remains straightforward by mocking hooks:
Storybook works by mocking hooks at story level:
When Container/Presenter Still Makes Sense
Strict separation remains valuable when:
- Presentation components shared across apps: Design system components
- Server-side rendering: Different data fetching patterns per platform
- Complex prop interfaces: Clear contract between data and display
- Team boundaries: Different teams own data vs UI
For most application code, co-location with hooks provides better developer experience without losing testability.
Render Props: Advanced Control Pattern
When Hooks Aren't Enough
Hooks work for most scenarios but fail when consumers need rendering control:
Render Props Pattern
Pass rendering control to consumers:
Render Props vs Hooks
Use hooks when:
- Consumers just need data
- Rendering is consistent across uses
- Logic composition matters more than rendering control
Use render props when:
- Consumers need full rendering control
- Loading/error states vary significantly
- Component manages complex UI state (modals, dropdowns)
Real example from React Router:
Modern React Router v6 switched to hooks (useParams, useNavigate) for most use cases, but render props remain for advanced scenarios.
Series Wrap-Up: Patterns Past and Present
We've covered four posts exploring design patterns from 1994 to 2025:
Post 1: Creational Patterns showed how ES modules, object spread, and TypeScript's type system replaced most creational patterns. Singleton became module exports, prototype became spread operators, factory became discriminated unions. Builder pattern remains valuable for complex configurations.
Post 2: Structural Patterns examined how React's composition model absorbed structural patterns. Decorator became hooks and HOCs, facade simplified complex APIs, composite naturally maps to component trees, adapter isolated external dependencies.
Post 3: Behavioral Patterns explored observer's evolution into reactive programming. RxJS, Redux, and hooks represent modern observer implementations with better error handling and cancellation. Strategy became functions, command powers undo/redo, state machines prevent invalid states.
Post 4: Modern Patterns cataloged patterns that emerged from JavaScript/TypeScript ecosystems. Hooks solved wrapper hell, compound components provided flexible APIs, repository abstracted data access, modules became natural singletons.
What Changed and Why
The Gang of Four worked in 1994 with C++ and Smalltalk. These languages had:
- Static class hierarchies (inheritance primary mechanism)
- Limited type inference
- No first-class functions
- No module systems
- No asynchronous primitives
- No component composition
Modern JavaScript and TypeScript have:
- First-class functions and closures
- Sophisticated type inference
- ES modules with tree shaking
- Async/await and Promises
- Component composition (React/Vue/Svelte)
- Reactive programming primitives
Different constraints lead to different patterns. The problems patterns solve remain relevant - the implementations evolved.
Pattern Selection Framework
When facing a design decision:
- Identify the problem: What specific problem needs solving?
- Check language features: Does the language already solve this?
- Consider frameworks: Does React/Next.js/etc provide a solution?
- Evaluate trade-offs: Does the pattern add value or just complexity?
- Think about testing: Does this make the code easier or harder to test?
- Consider team context: Will the team understand and maintain this?
Modern Pattern Checklist
Before implementing any pattern, ask:
- Does TypeScript's type system solve this? (Discriminated unions often eliminate factory pattern)
- Do hooks solve this? (Usually yes for stateful logic sharing)
- Does composition solve this? (React components compose naturally)
- Do modules solve this? (ES modules replace many creational patterns)
- Does this improve testability? (If not, reconsider)
- Will this be clear to teammates? (Clever patterns can harm maintainability)
Looking Forward
Patterns will continue evolving as languages and frameworks change:
React Server Components introduce new patterns for server/client composition. The boundary between server and client code creates new abstraction challenges. With React 19 (December 2024), RSC became stable and production-ready, bringing server-first patterns to mainstream development.
Signals and fine-grained reactivity (SolidJS, Vue 3, Preact Signals) represent different state management patterns than React's component re-rendering model. This approach is gaining adoption across multiple frameworks as an alternative to virtual DOM diffing.
Type-level programming in TypeScript enables encoding constraints at compile time that previously required runtime patterns.
Edge computing and distributed systems introduce patterns for dealing with network boundaries, caching, and eventual consistency.
The next generation of patterns will solve problems we're just beginning to encounter. Understanding classic patterns and their modern evolution prepares us to recognize emerging patterns and evaluate their trade-offs.
Key Principles Across All Patterns
Regardless of era or language, good patterns share characteristics:
- Solve real problems: Don't pattern for pattern's sake
- Improve maintainability: Code should be easier to understand and change
- Enable testing: Good patterns make code more testable, not less
- Reduce coupling: Components should depend on abstractions, not implementations
- Clear intent: Pattern usage should communicate design decisions
- Context-appropriate: What works for libraries differs from applications
- Team-aligned: Patterns should match team's skills and codebase conventions
Closing Thoughts
Working with patterns taught me they're tools, not rules. The Gang of Four patterns solved specific problems in specific contexts. Modern patterns solve different problems in different contexts. Understanding both helps recognize when each applies.
Don't cargo-cult patterns from books or blog posts. Understand the problem each pattern solves, evaluate whether that problem exists in your codebase, and choose the most appropriate solution - whether that's a classic pattern, a modern pattern, or no pattern at all.
The best code often uses patterns invisibly. Readers recognize the solution without seeing explicit pattern implementation. That's the goal: solve problems clearly and maintainably, using patterns when they help and skipping them when they don't.
Patterns are a vocabulary for discussing design, not a prescription for implementing it. Use them to communicate with your team, not to impress them.
References
- typescriptlang.org - TypeScript Handbook and language reference.
- github.com - TypeScript project wiki (FAQ and design notes).
- react.dev - React official documentation.
- 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.