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: falsepipes 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 insiderunInInjectionContext(). Calling it inside a method or lifecycle hook throwsNG0203at 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 Pattern | GoF Ancestor | Relationship |
|---|---|---|
| HOC | Decorator | Wraps a component with a new component implementing the same interface; adds behavior before/after render |
| Angular Attribute Directive | Decorator | Attached to an existing DOM element; adds behavior without replacing the element |
| Angular Structural Directive | Decorator + Template Method | Controls whether/how a template is rendered; decorates DOM structure |
Angular inject() function | Strategy + Factory | Selects a concrete service implementation at injection point; injection token acts as the strategy interface |
| Render Props | Template Method | Parent defines the algorithm skeleton; consumer fills in the rendering step |
| Compound Components | Composite | Related components form a tree; parent manages state, children implement leaf behavior via shared context |
| Custom Hook | Facade | Encapsulates 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
| Pattern | Ecosystem | Reuse Mechanism | GoF Ancestor | Modern Status |
|---|---|---|---|---|
| HOC | React | Wraps component | Decorator | Valid for component wrapping; hooks preferred for logic |
| Custom Hook | React | Encapsulates stateful logic | Facade | Primary composition mechanism since React 16.8 |
| Render Props | React | Render callback function | Template Method | Superseded by hooks; valid for UI slot patterns |
| Compound Components | React + Angular | Context/ContentChildren | Composite | Active — standard for related component groups |
| Attribute Directive | Angular | Host element decoration | Decorator | Active — standalone since Angular 14 |
| Structural Directive | Angular | Template manipulation | Decorator + Template | Declining — built-in control flow preferred |
| Pipe | Angular | Pure template transform | Chain of Responsibility | Active — standalone since Angular 14 |
| inject() Function | Angular | Composable DI | Strategy + Factory | Growing — recommended over constructor injection |