Component Composition Patterns

Component Composition Patterns

Cross-ecosystem composition patterns — HOC, hooks, render props, and compound components (React) alongside directives, pipes, and inject functions (Angular) — each tracing lineage to a GoF ancestor.

Intent

Frontend components accumulate cross-cutting concerns over time: authentication guards, data fetching, formatting, logging, and dependency injection. Without a principled composition model, teams reach for inheritance — a choice that creates rigid hierarchies and tight coupling.

Composition patterns solve reuse without inheritance. A HOC wraps a component and adds an auth guard. An Angular directive decorates a DOM element with highlight behaviour. A custom hook encapsulates multiple state primitives behind a single interface. None of these are novel inventions — each descends directly from a GoF pattern defined in 1994.

This is a TypeScript-only note — component composition is a frontend concern with no Java equivalent, consistent with the Micro-Frontends precedent in this vault. All examples draw from both React and Angular ecosystems in the same note. See also State-Management-Patterns for how state is managed across composed components, and Change-Detection-and-Rendering-Engines for how Angular's change detection interacts with directives.

When NOT to Use

Each composition pattern has contexts where it is the wrong choice:

HOC:

  • Avoid when a custom hook can solve the same problem (React 16.8+). HOCs are valid for component-wrapping concerns (auth guard, error boundary, analytics) but hooks handle logic reuse more composably.
  • Avoid chaining 5+ HOCs around a single component — DevTools trees become unreadable and prop name collisions become likely.

Render Props:

  • Superseded by hooks for logic reuse (post-2019). Use render props only for explicit UI slot patterns where a render callback makes the API semantically clearer than a prop or children.

Angular Structural Directives with complex logic:

  • Prefer Angular 17+ built-in control flow (@if, @for, @switch) over custom structural directives for conditional and list rendering. Custom structural directives remain valid only for novel DOM manipulation beyond standard control flow.

Impure Pipes:

  • Never use pure: false pipes for data fetching or complex computation. Impure pipes re-execute on every change detection cycle — hundreds of times per second in zone.js apps.

inject() outside injection context:

  • Call inject() only in constructor body, field initializer, or inside runInInjectionContext(). Calling it inside a method or lifecycle hook throws NG0203 at runtime.

GoF Lineage

Every frontend composition pattern descends from a GoF ancestor. This lineage table must be read before any implementation — it answers why the pattern exists, not just how it works.

Frontend PatternGoF AncestorRelationship
HOCDecoratorWraps a component with a new component implementing the same interface; adds behavior before/after render
Angular Attribute DirectiveDecoratorAttached to an existing DOM element; adds behavior without replacing the element
Angular Structural DirectiveDecorator + Template MethodControls whether/how a template is rendered; decorates DOM structure
Angular inject() functionStrategy + FactorySelects a concrete service implementation at injection point; injection token acts as the strategy interface
Render PropsTemplate MethodParent defines the algorithm skeleton; consumer fills in the rendering step
Compound ComponentsCompositeRelated components form a tree; parent manages state, children implement leaf behavior via shared context
Custom HookFacadeEncapsulates multiple primitives behind a single composable function

Pattern Relationships

classDiagram
    class ComponentInterface {
        <<interface>>
        +render() ReactNode
    }
    class HOC {
        GoF: Decorator
        +wraps: ComponentInterface
        +addsBehavior() void
    }
    class AttributeDirective {
        GoF: Decorator
        +decoratesHost() void
    }
    class StructuralDirective {
        GoF: Decorator + Template
        +templateRef: TemplateRef
        +viewContainer: ViewContainerRef
    }
    class InjectFunction {
        GoF: Strategy + Factory
        +token: InjectionToken
        +resolves() Service
    }
    class RenderProp {
        GoF: Template Method
        +render: Function
        +fillsAlgorithmStep() void
    }
    class CompoundComponent {
        GoF: Composite
        +context: Context
        +children: ComponentInterface[]
    }
    class CustomHook {
        GoF: Facade
        +encapsulates() Primitives
    }
    class AngularPipe {
        Pure Transform
        +transform(value) T
    }
    HOC ..|> ComponentInterface
    AttributeDirective ..|> ComponentInterface
    StructuralDirective ..|> ComponentInterface
    CompoundComponent --* ComponentInterface
    InjectFunction ..> AuthService
    InjectFunction ..> HttpService

