Skip to content

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:

typescript
// HOC wrapper hellexport default withAuth(  withTheme(    withAnalytics(      withErrorBoundary(        Component      )    )  ));
// Render props callback hell<DataFetcher url="/api/user">  {(user, userLoading) => (    <PermissionsChecker user={user}>      {(permissions, permLoading) => (        <ThemeProvider>          {(theme) => (            <Component              user={user}              permissions={permissions}              theme={theme}              loading={userLoading || permLoading}            />          )}        </ThemeProvider>      )}    </PermissionsChecker>  )}</DataFetcher>

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:

typescript
// Custom hook for data fetchingfunction useUser(userId: string) {  const [user, setUser] = useState<User | null>(null);  const [loading, setLoading] = useState(true);  const [error, setError] = useState<Error | null>(null);
  useEffect(() => {    let cancelled = false;
    async function fetchData() {      try {        const data = await fetch(`/api/users/${userId}`).then(r => r.json());        if (!cancelled) setUser(data);      } catch (err) {        if (!cancelled) setError(err as Error);      } finally {        if (!cancelled) setLoading(false);      }    }
    fetchData();
    return () => {      cancelled = true;    };  }, [userId]);
  // Note: Modern alternative using AbortController  // const controller = new AbortController();  // fetch(url, { signal: controller.signal })  // return () => controller.abort();
  return { user, loading, error };}
// Usage is cleanfunction Profile({ userId }: { userId: string }) {  const { user, loading, error } = useUser(userId);  const { theme } = useTheme();  const { permissions } = usePermissions(user?.id);
  if (loading) return <Spinner />;  if (error) return <ErrorMessage error={error} />;
  return (    <div className={theme}>      <UserCard user={user!} permissions={permissions} />    </div>  );}

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:

typescript
function useForm<T extends Record<string, any>>(initialValues: T) {  const [values, setValues] = useState<T>(initialValues);  const [errors, setErrors] = useState<Partial<Record<keyof T, string>>>({});  const [touched, setTouched] = useState<Partial<Record<keyof T, boolean>>>({});  const [isSubmitting, setIsSubmitting] = useState(false);
  const handleChange = useCallback((field: keyof T, value: any) => {    setValues(prev => ({ ...prev, [field]: value }));    // Clear error when user starts typing    if (errors[field]) {      setErrors(prev => {        const next = { ...prev };        delete next[field];        return next;      });    }  }, [errors]);
  const handleBlur = useCallback((field: keyof T) => {    setTouched(prev => ({ ...prev, [field]: true }));  }, []);
  const setError = useCallback((field: keyof T, error: string) => {    setErrors(prev => ({ ...prev, [field]: error }));  }, []);
  const reset = useCallback(() => {    setValues(initialValues);    setErrors({});    setTouched({});    setIsSubmitting(false);  }, [initialValues]);
  return {    values,    errors,    touched,    isSubmitting,    setIsSubmitting,    handleChange,    handleBlur,    setError,    reset  };}
// Compose with validation hookfunction useValidatedForm<T extends Record<string, any>>(  initialValues: T,  validationSchema: z.ZodSchema<T>) {  const form = useForm(initialValues);
  const validate = useCallback(async () => {    try {      await validationSchema.parseAsync(form.values);      return true;    } catch (error) {      if (error instanceof z.ZodError) {        error.errors.forEach(err => {          const field = err.path[0] as keyof T;          form.setError(field, err.message);        });      }      return false;    }  }, [form, validationSchema]);
  return { ...form, validate };}
// Usagefunction RegistrationForm() {  const form = useValidatedForm(    { email: '', password: '' },    registrationSchema  );
  const handleSubmit = async (e: FormEvent) => {    e.preventDefault();
    if (!await form.validate()) return;
    form.setIsSubmitting(true);    try {      await registerUser(form.values);    } finally {      form.setIsSubmitting(false);    }  };
  return (    <form onSubmit={handleSubmit}>      <input        value={form.values.email}        onChange={(e) => form.handleChange('email', e.target.value)}        onBlur={() => form.handleBlur('email')}      />      {form.touched.email && form.errors.email && (        <span className="error">{form.errors.email}</span>      )}      {/* ... */}    </form>  );}

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:

  1. Only call hooks at the top level: No hooks in conditionals, loops, or nested functions
  2. Only call hooks from React functions: Functional components or other hooks
  3. Dependencies must be exhaustive: useEffect and useCallback dependencies must include all referenced values

