StackPractices
intermediate By Mathias Paulenko

Composite Pattern for UI Component Trees in React

Use the Composite pattern to compose objects into tree structures, letting clients treat individual objects and compositions uniformly in UI component hierarchies

The Composite pattern composes objects into tree structures to represent part-whole hierarchies. It lets clients treat individual objects and compositions of objects uniformly. In React, this pattern appears naturally when rendering nested component trees where a container holds both leaf elements and other containers. See the general Composite Pattern for language-agnostic examples.

When to Use This

  • You have a tree structure of objects with parent-child relationships
  • Clients should ignore the difference between compositions and individual objects
  • You need to perform operations recursively across a hierarchy

Problem

A form builder needs to render nested groups, fields, and sections. The rendering logic branches for every type instead of treating everything as a renderable node.

Solution

// components/Composite.tsx
interface ComponentNode {
  id: string;
  type: string;
  render(): React.ReactNode;
}

// Leaf
class FieldNode implements ComponentNode {
  constructor(
    public id: string,
    public label: string,
    public value: string
  ) {}

  render(): React.ReactNode {
    return (
      <div key={this.id} className="field">
        <label>{this.label}</label>
        <input type="text" defaultValue={this.value} />
      </div>
    );
  }
}

// Composite
class GroupNode implements ComponentNode {
  public children: ComponentNode[] = [];

  constructor(
    public id: string,
    public title: string
  ) {}

  add(child: ComponentNode): void {
    this.children.push(child);
  }

  remove(childId: string): void {
    this.children = this.children.filter(c => c.id !== childId);
  }

  render(): React.ReactNode {
    return (
      <fieldset key={this.id} className="group">
        <legend>{this.title}</legend>
        {this.children.map(child => child.render())}
      </fieldset>
    );
  }

  getTotalFields(): number {
    return this.children.reduce((count, child) => {
      if (child instanceof GroupNode) {
        return count + child.getTotalFields();
      }
      return count + 1;
    }, 0);
  }
}

// Usage
const formRoot = new GroupNode('root', 'User Profile');

const personalInfo = new GroupNode('personal', 'Personal Information');
personalInfo.add(new FieldNode('firstName', 'First Name', 'John'));
personalInfo.add(new FieldNode('lastName', 'Last Name', 'Doe'));

const address = new GroupNode('address', 'Address');
address.add(new FieldNode('street', 'Street', '123 Main St'));
address.add(new FieldNode('city', 'City', 'Springfield'));

formRoot.add(personalInfo);
formRoot.add(address);

// In a React component
function FormBuilder({ root }: { root: GroupNode }) {
  return (
    <form>
      {root.render()}
      <p>Total fields: {root.getTotalFields()}</p>
    </form>
  );
}

How It Works

  1. Component defines the common interface for all objects in the tree
  2. Leaf represents individual objects with no children
  3. Composite stores child components and implements child-related operations
  4. Client works with any Component uniformly via the common interface

Real-World Example: File System

// File system nodes
interface FileSystemNode {
  name: string;
  getSize(): number;
  print(indent?: string): void;
}

class File implements FileSystemNode {
  constructor(
    public name: string,
    private size: number
  ) {}

  getSize(): number {
    return this.size;
  }

  print(indent = ''): void {
    console.log(`${indent}📄 ${this.name} (${this.size} bytes)`);
  }
}

class Directory implements FileSystemNode {
  public children: FileSystemNode[] = [];

  constructor(public name: string) {}

  add(node: FileSystemNode): void {
    this.children.push(node);
  }

  getSize(): number {
    return this.children.reduce((sum, child) => sum + child.getSize(), 0);
  }

  print(indent = ''): void {
    console.log(`${indent}📁 ${this.name}/`);
    this.children.forEach(child => child.print(indent + '  '));
  }
}

const root = new Directory('src');
const components = new Directory('components');
components.add(new File('Button.tsx', 1200));
components.add(new File('Card.tsx', 800));
root.add(components);
root.add(new File('index.ts', 150));

root.print();
console.log(`Total size: ${root.getSize()} bytes`);

Production Considerations

  • Use TypeScript discriminated unions instead of classes for simpler React props
  • Consider immutable tree updates with structural sharing for large hierarchies
  • Add parent references for upward traversal, but avoid circular JSON serialization

Common Mistakes

  • Putting child management methods in the base Component interface, forcing Leaf to implement them
  • Not handling deeply nested recursion that could exceed stack limits
  • Mutating the tree structure during iteration