React Patterns

Higher-Order Component (HOC)

A HOC is a function that takes a component and returns a new component with added behaviour — the GoF Decorator pattern applied to function components. The wrapped component retains its original interface; the HOC adds a layer (auth guard, analytics, theming) without modifying the original.

// Higher-Order Component: wraps component with auth guard (GoF: Decorator)
import React from 'react';
import { useNavigate } from 'react-router-dom';
 
interface WithAuthProps {
  isAuthenticated: boolean;
}
 
function withAuth<T extends object>(
  Wrapped: React.ComponentType<T>
): React.FC<T & WithAuthProps> {
  const WithAuth: React.FC<T & WithAuthProps> = ({ isAuthenticated, ...props }) => {
    const navigate = useNavigate();
 
    React.useEffect(() => {
      if (!isAuthenticated) navigate('/login');
    }, [isAuthenticated, navigate]);
 
    if (!isAuthenticated) return null;
    return <Wrapped {...(props as T)} />;
  };
 
  WithAuth.displayName = `WithAuth(${Wrapped.displayName ?? Wrapped.name})`;
  return WithAuth;
}
 
export default withAuth;

Set displayName explicitly — React DevTools uses it to show the HOC wrapper in the component tree. Without it, all HOCs appear as anonymous <Component> nodes.

Modern React prefers hooks for logic reuse; HOCs remain valid for component-wrapping concerns (auth guard, error boundary, analytics).

Custom Hook

A custom hook encapsulates multiple React primitives (useState, useEffect, useContext) behind a single composable function — the GoF Facade pattern. The hook owns its own state; multiple components using the same hook each get independent state instances.

// Custom hook: encapsulates data fetching concern (GoF: Facade over useState + useEffect)
import { useState, useEffect } from 'react';
 
interface UseFetchResult<T> {
  data: T | null;
  loading: boolean;
  error: Error | null;
}
 
function useFetch<T>(url: string): UseFetchResult<T> {
  const [data, setData] = useState<T | null>(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);
 
  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
 
    fetch(url, { signal: controller.signal })
      .then((res) => res.json() as Promise<T>)
      .then(setData)
      .catch((err) => { if (err.name !== 'AbortError') setError(err); })
      .finally(() => setLoading(false));
 
    return () => controller.abort();
  }, [url]);
 
  return { data, loading, error };
}
 
export default useFetch;

The AbortController cleanup cancels in-flight requests when the component unmounts or url changes — prevents the classic stale-closure memory leak.

Render Props

A render prop component accepts a function as a child (or named prop) that returns JSX — the GoF Template Method pattern. The parent component owns the algorithm skeleton (state management, data fetching); the consumer fills in the rendering step.

// Render prop: provides data to consumer-supplied render function (GoF: Template Method)
import { useState } from 'react';
 
interface ToggleProps {
  children: (args: { on: boolean; toggle: () => void }) => React.ReactNode;
}
 
function Toggle({ children }: ToggleProps): JSX.Element {
  const [on, setOn] = useState(false);
  return <>{children({ on, toggle: () => setOn((v) => !v) })}</>;
}
 
// Usage:
// <Toggle>{({ on, toggle }) => <button onClick={toggle}>{on ? 'ON' : 'OFF'}</button>}</Toggle>

Render props were the primary composition strategy from 2016-2019; hooks supersede them for most use cases. Use render props only for explicit UI slot patterns where a render callback makes the API semantically clearer.