These constraints enable React to preserve hook state across re-renders. The implementation relies on call order:

typescript
// React internally maintains an array of hook values// First render:const [name, setName] = useState('');  // hooks[0]const [age, setAge] = useState(0);  // hooks[1]const [email, setEmail] = useState('');  // hooks[2]
// Second render - same order required:const [name, setName] = useState('');  // hooks[0] - same positionconst [age, setAge] = useState(0);  // hooks[1] - same positionconst [email, setEmail] = useState('');  // hooks[2] - same position
// Breaking the rules causes state corruption:if (showAge) {  const [age, setAge] = useState(0);  // Conditional hook - order changes!}

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:

typescript
// Props explosion - hard to extendinterface SelectProps {  options: Array<{ value: string; label: string; disabled?: boolean }>;  value: string | null;  onChange: (value: string) => void;  placeholder?: string;  disabled?: boolean;  searchable?: boolean;  clearable?: boolean;  renderOption?: (option: Option) => ReactNode;  renderValue?: (value: string) => ReactNode;  filterOptions?: (options: Option[], search: string) => Option[];  onOpen?: () => void;  onClose?: () => void;  // ... 20 more props}
<Select  options={options}  value={selected}  onChange={setSelected}  searchable  clearable  renderOption={(opt) => <CustomOption {...opt} />}/>

Every new feature adds props. The component API becomes unwieldy.

Compound Components Pattern

Compound components distribute props across multiple components that share implicit state:

typescript
// Context holds shared stateinterface SelectContextValue {  value: string | null;  onChange: (value: string) => void;  isOpen: boolean;  setIsOpen: (open: boolean) => void;}
const SelectContext = createContext<SelectContextValue | null>(null);
function useSelectContext() {  const context = useContext(SelectContext);  if (!context) {    throw new Error('Select.* components must be used within <Select>');  }  return context;}
// Root component manages stateinterface SelectProps {  value: string | null;  onChange: (value: string) => void;  children: ReactNode;}
function Select({ value, onChange, children }: SelectProps) {  const [isOpen, setIsOpen] = useState(false);
  return (    <SelectContext.Provider value={{ value, onChange, isOpen, setIsOpen }}>      <div className="select-container">        {children}      </div>    </SelectContext.Provider>  );}
// Sub-components access contextfunction SelectTrigger({ children }: { children: ReactNode }) {  const { isOpen, setIsOpen, value } = useSelectContext();
  return (    <button      className="select-trigger"      onClick={() => setIsOpen(!isOpen)}    >      {children || value || 'Select...'}    </button>  );}
function SelectOptions({ children }: { children: ReactNode }) {  const { isOpen } = useSelectContext();
  if (!isOpen) return null;
  return (    <div className="select-options">      {children}    </div>  );}
interface SelectOptionProps {  value: string;  children: ReactNode;}
function SelectOption({ value, children }: SelectOptionProps) {  const { value: selectedValue, onChange, setIsOpen } = useSelectContext();
  return (    <div      className={selectedValue === value ? 'selected' : ''}      onClick={() => {        onChange(value);        setIsOpen(false);      }}    >      {children}    </div>  );}
// Namespace sub-componentsSelect.Trigger = SelectTrigger;Select.Options = SelectOptions;Select.Option = SelectOption;
// Flexible usage<Select value={selected} onChange={setSelected}>  <Select.Trigger>    <span>Choose a framework</span>  </Select.Trigger>  <Select.Options>    <Select.Option value="react">React</Select.Option>    <Select.Option value="vue">Vue</Select.Option>    <Select.Option value="svelte">Svelte</Select.Option>  </Select.Options></Select>
// Or customize rendering<Select value={selected} onChange={setSelected}>  <Select.Trigger>    {selected ? (      <div className="custom-display">        <Icon name={selected} />        <span>{selected}</span>      </div>    ) : (      <span>Pick one</span>    )}  </Select.Trigger>  <Select.Options>    {frameworks.map(fw => (      <Select.Option key={fw.id} value={fw.id}>        <div className="custom-option">          <Icon name={fw.icon} />          <div>            <div className="name">{fw.name}</div>            <div className="description">{fw.description}</div>          </div>        </div>      </Select.Option>    ))}  </Select.Options></Select>

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:

