Skip to content

Structural Patterns Meet Component Composition

Exploring how Decorator, Adapter, Facade, Composite, and Proxy patterns evolved in React and TypeScript. Learn when HOCs give way to hooks, how adapters isolate third-party APIs, and when facades simplify complexity.

Structural patterns organize relationships between objects and classes. The Gang of Four documented Decorator, Adapter, Facade, Composite, and Proxy in 1994 for C++ and Smalltalk. In modern TypeScript and React ecosystems, these patterns haven't disappeared - they've been absorbed into framework conventions, hooks, and type-safe wrappers.

This post examines how structural patterns manifest in React component composition, when higher-order components still matter, and how TypeScript's type system enhances classical implementations.

Decorator Pattern: Three Meanings in TypeScript

The term "decorator" means three different things in TypeScript ecosystems:

  1. Gang of Four Decorator Pattern: Adding behavior to objects dynamically
  2. React Higher-Order Components (HOCs): Enhancing components with additional functionality
  3. TypeScript Decorator Syntax: Stage 3 proposal for class/method decorators

Each solves different problems. Let's examine when each approach adds value.

React HOCs: The Classic Decorator Implementation

Higher-order components enhance components by wrapping them with additional functionality:

typescript
// Authentication HOCfunction withAuth<P extends object>(  Component: React.ComponentType<P>): React.FC<P> {  return (props: P) => {    const { user, loading } = useAuth();
    if (loading) {      return <div className="spinner">Loading...</div>;    }
    if (!user) {      return <Navigate to="/login" replace />;    }
    return <Component {...props} />;  };}
// Usageconst Dashboard = ({ data }: DashboardProps) => {  return <div>Welcome to dashboard</div>;};
export default withAuth(Dashboard);

This works, but HOCs create problems:

Wrapper Hell: Stacking multiple HOCs creates deeply nested component trees:

typescript
export default withAuth(  withTracking(    withErrorBoundary(      withLoading(        Dashboard      )    )  ));

Props Collision: Multiple HOCs might inject props with the same names, causing conflicts.

Ref Forwarding Complexity: Passing refs through HOC layers requires explicit forwarding. Note that React 19 deprecated forwardRef - refs can now be passed as standard props, simplifying this pattern.

Unclear Data Flow: Props injected by HOCs aren't visible in component signature.

Modern Alternative: Custom Hooks

Hooks provide the same functionality with cleaner composition:

typescript
function Dashboard({ data }: DashboardProps) {  // Each hook adds specific functionality  const { user, loading } = useAuth();  const tracking = usePageTracking('dashboard_view');  const errorBoundary = useErrorBoundary();
  if (loading) {    return <div className="spinner">Loading...</div>;  }
  if (!user) {    return <Navigate to="/login" replace />;  }
  return <div>Welcome to dashboard</div>;}

Hooks eliminate wrapper hell, make data flow explicit, and compose naturally. Each hook's functionality is clear from its return values.

When HOCs Still Make Sense

Despite hooks' advantages, HOCs remain valuable for:

Library Code Where Hooks Can't Be Used: Some contexts require wrapping components without modifying their internals.

Visual Wrappers: HOCs that add purely visual elements around components:

typescript
function withCard<P extends object>(  Component: React.ComponentType<P>): React.FC<P> {  return (props: P) => (    <div className="card">      <div className="card-body">        <Component {...props} />      </div>    </div>  );}

Legacy Class Component Integration: When migrating from class components, HOCs bridge the gap to modern hooks.

TypeScript Decorator Syntax

TypeScript decorators (Stage 3 proposal, supported in TypeScript 5.0+) enable declarative metadata and behavior modification:

typescript
// Method decorator for loggingfunction log(  target: any,  propertyKey: string,  descriptor: PropertyDescriptor) {  const originalMethod = descriptor.value;
  descriptor.value = async function(...args: any[]) {    console.log(`[${propertyKey}] Called with:`, args);    const start = Date.now();
    try {      const result = await originalMethod.apply(this, args);      const duration = Date.now() - start;      console.log(`[${propertyKey}] Completed in ${duration}ms`);      return result;    } catch (error) {      console.error(`[${propertyKey}] Failed:`, error);      throw error;    }  };
  return descriptor;}
