StackPractices
intermediate By Mathias Paulenko

Bridge Pattern for Decoupling UI Components from Themes

Separate an abstraction from its implementation so both can vary independently using the Bridge pattern for pluggable UI themes and rendering engines

The Bridge pattern decouples an abstraction from its implementation so that the two can vary independently. Instead of a class hierarchy that combines component types with rendering platforms, Bridge creates two separate hierarchies: one for abstractions (components) and one for implementations (renderers or themes).

When to Use This

  • You need to support multiple platforms or themes without subclassing explosion
  • Changes to implementation should not require recompiling the abstraction layer
  • Both dimensions (what and how) need to evolve independently

Problem

Supporting Button, Checkbox, and Slider across Web, iOS, and Android leads to 9 subclasses: WebButton, iOSButton, AndroidButton, WebCheckbox, iOSCheckbox, and so on.

Solution

// bridge/Renderer.ts
interface UIRenderer {
  renderButton(label: string, onClick: () => void): string;
  renderCheckbox(label: string, checked: boolean): string;
  renderSlider(min: number, max: number, value: number): string;
}

// Implementations
class WebRenderer implements UIRenderer {
  renderButton(label: string, onClick: () => void): string {
    return `<button onclick="${onClick.name}">${label}</button>`;
  }

  renderCheckbox(label: string, checked: boolean): string {
    const checkedAttr = checked ? 'checked' : '';
    return `<label><input type="checkbox" ${checkedAttr}> ${label}</label>`;
  }

  renderSlider(min: number, max: number, value: number): string {
    return `<input type="range" min="${min}" max="${max}" value="${value}">`;
  }
}

class NativeRenderer implements UIRenderer {
  renderButton(label: string): string {
    return `[Native Button: ${label}]`;
  }

  renderCheckbox(label: string, checked: boolean): string {
    return `[Native Checkbox: ${label} ${checked ? '✓' : ' '}]`;
  }

  renderSlider(min: number, max: number, value: number): string {
    return `[Native Slider: ${value}/${max}]`;
  }
}

// Abstractions
abstract class UIComponent {
  constructor(protected renderer: UIRenderer) {}
  abstract render(): string;
}

class Button extends UIComponent {
  constructor(
    renderer: UIRenderer,
    private label: string,
    private onClick: () => void
  ) {
    super(renderer);
  }

  render(): string {
    return this.renderer.renderButton(this.label, this.onClick);
  }
}

class Checkbox extends UIComponent {
  constructor(
    renderer: UIRenderer,
    private label: string,
    private checked: boolean
  ) {
    super(renderer);
  }

  render(): string {
    return this.renderer.renderCheckbox(this.label, this.checked);
  }
}

// Usage
const webRenderer = new WebRenderer();
const nativeRenderer = new NativeRenderer();

const webButton = new Button(webRenderer, 'Submit', () => {});
const nativeButton = new Button(nativeRenderer, 'Submit', () => {});

console.log(webButton.render());     // <button>Submit</button>
console.log(nativeButton.render());  // [Native Button: Submit]

Variation: Theme Bridge

// bridge/Theme.ts
interface Theme {
  getColors(): { primary: string; background: string; text: string };
  getBorderRadius(): number;
  getSpacing(): number;
}

class LightTheme implements Theme {
  getColors() { return { primary: '#007bff', background: '#ffffff', text: '#333333' }; }
  getBorderRadius() { return 4; }
  getSpacing() { return 8; }
}

class DarkTheme implements Theme {
  getColors() { return { primary: '#4dabf7', background: '#1a1a1a', text: '#e0e0e0' }; }
  getBorderRadius() { return 8; }
  getSpacing() { return 12; }
}

abstract class ThemedComponent {
  constructor(protected theme: Theme) {}
}

class ThemedButton extends ThemedComponent {
  render(label: string): string {
    const colors = this.theme.getColors();
    return `
      <button style="
        background: ${colors.primary};
        color: ${colors.text};
        border-radius: ${this.theme.getBorderRadius()}px;
        padding: ${this.theme.getSpacing()}px;
      ">${label}</button>
    `;
  }
}