typescript
<Tabs.Root value={tab} onValueChange={setTab}>  <Tabs.List>    <Tabs.Trigger value="account">Account</Tabs.Trigger>    <Tabs.Trigger value="password">Password</Tabs.Trigger>  </Tabs.List>  <Tabs.Content value="account">    <AccountSettings />  </Tabs.Content>  <Tabs.Content value="password">    <PasswordSettings />  </Tabs.Content></Tabs.Root>

Headless UI follows the same pattern for accessible components:

typescript
<Disclosure>  <Disclosure.Button>    What is your refund policy?  </Disclosure.Button>  <Disclosure.Panel>    Refunds are available within 30 days.  </Disclosure.Panel></Disclosure>

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:

typescript
// UserService.tsasync function getUser(id: string) {  return prisma.user.findUnique({ where: { id } });}
// OrderService.tsasync function getOrders(userId: string) {  return prisma.order.findMany({ where: { userId } });}
// ProductService.tsasync function getProducts() {  return prisma.product.findMany();}

This approach has problems:

  1. Switching ORMs requires changes throughout codebase
  2. Testing requires mocking Prisma directly
  3. No clear separation between business logic and data access
  4. Hard to implement caching or logging uniformly

Repository Pattern Implementation

Repository pattern centralizes data access behind interfaces:

typescript
// Define interface (abstraction)interface UserRepository {  findById(id: string): Promise<User | null>;  findByEmail(email: string): Promise<User | null>;  findAll(options?: FindOptions): Promise<User[]>;  save(user: User): Promise<User>;  delete(id: string): Promise<void>;}
// Prisma implementationclass PrismaUserRepository implements UserRepository {  constructor(private prisma: PrismaClient) {}
  async findById(id: string): Promise<User | null> {    return this.prisma.user.findUnique({      where: { id },      include: { profile: true }    });  }
  async findByEmail(email: string): Promise<User | null> {    return this.prisma.user.findUnique({      where: { email }    });  }
  async findAll(options?: FindOptions): Promise<User[]> {    return this.prisma.user.findMany({      skip: options?.offset,      take: options?.limit,      orderBy: { createdAt: 'desc' }    });  }
  async save(user: User): Promise<User> {    return this.prisma.user.upsert({      where: { id: user.id },      create: user,      update: user    });  }
  async delete(id: string): Promise<void> {    await this.prisma.user.delete({ where: { id } });  }}
// In-memory implementation for testingclass InMemoryUserRepository implements UserRepository {  private users = new Map<string, User>();
  async findById(id: string): Promise<User | null> {    return this.users.get(id) || null;  }
  async findByEmail(email: string): Promise<User | null> {    return Array.from(this.users.values())      .find(u => u.email === email) || null;  }
  async findAll(options?: FindOptions): Promise<User[]> {    let users = Array.from(this.users.values());
    if (options?.offset) {      users = users.slice(options.offset);    }
    if (options?.limit) {      users = users.slice(0, options.limit);    }
    return users;  }
  async save(user: User): Promise<User> {    this.users.set(user.id, user);    return user;  }
  async delete(id: string): Promise<void> {    this.users.delete(id);  }}
// Business logic depends on interface, not implementationclass UserService {  constructor(private userRepository: UserRepository) {}
  async registerUser(email: string, password: string): Promise<User> {    const existing = await this.userRepository.findByEmail(email);    if (existing) {      throw new Error('Email already registered');    }
    const user: User = {      id: uuid(),      email,      password: await hashPassword(password),      createdAt: new Date()    };
    return this.userRepository.save(user);  }
  async getUser(id: string): Promise<User> {    const user = await this.userRepository.findById(id);    if (!user) {      throw new Error('User not found');    }    return user;  }}
// Production - use Prismaconst userRepository = new PrismaUserRepository(prisma);const userService = new UserService(userRepository);
// Testing - use in-memoryconst testRepository = new InMemoryUserRepository();const testService = new UserService(testRepository);

Benefits and Trade-offs

Benefits:

  1. Testability: Swap implementations without mocking ORM internals
  2. Flexibility: Switch from Prisma to TypeORM by implementing interface
  3. Centralized logic: Query patterns, caching, logging in one place
  4. Clear boundaries: Business logic doesn't know about database details

Trade-offs:

  1. More code: Each entity needs repository interface and implementation
  2. Learning curve: Team must understand repository pattern
  3. Abstraction cost: Can't access ORM-specific features easily
  4. 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:

typescript
function App() {  const theme = useTheme();  const user = useUser();  const config = useConfig();
  return (    <Dashboard theme={theme} user={user} config={config} />  );}
function Dashboard({ theme, user, config }: DashboardProps) {  return (    <Layout theme={theme}>      <Sidebar user={user} config={config} />    </Layout>  );}
function Sidebar({ user, config }: SidebarProps) {  return (    <Navigation user={user} config={config} />  );}
function Navigation({ user, config }: NavigationProps) {  // Finally use the props  return <div>{user.name} - {config.appName}</div>;}

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:

typescript
// Create context with typeinterface ThemeContextValue {  theme: 'light' | 'dark';  toggleTheme: () => void;}
const ThemeContext = createContext<ThemeContextValue | null>(null);
// Custom hook for consuming contextfunction useTheme() {  const context = useContext(ThemeContext);  if (!context) {    throw new Error('useTheme must be used within ThemeProvider');  }  return context;}
// Provider componentfunction ThemeProvider({ children }: { children: ReactNode }) {  const [theme, setTheme] = useState<'light' | 'dark'>('light');
  const toggleTheme = useCallback(() => {    setTheme(prev => prev === 'light' ? 'dark' : 'light');  }, []);
  const value = useMemo(    () => ({ theme, toggleTheme }),    [theme, toggleTheme]  );
  return (    <ThemeContext.Provider value={value}>      {children}    </ThemeContext.Provider>  );}
// Usage - no prop drillingfunction App() {  return (    <ThemeProvider>      <Dashboard />    </ThemeProvider>  );}
function Dashboard() {  return (    <Layout>      <Sidebar />    </Layout>  );}
function Sidebar() {  return <Navigation />;}
function Navigation() {  const { theme, toggleTheme } = useTheme();
  return (    <div className={theme}>      <button onClick={toggleTheme}>        Toggle theme      </button>    </div>  );}

Components at any depth access theme without intermediate components knowing about it.

Combining Multiple Providers

Real applications have multiple contexts:

typescript
function App() {  return (    <ThemeProvider>      <AuthProvider>        <ConfigProvider>          <I18nProvider>            <Router />          </I18nProvider>        </ConfigProvider>      </AuthProvider>    </ThemeProvider>  );}

This nesting works but gets verbose. A common pattern combines providers:

typescript
interface AppProvidersProps {  children: ReactNode;}
const providers = [  ThemeProvider,  AuthProvider,  ConfigProvider,  I18nProvider];
function AppProviders({ children }: AppProvidersProps) {  return providers.reduceRight(    (acc, Provider) => <Provider>{acc}</Provider>,    children  );}
// Cleaner usagefunction App() {  return (    <AppProviders>      <Router />    </AppProviders>  );}

Performance Considerations

Context re-renders all consumers when value changes. Optimize with memoization:

typescript
function ExpensiveProvider({ children }: { children: ReactNode }) {  const [state, setState] = useState(initialState);
  // Without memoization - new object every render  // All consumers re-render even if state unchanged  const badValue = { state, setState };
  // With memoization - only changes when dependencies change  const goodValue = useMemo(    () => ({ state, setState }),    [state]  // Only recreate when state changes  );
  return (    <ExpensiveContext.Provider value={goodValue}>      {children}    </ExpensiveContext.Provider>  );}

For frequently updating values, split contexts:

typescript
// Separate fast-changing and slow-changing valuesconst UserContext = createContext<User>(null!);const UserActionsContext = createContext<UserActions>(null!);
function UserProvider({ children }: { children: ReactNode }) {  const [user, setUser] = useState<User>(null!);
  // Actions rarely change  const actions = useMemo(    () => ({      updateProfile: (data: ProfileData) => {        setUser(prev => ({ ...prev, ...data }));      },      logout: () => setUser(null!)    }),    []  );
  return (    <UserContext.Provider value={user}>      <UserActionsContext.Provider value={actions}>        {children}      </UserActionsContext.Provider>    </UserContext.Provider>  );}
// Components consuming only actions don't re-render when user changesfunction LogoutButton() {  const { logout } = useContext(UserActionsContext);  // Doesn't re-render when user profile updates  return <button onClick={logout}>Logout</button>;}

Module Pattern: ES Modules as Design Pattern

Pre-ES6: IIFE and Revealing Module