Troubleshooting

  • Pattern does not fit the problem: re-evaluate the forces (performance, scalability, team size, coupling). A pattern is only appropriate when its trade-offs match your constraints.
  • Too many abstractions: if adding a pattern increases complexity without a clear benefit, simplify. Not every module needs a factory, decorator, or strategy.
  • Tight coupling after refactoring: check that interfaces are stable and dependencies point inward.
  • Tests break when the design changes: favor stable contracts over internal structure.
  • Performance regression from indirection: measure before and after. Layers, decorators, and adapters can add latency; cache or inline hot paths if needed.

Further Reading

  • Official documentation: check the current reference for the framework or tool used.
  • Related guides: explore the composite and structural-patterns guides for deeper coverage.
  • Complementary patterns: review design patterns applicable to your technology stack.
  • Public postmortems: study real incidents from teams that faced similar production issues.

Production Notes

  • Deploy gradually using canary or blue-green to catch regressions early.
  • Configure alerts for error rate, p99 latency, and failure rate before enabling in production.
  • Document the rollback in the runbook; test the procedure in staging at least once per quarter.
  • Review structured logs with correlation IDs to trace requests end-to-end during incidents.

Key Takeaways

  • Apply composite pattern for ui component trees in react when you need a practical solution for your use case.
  • Monitor performance after implementation; measure latency, errors, and resource usage before and after.
  • Check the Troubleshooting section for common failures; most have documented root causes with fixes.
  • Keep dependencies updated and run tests in CI to prevent production regressions.

Advanced Topics

Scenario: Composite for UI Component Tree

// Composite pattern: treat individuals and composites uniformly
interface UIComponent {
  render(): string;
  getChildren(): UIComponent[];
  add(child: UIComponent): void;
  remove(child: UIComponent): void;
}

// Leaf: component without children
class Button implements UIComponent {
  constructor(private label: string, private style: string = "") {}
  render(): string {
    return `<button class="${this.style}">${this.label}</button>`;
  }
  getChildren(): UIComponent[] { return []; }
  add(child: UIComponent): void { throw new Error("Cannot add to leaf"); }
  remove(child: UIComponent): void { throw new Error("Cannot remove from leaf"); }
}

class Input implements UIComponent {
  constructor(private type: string, private placeholder: string) {}
  render(): string {
    return `<input type="${this.type}" placeholder="${this.placeholder}" />`;
  }
  getChildren(): UIComponent[] { return []; }
  add(): void { throw new Error("Cannot add to leaf"); }
  remove(): void { throw new Error("Cannot remove from leaf"); }
}

// Composite: component with children
class Container implements UIComponent {
  private children: UIComponent[] = [];
  constructor(private tag: string = "div", private className: string = "") {}
  add(child: UIComponent): void { this.children.push(child); }
  remove(child: UIComponent): void {
    this.children = this.children.filter(c => c !== child);
  }
  getChildren(): UIComponent[] { return [...this.children]; }
  render(): string {
    const childrenHTML = this.children.map(c => c.render()).join("");
    return `<${this.tag} class="${this.className}">${childrenHTML}</${this.tag}>`;
  }
}

// Usage: build UI tree
const form = new Container("form", "login-form");
const header = new Container("div", "form-header");
header.add(new Button("Close", "btn-close"));
const body = new Container("div", "form-body");
body.add(new Input("email", "Email"));
body.add(new Input("password", "Password"));
const footer = new Container("div", "form-footer");
footer.add(new Button("Submit", "btn-primary"));
form.add(header);
form.add(body);
form.add(footer);

console.log(form.render());
// <form class="qj"><div class="qk"><button class="ql">Close</button></div>...

Lessons:

  • Composite treats leaves and composites with the same interface
  • The client does not distinguish between a button and a container
  • UI trees: each node is a UIComponent
  • add/remove on leaves throws error: cannot have children
  • React uses this pattern: elements are uniformly composable

### Composite vs Decorator: which do I use?

Composite is structural: trees of objects with the same interface. Decorator is structural: wraps one object to add behavior. Composite has 0..N children; Decorator has exactly 1. Composite builds trees; Decorator builds chains. Use Composite for UI hierarchies. Use Decorator to add logging, cache, or validation to a service.

## Common Production Pitfalls

- Applying the pattern where no abstraction is needed, adding accidental complexity.
- Letting the pattern leak into unrelated modules and blur ownership boundaries.
- Over-engineering the first implementation instead of starting simple and measuring pain.
- Skipping contract tests, so refactors silently break consumers.
- Ignoring failure modes that the pattern does not cover.
- Using the pattern as a default instead of choosing the right tool for the current scale.
- Forgetting to document when to stop using the pattern and what replaces it.
- Missing observability around the pattern's performance and error propagation.

Frequently Asked Questions

How is this different from Decorator?

Composite builds tree structures with container semantics. Decorator adds responsibilities to a single object without tree semantics.

When should I avoid Composite?

When the hierarchy is flat (only one level) or when child operations make no sense for leaf nodes. For flat structures, consider Decorator instead.