const light = new ThemedButton(new LightTheme());
const dark = new ThemedButton(new DarkTheme());

How It Works

  1. Abstraction defines the high-level interface clients use
  2. Refined Abstraction extends the abstraction with variant behavior
  3. Implementation defines the platform or theme interface
  4. Concrete Implementation provides platform-specific rendering

Production Considerations

  • Use dependency injection to swap implementations at runtime
  • Bridge works well with Abstract Factory to create matched component families
  • Keep the abstraction thin; delegate all rendering details to the implementation

Common Mistakes

  • Confusing Bridge with Adapter: Adapter makes unrelated interfaces compatible; Bridge separates an interface from implementation
  • Creating a Bridge when a simple Strategy would suffice for single-method variation

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 bridge 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 bridge pattern for decoupling ui components from themes 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: Bridge for Multi-platform UI Themes

// Bridge pattern: separate abstraction (UI) from implementation (theme)
interface Theme {
  primary: string;
  background: string;
  text: string;
  renderButton(label: string): string;
}

// Concrete implementations
class LightTheme implements Theme {
  primary = "#3b82f6";
  background = "#ffffff";
  text = "#1e293b";
  renderButton(label: string): string {
    return `<button style="background:${this.primary};color:white;padding:8px 16px">${label}</button>`;
  }
}

class DarkTheme implements Theme {
  primary = "#3b82f6";
  background = "#1e293b";
  text = "#f1f5f9";
  renderButton(label: string): string {
    return `<button style="background:${this.primary};color:white;padding:8px 16px">${label}</button>`;
  }
}

class HighContrastTheme implements Theme {
  primary = "#ffff00";
  background = "#000000";
  text = "#ffffff";
  renderButton(label: string): string {
    return `<button style="background:${this.primary};color:black;padding:8px 16px;font-weight:bold;border:2px solid white">${label}</button>`;
  }
}

// Abstraction: UI Component
abstract class UIComponent {
  constructor(protected theme: Theme) {}
  abstract render(): string;
}

class Button extends UIComponent {
  constructor(theme: Theme, private label: string) { super(theme); }
  render(): string { return this.theme.renderButton(this.label); }
}

class Card extends UIComponent {
  constructor(theme: Theme, private content: string) { super(theme); }
  render(): string {
    return `<div style="background:${this.theme.background};color:${this.theme.text};padding:16px;border-radius:8px">${this.content}</div>`;
  }
}

// Usage: switch theme without touching components
const lightButton = new Button(new LightTheme(), "Click me");
const darkButton = new Button(new DarkTheme(), "Click me");
const hcButton = new Button(new HighContrastTheme(), "Click me");

// Switch theme at runtime
let currentTheme: Theme = new LightTheme();
const button = new Button(currentTheme, "Submit");
console.log(button.render()); // Light theme

// Switch to dark
currentTheme = new DarkTheme();
button.theme = currentTheme;
console.log(button.render()); // Dark theme

Lessons:

  • Bridge separates abstraction (UI components) from implementation (themes)
  • Adding new theme does not require touching UI components
  • Adding new component does not require touching themes
  • Switch theme at runtime: just swap the implementation
  • Reduces class count: M components + N themes vs M*N

### Bridge vs Strategy: which do I use?

Bridge is structural: separates abstraction hierarchy from implementation hierarchy permanently. Strategy is behavioral: changes algorithm at runtime. If the relationship is permanent (a UI component always has a theme), use Bridge. If behavior changes frequently (sorting algorithm), use Strategy. Bridge has two parallel hierarchies; Strategy has one interface with multiple implementations.


End of document. Review and update quarterly.

## 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 Strategy?

Strategy changes behavior of a single object. Bridge separates two entire class hierarchies so each can evolve independently.

Can I use this for database backends?

Yes. The abstraction is your repository interface; implementations are SQL, MongoDB, or DynamoDB adapters.