Before ES modules, JavaScript used IIFEs for encapsulation:

typescript
// Old IIFE patternconst logger = (function() {  // Private variables  let logLevel = 'info';  const levels = ['debug', 'info', 'warn', 'error'];
  // Private function  function shouldLog(level: string): boolean {    return levels.indexOf(level) >= levels.indexOf(logLevel);  }
  // Public API  return {    setLogLevel(level: string) {      logLevel = level;    },    log(level: string, message: string) {      if (shouldLog(level)) {        console.log(`[${level}] ${message}`);      }    }  };})();
logger.log('info', 'Application started');// Can't access logLevel or shouldLog directly

This worked but required boilerplate and lacked static analysis.

Modern: ES Modules

ES modules provide natural encapsulation:

typescript
// logger.ts - private by defaultlet logLevel: 'debug' | 'info' | 'warn' | 'error' = 'info';const levels = ['debug', 'info', 'warn', 'error'] as const;
// Private function (not exported)function shouldLog(level: typeof logLevel): boolean {  return levels.indexOf(level) >= levels.indexOf(logLevel);}
// Public API (exported)export function setLogLevel(level: typeof logLevel) {  logLevel = level;}
export function log(level: typeof logLevel, message: string) {  if (shouldLog(level)) {    console.log(`[${level}] ${message}`);  }}
// Other filesimport { log, setLogLevel } from './logger';log('info', 'Application started');// Can't access logLevel or shouldLog

Benefits over IIFE:

  1. Static analysis: TypeScript and bundlers understand imports
  2. Tree shaking: Unused exports eliminated at build time
  3. Better tooling: Auto-imports, go-to-definition work correctly
  4. Type safety: TypeScript enforces module boundaries
  5. No boilerplate: No wrapping function needed

Module-Scoped Singletons

ES modules naturally implement singleton pattern:

typescript
// database.tsclass DatabaseConnection {  private connected = false;
  connect() {    if (!this.connected) {      console.log('Connecting to database...');      this.connected = true;    }  }
  query(sql: string) {    if (!this.connected) {      throw new Error('Not connected');    }    // Execute query  }}
// Export single instanceexport const db = new DatabaseConnection();
// Other files always get the same instanceimport { db } from './database';db.connect();db.query('SELECT * FROM users');

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:

typescript
// config.tsinterface Config {  apiUrl: string;  apiKey: string;  timeout: number;}
let config: Config | null = null;
function loadConfig(): Config {  // Load from environment or file  return {    apiUrl: process.env.API_URL || 'http://localhost:3000',    apiKey: process.env.API_KEY || '',    timeout: 30000  };}
// Initialize on module loadconfig = loadConfig();
export function getConfig(): Config {  if (!config) {    throw new Error('Config not initialized');  }  return config;}
export function reloadConfig(): void {  config = loadConfig();}
// First import triggers initializationimport { getConfig } from './config';const config = getConfig();  // Already loaded

Container/Presenter: Separation Reconsidered

The Classic Pattern

Container/Presenter (also called Smart/Dumb or Stateful/Stateless) separates data fetching from presentation:

typescript
// Presenter - pure presentationinterface UserCardProps {  user: User;  onFollow: () => void;  onMessage: () => void;}
function UserCard({ user, onFollow, onMessage }: UserCardProps) {  return (    <div className="user-card">      <img src={user.avatar} alt={user.name} />      <h3>{user.name}</h3>      <p>{user.bio}</p>      <div className="actions">        <button onClick={onFollow}>Follow</button>        <button onClick={onMessage}>Message</button>      </div>    </div>  );}
// Container - data and logicinterface UserCardContainerProps {  userId: string;}
function UserCardContainer({ userId }: UserCardContainerProps) {  const { user, loading, error } = useUser(userId);  const { followUser } = useFollowUser();  const { startConversation } = useMessaging();
  if (loading) return <Spinner />;  if (error) return <ErrorMessage error={error} />;
  return (    <UserCard      user={user!}      onFollow={() => followUser(userId)}      onMessage={() => startConversation(userId)}    />  );}

Benefits of separation:

  1. Testability: UserCard easy to test with mock props
  2. Reusability: UserCard works with any user object
  3. Storybook: Show UserCard in all states without data fetching

Modern Alternative: Co-location

Hooks enable co-locating data and presentation without sacrifice:

typescript
function UserCard({ userId }: { userId: string }) {  const { user, loading, error } = useUser(userId);  const { followUser } = useFollowUser();  const { startConversation } = useMessaging();
  if (loading) return <Spinner />;  if (error) return <ErrorMessage error={error} />;
  return (    <div className="user-card">      <img src={user.avatar} alt={user.name} />      <h3>{user.name}</h3>      <p>{user.bio}</p>      <div className="actions">        <button onClick={() => followUser(userId)}>Follow</button>        <button onClick={() => startConversation(userId)}>Message</button>      </div>    </div>  );}

Testing remains straightforward by mocking hooks:

typescript
// Mock hooks for testingjest.mock('./hooks/useUser', () => ({  useUser: () => ({    user: mockUser,    loading: false,    error: null  })}));
// Test UserCard directlyrender(<UserCard userId="123" />);expect(screen.getByText(mockUser.name)).toBeInTheDocument();

Storybook works by mocking hooks at story level:

typescript
// UserCard.stories.tsxexport const Default: Story = {  decorators: [    (Story) => {      // Mock hooks for Storybook      jest.spyOn(require('./hooks/useUser'), 'useUser')        .mockReturnValue({          user: mockUser,          loading: false,          error: null        });
      return <Story />;    }  ]};