class ApiClient {  @log  async fetchUser(id: string): Promise<User> {    const response = await fetch(`/api/users/${id}`);    return response.json();  }
  @log  async updateUser(id: string, data: Partial<User>): Promise<User> {    const response = await fetch(`/api/users/${id}`, {      method: 'PATCH',      body: JSON.stringify(data),    });    return response.json();  }}

TypeScript decorators work well for:

  • Logging and monitoring
  • Validation and authorization
  • Caching and memoization
  • Performance tracking

Real Scenario: Analytics Tracking

Consider adding analytics to multiple components. Let's compare approaches:

HOC Approach (verbose):

typescript
const DashboardWithTracking = withTracking(Dashboard, 'dashboard_view');const ProfileWithTracking = withTracking(Profile, 'profile_view');const SettingsWithTracking = withTracking(Settings, 'settings_view');

Hook Approach (cleaner):

typescript
function Dashboard() {  usePageTracking('dashboard_view');  // component logic}
function usePageTracking(pageName: string) {  useEffect(() => {    analytics.track('page_view', { page: pageName });
    return () => {      // Cleanup if needed    };  }, [pageName]);}

The hook approach integrates naturally into component logic, avoiding extra wrapper components and making the tracking explicit in the component body.

Adapter Pattern: Bridging Incompatible Interfaces

Adapters translate one interface to another. In TypeScript, they isolate third-party APIs, enabling easier testing and future migrations.

The Problem: Third-Party API Mismatch

External libraries often have interfaces that don't match your domain model:

typescript
// Stripe's API shape (can't control)interface StripeCustomer {  id: string;  email: string;  metadata: Record<string, string>;  created: number; // Unix timestamp  description: string | null;}
// Your domain modelinterface Customer {  customerId: string;  email: string;  organizationId: string;  createdAt: Date;  notes?: string;}

These representations serve different purposes. Stripe's API is optimized for their backend, your domain model for your business logic.

The Solution: Adapter Layer

Create adapters to translate between representations:

typescript
class StripeCustomerAdapter {  static toDomain(stripeCustomer: StripeCustomer): Customer {    return {      customerId: stripeCustomer.id,      email: stripeCustomer.email,      organizationId: stripeCustomer.metadata.organizationId,      createdAt: new Date(stripeCustomer.created * 1000),      notes: stripeCustomer.description || undefined,    };  }
  static toStripe(customer: Customer): Partial<StripeCustomer> {    return {      email: customer.email,      metadata: {        organizationId: customer.organizationId,      },      description: customer.notes || null,    };  }}
// Usage in service layerclass CustomerService {  constructor(private stripe: Stripe) {}
  async getCustomer(id: string): Promise<Customer> {    const stripeCustomer = await this.stripe.customers.retrieve(id);    return StripeCustomerAdapter.toDomain(stripeCustomer);  }
  async createCustomer(customer: Customer): Promise<Customer> {    const stripeData = StripeCustomerAdapter.toStripe(customer);    const created = await this.stripe.customers.create(stripeData);    return StripeCustomerAdapter.toDomain(created);  }}

When Stripe changes their API, only the adapter needs updating. Your domain model remains stable.

TypeScript Mapped Types as Adapters

TypeScript's type system enables compile-time adapters using mapped types:

typescript
// Generic adapter for API response wrapperstype ApiResponse<T> = {  data: T;  status: number;  message: string;  metadata: {    timestamp: number;    requestId: string;  };};
// Unwrap API response typetype UnwrapApiResponse<T> = T extends ApiResponse<infer U> ? U : T;
// Automatically extract data typetype UserData = UnwrapApiResponse<  ApiResponse<{ id: string; name: string }>>;// Result: { id: string; name: string }
// Runtime adapter functionfunction unwrapApiResponse<T>(response: ApiResponse<T>): T {  if (response.status >= 400) {    throw new Error(`API Error: ${response.message}`);  }  return response.data;}

This pattern works well when many APIs share similar wrapper structures.

Adapter for React Component Props

Adapters help integrate third-party UI libraries into your design system:

typescript
// Internal design system button interfaceinterface InternalButtonProps {  label: string;  variant: 'primary' | 'secondary' | 'danger';  onClick: () => void;  disabled?: boolean;}
// Adapter component for Material-UIfunction InternalButton({  label,  variant,  onClick,  disabled,}: InternalButtonProps) {  // Adapt internal variant to Material-UI color  const muiColor = {    primary: 'primary',    secondary: 'secondary',    danger: 'error',  }[variant] as 'primary' | 'secondary' | 'error';
  return (    <MuiButton      variant="contained"      color={muiColor}      onClick={onClick}      disabled={disabled}    >      {label}    </MuiButton>  );}
// Usage with internal API<InternalButton  label="Delete"  variant="danger"  onClick={handleDelete}/>

This isolation means switching from Material-UI to another library only requires updating the adapter component, not every usage site.

When to Create Adapters

Create adapters for:

  • Third-party services you might replace (payment processors, cloud providers)
  • APIs with poor TypeScript support
  • External services requiring complex domain model transformations
  • Libraries undergoing frequent breaking changes

Don't create adapters for:

  • Stable, well-typed libraries (lodash, date-fns)
  • Internal utilities under your control
  • Simple one-to-one mappings (use utility functions instead)

Facade Pattern: Simplifying Complex Subsystems

Facades provide simplified interfaces to complex subsystems. In TypeScript, they hide initialization complexity, coordinate multiple services, and reduce coupling to implementation details.

The Problem: Complex API Setup

The AWS SDK v3 requires specific configuration for each service, command objects, and careful error handling:

typescript
// Without facade - scattered complexityimport { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';import { DynamoDBClient, PutItemCommand } from '@aws-sdk/client-dynamodb';import { marshall } from '@aws-sdk/util-dynamodb';import { SQSClient, SendMessageCommand } from '@aws-sdk/client-sqs';
// Setup scattered across codebaseconst s3 = new S3Client({ region: 'us-east-1' });const dynamodb = new DynamoDBClient({ region: 'us-east-1' });const sqs = new SQSClient({ region: 'us-east-1' });
// Usage requires understanding AWS SDK specificsawait s3.send(new PutObjectCommand({  Bucket: 'my-bucket',  Key: 'file.txt',  Body: buffer,}));
await dynamodb.send(new PutItemCommand({  TableName: 'my-table',  Item: marshall({ id: '123', data: 'value' }),}));
await sqs.send(new SendMessageCommand({  QueueUrl: process.env.QUEUE_URL,  MessageBody: JSON.stringify({ task: 'process' }),}));

Each operation requires knowledge of AWS SDK conventions, command objects, and data marshalling.

The Solution: Unified Facade

Create a facade that provides domain-specific operations:

typescript
class CloudStorage {  private s3: S3Client;  private dynamodb: DynamoDBClient;  private sqs: SQSClient;
  constructor(private region: string) {    this.s3 = new S3Client({ region });    this.dynamodb = new DynamoDBClient({ region });    this.sqs = new SQSClient({ region });  }
  async uploadFile(    bucket: string,    key: string,    data: Buffer  ): Promise<void> {    await this.s3.send(new PutObjectCommand({      Bucket: bucket,      Key: key,      Body: data,    }));  }
  async saveMetadata(    table: string,    item: Record<string, any>  ): Promise<void> {    await this.dynamodb.send(new PutItemCommand({      TableName: table,      Item: marshall(item),    }));  }
  async enqueueTask(    queueUrl: string,    task: Record<string, any>  ): Promise<void> {    await this.sqs.send(new SendMessageCommand({      QueueUrl: queueUrl,      MessageBody: JSON.stringify(task),    }));  }}
// Simple usageconst storage = new CloudStorage('us-east-1');await storage.uploadFile('my-bucket', 'file.txt', buffer);await storage.saveMetadata('my-table', { id: '123', data: 'value' });await storage.enqueueTask(queueUrl, { task: 'process' });

The facade hides AWS SDK complexity behind domain-appropriate methods. Consumers work with your application's vocabulary, not AWS's.

Facade for Complex Form Operations

Forms often involve validation, submission, error handling, and analytics:

typescript
interface FormFacadeOptions {  formik: FormikHelpers<any>;  analytics: AnalyticsService;  api: ApiClient;}
class RegistrationFormFacade {  constructor(private options: FormFacadeOptions) {}
  async submitRegistration(values: RegistrationFormValues): Promise<User> {    const { formik, analytics, api } = this.options;
    // Track attempt    analytics.track('registration_attempt', {      referrer: values.referrer,    });
    try {      // Validate      await this.validateEmail(values.email);
      // Create user      const user = await api.createUser({        email: values.email,        name: values.name,        password: values.password,      });
      // Send verification email      await api.sendVerificationEmail(user.email);
      // Track success      analytics.track('registration_success', {        userId: user.id,      });
      return user;    } catch (error) {      // Track error      analytics.track('registration_error', {        error: error.message,      });
      // Map API errors to form errors      const formErrors = this.mapApiErrorsToFormErrors(error);      formik.setErrors(formErrors);
      throw error;    }  }
  private async validateEmail(email: string): Promise<void> {    const available = await this.options.api.checkEmailAvailability(email);    if (!available) {      throw new Error('Email already registered');    }  }
  private mapApiErrorsToFormErrors(    error: ApiError  ): FormikErrors<RegistrationFormValues> {    // Complex error mapping logic    if (error.code === 'EMAIL_TAKEN') {      return { email: 'This email is already registered' };    }    if (error.code === 'WEAK_PASSWORD') {      return { password: 'Password must be stronger' };    }    return { _form: 'Registration failed. Please try again.' };  }}
// Usage in componentfunction RegistrationForm() {  const formik = useFormik({ /* ... */ });  const analytics = useAnalytics();  const api = useApiClient();
  const facade = useMemo(    () => new RegistrationFormFacade({ formik, analytics, api }),    [formik, analytics, api]  );
  const handleSubmit = async (values: RegistrationFormValues) => {    try {      const user = await facade.submitRegistration(values);      navigate(`/welcome/${user.id}`);    } catch (error) {      // Error already handled by facade    }  };
  return <form onSubmit={formik.handleSubmit(handleSubmit)}>    {/* Form fields */}  </form>;}

The facade coordinates multiple subsystems - API client, analytics, form state - behind a single method call.

Barrel Exports: A Controversial Facade

Barrel exports (index.ts files) create public facades for modules:

typescript
// lib/index.ts - public API facadeexport { User, type UserRole } from './models/user';export { createClient } from './client';export { authenticate, type AuthOptions } from './auth';export { ApiError } from './errors';
// Consumers see clean interfaceimport { User, createClient, authenticate } from 'my-lib';

This hides internal structure, letting you refactor internals without breaking consumers.

However: 2024 research from Atlassian shows barrel exports can harm build performance significantly. They reduced build times by 75% after removing barrel files. Next.js pages that imported 11k modules through barrels dropped to 3.5k direct imports (68% reduction).

Recommendation: Use barrel exports only for public library APIs. Avoid them for internal project modules where build performance matters.

When Facades Add Value

Create facades when:

  • Coordinating multiple services for common operations
  • Simplifying complex initialization sequences
  • Providing domain-specific interfaces to technical APIs
  • Isolating applications from third-party API changes

Don't create facades for:

  • Simple object creation (that's just unnecessary indirection)
  • Wrapping single methods with no additional logic
  • Internal utilities that are already simple

Composite Pattern: React's Natural Structure

The Composite pattern treats individual objects and compositions uniformly. React's component model is inherently composite - components can contain other components, and both are treated the same way.

Classic Composite Pattern

The textbook example involves hierarchical structures like file systems:

typescript
// Component interfaceinterface FileSystemNode {  name: string;  size: number;  render(): JSX.Element;}
// Leaf - Fileclass File implements FileSystemNode {  constructor(    public name: string,    public size: number,    public type: string  ) {}
  render() {    return (      <div className="file">        <FileIcon type={this.type} />        <span>{this.name}</span>        <span>{formatSize(this.size)}</span>      </div>    );  }}
// Composite - Folderclass Folder implements FileSystemNode {  constructor(    public name: string,    private children: FileSystemNode[]  ) {}
  get size(): number {    return this.children.reduce((sum, child) => sum + child.size, 0);  }
  render() {    return (      <div className="folder">        <FolderIcon />        <span>{this.name}</span>        <div className="children">          {this.children.map((child, i) => (            <div key={i}>{child.render()}</div>          ))}        </div>      </div>    );  }}
// Uniform interface for files and foldersconst root = new Folder('root', [  new File('document.txt', 1024, 'text'),  new Folder('images', [    new File('photo1.jpg', 2048, 'image'),    new File('photo2.jpg', 3072, 'image'),  ]),  new File('README.md', 512, 'markdown'),]);

This works, but feels verbose for React. The pattern is sound - treating individual items and collections uniformly - but the implementation doesn't leverage React's natural composition.

Modern React Approach

React components are composite by nature. Here's the same logic with idiomatic React:

typescript
interface FileSystemNodeData {  name: string;  type: 'file' | 'folder';  size?: number;  mimeType?: string;  children?: FileSystemNodeData[];}
function FileSystemNode({ node }: { node: FileSystemNodeData }) {  if (node.type === 'file') {    return (      <div className="file">        <FileIcon type={node.mimeType!} />        <span>{node.name}</span>        <span>{formatSize(node.size!)}</span>      </div>    );  }
  const totalSize = node.children?.reduce(    (sum, child) => sum + (child.size || 0),    0  ) || 0;
  return (    <div className="folder">      <FolderIcon />      <span>{node.name}</span>      <span>{formatSize(totalSize)}</span>      <div className="children">        {node.children?.map((child, i) => (          <FileSystemNode key={i} node={child} />        ))}      </div>    </div>  );}
// Usage with data structureconst fileSystem: FileSystemNodeData = {  name: 'root',  type: 'folder',  children: [    { name: 'document.txt', type: 'file', size: 1024, mimeType: 'text' },    {      name: 'images',      type: 'folder',      children: [        { name: 'photo1.jpg', type: 'file', size: 2048, mimeType: 'image' },        { name: 'photo2.jpg', type: 'file', size: 3072, mimeType: 'image' },      ],    },    { name: 'README.md', type: 'file', size: 512, mimeType: 'markdown' },  ],};
<FileSystemNode node={fileSystem} />

This leverages React's natural recursion and discriminated unions for type safety. The composite nature emerges from component structure rather than explicit classes.

Compound Components Pattern

The compound component pattern provides flexible APIs with implicit state sharing:

typescript
interface SelectContextValue {  value: string | null;  onChange: (value: string) => void;  isOpen: boolean;  setIsOpen: (open: boolean) => void;}
const SelectContext = createContext<SelectContextValue | null>(null);
function Select({ children, value, onChange }: SelectProps) {  const [isOpen, setIsOpen] = useState(false);
  return (    <SelectContext.Provider value={{ value, onChange, isOpen, setIsOpen }}>      <div className="select">{children}</div>    </SelectContext.Provider>  );}
function SelectTrigger({ children }: { children: ReactNode }) {  const context = useContext(SelectContext);  if (!context) throw new Error('SelectTrigger must be used within Select');
  return (    <button      onClick={() => context.setIsOpen(!context.isOpen)}      className="select-trigger"    >      {context.value || children}    </button>  );}
function SelectContent({ children }: { children: ReactNode }) {  const context = useContext(SelectContext);  if (!context) throw new Error('SelectContent must be used within Select');
  if (!context.isOpen) return null;
  return <div className="select-content">{children}</div>;}
function SelectOption({ value, children }: OptionProps) {  const context = useContext(SelectContext);  if (!context) throw new Error('SelectOption must be used within Select');
  return (    <div      className={context.value === value ? 'selected' : ''}      onClick={() => {        context.onChange(value);        context.setIsOpen(false);      }}    >      {children}    </div>  );}
// Namespace for ergonomic usageSelect.Trigger = SelectTrigger;Select.Content = SelectContent;Select.Option = SelectOption;
// Flexible composition<Select value={selected} onChange={setSelected}>  <Select.Trigger>Choose option</Select.Trigger>  <Select.Content>    <Select.Option value="1">Option 1</Select.Option>    <Select.Option value="2">Option 2</Select.Option>    <Select.Option value="3">Option 3</Select.Option>  </Select.Content></Select>

This pattern appears in Radix UI, Headless UI, and Reach UI. It combines composite structure with context-based state sharing for flexible, composable APIs.

Proxy Pattern: Lazy Loading and Access Control

Proxies control access to objects, adding behavior like lazy loading, caching, validation, or access control. TypeScript provides both class-based proxies and JavaScript's Proxy API for runtime interception.

React.lazy and Suspense

React's built-in lazy loading is a proxy pattern - it intercepts component rendering to load code on demand:

typescript
// Proxy for code-splittingconst Dashboard = React.lazy(() => import('./Dashboard'));const Settings = React.lazy(() => import('./Settings'));const Profile = React.lazy(() => import('./Profile'));
function App() {  return (    <Suspense fallback={<div>Loading...</div>}>      <Routes>        <Route path="/dashboard" element={<Dashboard />} />        <Route path="/settings" element={<Settings />} />        <Route path="/profile" element={<Profile />} />      </Routes>    </Suspense>  );}

React.lazy intercepts component rendering, loading the module only when needed. The Suspense boundary provides loading state while the proxy fetches code.

Custom Proxy for API Caching

Proxies add caching layers without changing calling code:

typescript
interface ApiClient {  fetchUser(id: string): Promise<User>;  fetchPosts(userId: string): Promise<Post[]>;}
class CachedApiClient implements ApiClient {  private cache = new Map<string, {    data: any;    timestamp: number;  }>();  private ttl = 60000; // 1 minute
  constructor(private realClient: ApiClient) {}
  async fetchUser(id: string): Promise<User> {    const cacheKey = `user:${id}`;    const cached = this.cache.get(cacheKey);
    if (cached && Date.now() - cached.timestamp < this.ttl) {      console.log('[Cache] Hit:', cacheKey);      return cached.data;    }
    console.log('[Cache] Miss:', cacheKey);    const data = await this.realClient.fetchUser(id);    this.cache.set(cacheKey, {      data,      timestamp: Date.now(),    });
    return data;  }
  async fetchPosts(userId: string): Promise<Post[]> {    const cacheKey = `posts:${userId}`;    const cached = this.cache.get(cacheKey);
    if (cached && Date.now() - cached.timestamp < this.ttl) {      console.log('[Cache] Hit:', cacheKey);      return cached.data;    }
    console.log('[Cache] Miss:', cacheKey);    const data = await this.realClient.fetchPosts(userId);    this.cache.set(cacheKey, {      data,      timestamp: Date.now(),    });
    return data;  }}
// Transparent proxy - same interface as real clientconst realClient = new RealApiClient();const cachedClient = new CachedApiClient(realClient);
// Usage unchangedconst user = await cachedClient.fetchUser('123');const posts = await cachedClient.fetchPosts('123');

The proxy implements the same interface as the real client, adding caching transparently. Calling code doesn't know whether it's using cached or fresh data.

JavaScript Proxy API for Validation

JavaScript's Proxy API enables runtime interception:

typescript
import { z } from 'zod';
const userSchema = z.object({  name: z.string().min(2),  email: z.string().email(),  age: z.number().positive().int(),});
function createValidatedProxy<T extends object>(  target: T,  schema: z.ZodSchema<T>): T {  return new Proxy(target, {    set(obj, prop, value) {      // Validate entire object after change      const updated = { ...obj, [prop]: value };      const result = schema.safeParse(updated);
      if (!result.success) {        throw new Error(          `Validation failed for ${String(prop)}: ${result.error.message}`        );      }
      obj[prop as keyof T] = value;      return true;    },
    get(obj, prop) {      const value = obj[prop as keyof T];      console.log(`[Access] ${String(prop)}:`, value);      return value;    },  });}
// Usageconst user = createValidatedProxy(  { name: '', email: '', age: 0 },  userSchema);
user.name = 'John'; // OKuser.email = '[email protected]'; // OKuser.age = 30; // OK
// user.age = -5; // Throws validation error// user.email = 'invalid'; // Throws validation error

The Proxy intercepts property access and assignment, enforcing validation rules at runtime. This works well for configuration objects or domain entities requiring invariants.

React Query as Proxy Pattern

React Query acts as a proxy for data fetching, managing caching, loading states, and refetching:

typescript
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
function UserProfile({ userId }: { userId: string }) {  // React Query proxies the fetch operation  const { data, isLoading, error } = useQuery({    queryKey: ['user', userId],    queryFn: () => fetchUser(userId),    staleTime: 60000, // Cache for 1 minute    gcTime: 300000, // Garbage collect after 5 minutes  });
  const queryClient = useQueryClient();
  const updateMutation = useMutation({    mutationFn: (updates: Partial<User>) => updateUser(userId, updates),    onSuccess: () => {      // Invalidate cache after successful mutation      queryClient.invalidateQueries({ queryKey: ['user', userId] });    },  });
  if (isLoading) return <div>Loading...</div>;  if (error) return <div>Error: {error.message}</div>;
  return (    <div>      <h2>{data.name}</h2>      <button onClick={() => updateMutation.mutate({ name: 'New Name' })}>        Update Name      </button>    </div>  );}

React Query intercepts data fetching, adding:

  • Automatic caching with configurable TTL
  • Loading and error states
  • Background refetching
  • Cache invalidation
  • Request deduplication

The component treats React Query's useQuery as a simple data fetch, but the proxy handles complex caching and synchronization logic.

Proxy for Access Control

Proxies can enforce authorization:

typescript
interface AdminActions {  deleteUser(id: string): Promise<void>;  modifyPermissions(userId: string, permissions: string[]): Promise<void>;  accessAuditLogs(): Promise<AuditLog[]>;}
class AuthorizedAdminProxy implements AdminActions {  constructor(    private realAdmin: AdminActions,    private currentUser: User  ) {}
  private checkAuthorization(action: string): void {    if (!this.currentUser.roles.includes('admin')) {      throw new Error(`Unauthorized: ${action} requires admin role`);    }  }
  async deleteUser(id: string): Promise<void> {    this.checkAuthorization('deleteUser');    console.log(`[Audit] User ${this.currentUser.id} deleted user ${id}`);    await this.realAdmin.deleteUser(id);  }
  async modifyPermissions(    userId: string,    permissions: string[]  ): Promise<void> {    this.checkAuthorization('modifyPermissions');    console.log(      `[Audit] User ${this.currentUser.id} modified permissions for ${userId}`    );    await this.realAdmin.modifyPermissions(userId, permissions);  }
  async accessAuditLogs(): Promise<AuditLog[]> {    this.checkAuthorization('accessAuditLogs');    console.log(`[Audit] User ${this.currentUser.id} accessed audit logs`);    return await this.realAdmin.accessAuditLogs();  }}
// Usageconst adminActions = new RealAdminActions();const authorizedProxy = new AuthorizedAdminProxy(adminActions, currentUser);
// Proxy checks authorization before each actionawait authorizedProxy.deleteUser('user-123');

The proxy enforces authorization and logging without modifying the real implementation.

Common Pitfalls and Lessons

Pitfall 1: HOC Wrapper Hell

Problem: Stacking multiple HOCs creates component trees dozens of layers deep, making debugging difficult and performance monitoring challenging.

Solution: Migrate to hooks for most cross-cutting concerns. Reserve HOCs for legacy integration or truly visual wrappers.

Pitfall 2: Leaky Facades

Problem: Facades that expose implementation details defeat their purpose.

Bad Example:

typescript
class CloudStorage {  // Leaky - exposes S3-specific details  async upload(command: PutObjectCommand): Promise<void> {    await this.s3.send(command);  }}

Good Example:

typescript
class CloudStorage {  // Proper abstraction - consumers don't see S3  async upload(bucket: string, key: string, data: Buffer): Promise<void> {    const command = new PutObjectCommand({ Bucket: bucket, Key: key, Body: data });    await this.s3.send(command);  }}

Lesson: Facades should speak your domain language, not the underlying library's.

Pitfall 3: Adapter Proliferation

Problem: Creating adapters for every external dependency creates maintenance burden without clear benefits.

When to create adapters:

  • Dependencies you plan to swap (different cloud providers, payment processors)
  • APIs with poor TypeScript support requiring type safety layers
  • External services requiring significant domain model transformation

When NOT to create adapters:

  • Stable, well-typed libraries (lodash, date-fns)
  • Internal utilities under your control
  • Simple one-to-one mappings (use utility functions)

Pitfall 4: Composite Overengineering

Problem: Implementing full composite pattern for simple component hierarchies.

Overkill:

typescript
interface Component {  render(): JSX.Element;  getSize(): number;  add(child: Component): void;  remove(child: Component): void;}

Better:

typescript
// Let React handle composition naturallyfunction List({ items }: { items: Item[] }) {  return (    <ul>      {items.map(item => <ListItem key={item.id} item={item} />)}    </ul>  );}

Lesson: React's component model already provides composite behavior. Explicit pattern implementation is rarely needed.

Pitfall 5: Barrel Export Performance

Problem: Using barrel exports (index.ts files) for internal modules harms build performance.

Research: Atlassian reduced build times by 75% after removing barrel files. Next.js applications saw module imports drop from 11k to 3.5k (68% reduction).

Recommendation: Use barrel exports only for public library APIs. For internal modules, import directly:

typescript
// BAD: Slow with barrelsimport { Button, Input, Select } from '@/components';
// Fast with direct importsimport { Button } from '@/components/button';import { Input } from '@/components/input';import { Select } from '@/components/select';

Key Takeaways

HOCs → Hooks: Higher-order components (the decorator pattern) have been largely replaced by hooks for cleaner composition. HOCs remain useful for legacy code and purely visual wrappers.

Adapter for Boundaries: Use adapters at system boundaries - external APIs, third-party libraries - to isolate changes and maintain clean domain models. Don't adapt stable, well-typed dependencies.

Facade for Complexity: Facades excel at coordinating multiple subsystems or simplifying complex initialization. Avoid creating facades for simple object creation - that's unnecessary indirection.

Composite Is React's Nature: React's component model is inherently composite. Explicit composite pattern implementation is rarely needed - leverage React's natural composition.

Proxy for Cross-Cutting Concerns: Use proxies for caching, lazy loading, access control, and validation. JavaScript's Proxy API and patterns like React Query enable powerful interception without modifying original implementations.

Barrel Exports Are Controversial: Use sparingly for public library APIs only. Internal barrel exports harm build performance significantly.

TypeScript Enhances Patterns: Mapped types, conditional types, and discriminated unions make structural patterns more powerful and type-safe than classical implementations.

Composition Over Decoration: Modern React favors composition patterns (hooks, compound components, render props) over decoration patterns (HOCs) for better readability and maintainability.

The structural patterns from 1994 haven't disappeared. They've evolved into framework conventions, type system features, and modern architectural approaches. Understanding the underlying principles - wrapping behavior, adapting interfaces, simplifying complexity, treating parts and wholes uniformly, controlling access - helps you recognize these patterns in modern codebases and apply them appropriately when building new systems.

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.

Progress2/4 posts completed

Related Posts