Compound Components

Compound components are related components that share state via React context — the GoF Composite pattern. The parent component manages state and exposes it through a context provider; child components consume that context to implement their leaf behaviour. The consumer API reads like a single component group: <Tabs>, <Tabs.Tab>, <Tabs.Panel>.

// Compound components: parent state shared via context (GoF: Composite)
import React, { createContext, useContext, useState } from 'react';
 
interface TabsContextValue {
  activeTab: string;
  setActiveTab: (id: string) => void;
}
 
const TabsContext = createContext<TabsContextValue | null>(null);
 
function Tabs({ children, defaultTab }: { children: React.ReactNode; defaultTab: string }) {
  const [activeTab, setActiveTab] = useState(defaultTab);
  return (
    <TabsContext.Provider value={{ activeTab, setActiveTab }}>
      {children}
    </TabsContext.Provider>
  );
}
 
function Tab({ id, children }: { id: string; children: React.ReactNode }) {
  const ctx = useContext(TabsContext);
  if (!ctx) throw new Error('Tab must be inside Tabs');
  return (
    <button
      onClick={() => ctx.setActiveTab(id)}
      aria-selected={ctx.activeTab === id}
    >
      {children}
    </button>
  );
}
 
Tabs.Tab = Tab;
export default Tabs;
// Usage: <Tabs defaultTab="a"><Tabs.Tab id="a">Tab A</Tabs.Tab></Tabs>

Angular Patterns

Attribute Directive

An attribute directive adds behaviour to an existing host element without replacing it — the GoF Decorator pattern applied to DOM elements. The directive is attached via its selector ([appHighlight]) and uses @HostListener and @HostBinding to listen and bind to the host element.

// Attribute directive: adds highlight behavior to host element (GoF: Decorator)
import { Directive, ElementRef, HostListener, Input } from '@angular/core';
 
@Directive({
  selector: '[appHighlight]',
  standalone: true,
})
export class HighlightDirective {
  @Input() highlightColor = 'yellow';
 
  constructor(private el: ElementRef<HTMLElement>) {}
 
  @HostListener('mouseenter')
  onMouseEnter(): void {
    this.el.nativeElement.style.backgroundColor = this.highlightColor;
  }
 
  @HostListener('mouseleave')
  onMouseLeave(): void {
    this.el.nativeElement.style.backgroundColor = '';
  }
}

standalone: true (Angular 14+) removes the NgModule requirement — import the directive directly in the component's imports array.

Structural Directive

A structural directive manipulates the DOM template using TemplateRef and ViewContainerRef — Decorator applied to DOM structure, combined with Template Method for the conditional rendering algorithm.

// Structural directive: conditionally renders a template (GoF: Decorator + Template Method)
import { Directive, Input, TemplateRef, ViewContainerRef } from '@angular/core';
 
@Directive({
  selector: '[appIf]',
  standalone: true,
})
export class AppIfDirective {
  constructor(
    private templateRef: TemplateRef<unknown>,
    private viewContainer: ViewContainerRef
  ) {}
 
  @Input() set appIf(condition: boolean) {
    if (condition) {
      this.viewContainer.createEmbeddedView(this.templateRef);
    } else {
      this.viewContainer.clear();
    }
  }
}
// Usage: <div [appIf]="isVisible">Shown when true</div>
// Microsyntax: *appIf="isVisible" desugars to [appIf]="isVisible"

Angular 17+ built-in control flow (@if, @for, @switch) supersedes most custom structural directive use cases. Write custom structural directives only for DOM manipulation that goes beyond conditional rendering or list iteration.

Pipe

An Angular pipe is a pure transformation function applied in templates — a stateless transform triggered only when its input reference changes. Pipes implement a Chain of Responsibility pattern for template data transformation.

// Pipe: pure transformation function for template use
import { Pipe, PipeTransform } from '@angular/core';
 