When Container/Presenter Still Makes Sense

Strict separation remains valuable when:

  1. Presentation components shared across apps: Design system components
  2. Server-side rendering: Different data fetching patterns per platform
  3. Complex prop interfaces: Clear contract between data and display
  4. 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:

typescript
// Hook approach - consumer can't control loading UIfunction useData<T>(url: string) {  const [data, setData] = useState<T | null>(null);  const [loading, setLoading] = useState(true);
  useEffect(() => {    fetch(url)      .then(r => r.json())      .then(setData)      .finally(() => setLoading(false));  }, [url]);
  if (loading) return <DefaultSpinner />;  // Fixed loading UI  return data;  // Consumer can't customize}

Render Props Pattern

Pass rendering control to consumers:

typescript
interface DataFetcherProps<T> {  url: string;  children: (state: {    data: T | null;    loading: boolean;    error: Error | null;    refetch: () => void;  }) => ReactNode;}
function DataFetcher<T>({ url, children }: DataFetcherProps<T>) {  const [data, setData] = useState<T | null>(null);  const [loading, setLoading] = useState(true);  const [error, setError] = useState<Error | null>(null);
  const fetchData = useCallback(() => {    setLoading(true);    setError(null);
    fetch(url)      .then(r => r.json())      .then(setData)      .catch(setError)      .finally(() => setLoading(false));  }, [url]);
  useEffect(() => {    fetchData();  }, [fetchData]);
  return <>{children({ data, loading, error, refetch: fetchData })}</>;}
// Flexible usage - consumer controls everything<DataFetcher<User> url="/api/user">  {({ data, loading, error, refetch }) => (    <div>      {loading && <CustomSpinner message="Loading user..." />}      {error && (        <div className="error">          <p>{error.message}</p>          <button onClick={refetch}>Retry</button>        </div>      )}      {data && (        <div>          <h1>{data.name}</h1>          <button onClick={refetch}>Refresh</button>        </div>      )}    </div>  )}</DataFetcher>
// Different consumer, different rendering<DataFetcher<Product[]> url="/api/products">  {({ data, loading }) => (    loading ? <SkeletonGrid /> : <ProductGrid products={data!} />  )}</DataFetcher>

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:

typescript
// Render prop gives access to router state<Route path="/users/:id">  {({ match }) => (    match ? (      <UserProfile userId={match.params.id} />    ) : (      <UserList />    )  )}</Route>

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:

  1. Identify the problem: What specific problem needs solving?
  2. Check language features: Does the language already solve this?
  3. Consider frameworks: Does React/Next.js/etc provide a solution?
  4. Evaluate trade-offs: Does the pattern add value or just complexity?
  5. Think about testing: Does this make the code easier or harder to test?
  6. 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:

  1. Solve real problems: Don't pattern for pattern's sake
  2. Improve maintainability: Code should be easier to understand and change
  3. Enable testing: Good patterns make code more testable, not less
  4. Reduce coupling: Components should depend on abstractions, not implementations
  5. Clear intent: Pattern usage should communicate design decisions
  6. Context-appropriate: What works for libraries differs from applications
  7. 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

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.

Progress4/4 posts completed

Related Posts