@Pipe({
  name: 'truncate',
  pure: true,        // re-executes only when input reference changes
  standalone: true,
})
export class TruncatePipe implements PipeTransform {
  transform(value: string, limit = 50): string {
    if (value.length <= limit) return value;
    return value.slice(0, limit) + '...';
  }
}
// Template: {{ description | truncate:100 }}

pure: true is the default and recommended setting. An impure pipe (pure: false) re-executes on every change detection cycle — avoid it for data fetching, HTTP calls, or any operation with side effects.

inject() Function

The inject() function selects a concrete service implementation at the injection point — the GoF Strategy pattern combined with Factory. The injection token acts as the strategy interface; Angular's DI container acts as the factory that resolves the concrete implementation.

// inject() function: composable injection function (GoF: Strategy + Factory)
import { inject } from '@angular/core';
import { HttpClient } from '@angular/common/http';
import { Observable } from 'rxjs';
 
interface User {
  id: string;
  name: string;
}
 
// Injection function — composable service access pattern (Angular 14+)
export function injectUserApi() {
  const http = inject(HttpClient);
  return {
    getUser: (id: string): Observable<User> =>
      http.get<User>(`/api/users/${id}`),
  };
}
 
// Usage in component:
// @Component({ standalone: true, /* ... */ })
// export class UserProfileComponent {
//   private userApi = injectUserApi();  // field initializer injection context
// }

Replaces constructor injection as a more composable approach (Angular 14+). Injection functions like injectUserApi() can be composed, tested independently, and shared across components without inheritance.

Compound Components (Angular)

Angular compound components use @ContentChildren to query projected child component instances — structurally equivalent to React's context-based approach, with the parent managing state and children consuming it via projection.

// Angular compound component: parent queries projected children via ContentChildren
import { Component, ContentChildren, QueryList, AfterContentInit, Input } from '@angular/core';
 
@Component({
  selector: 'app-tab',
  standalone: true,
  template: `<ng-content />`,
})
export class TabComponent {
  @Input() id = '';
  @Input() label = '';
}
 
@Component({
  selector: 'app-tabs',
  standalone: true,
  imports: [TabComponent],
  template: `
    <div class="tab-headers">
      @for (tab of tabs; track tab.id) {
        <button
          (click)="activeTab = tab.id"
          [attr.aria-selected]="activeTab === tab.id"
        >{{ tab.label }}</button>
      }
    </div>
    <ng-content />
  `,
})
export class TabsComponent implements AfterContentInit {
  @ContentChildren(TabComponent) tabs!: QueryList<TabComponent>;
  activeTab = '';
 
  ngAfterContentInit(): void {
    if (this.tabs.first) {
      this.activeTab = this.tabs.first.id;
    }
  }
}
// Usage: <app-tabs><app-tab id="a" label="Tab A">Content A</app-tab></app-tabs>

Angular 17+ prefers signal-based queries — contentChildren(TabComponent) returns a Signal<readonly TabComponent[]> instead of QueryList, providing synchronous access without the AfterContentInit lifecycle hook.

Cross-Pattern Comparison Table

PatternEcosystemReuse MechanismGoF AncestorModern Status
HOCReactWraps componentDecoratorValid for component wrapping; hooks preferred for logic
Custom HookReactEncapsulates stateful logicFacadePrimary composition mechanism since React 16.8
Render PropsReactRender callback functionTemplate MethodSuperseded by hooks; valid for UI slot patterns
Compound ComponentsReact + AngularContext/ContentChildrenCompositeActive — standard for related component groups
Attribute DirectiveAngularHost element decorationDecoratorActive — standalone since Angular 14
Structural DirectiveAngularTemplate manipulationDecorator + TemplateDeclining — built-in control flow preferred
PipeAngularPure template transformChain of ResponsibilityActive — standalone since Angular 14
inject() FunctionAngularComposable DIStrategy + FactoryGrowing — recommended over constructor injection