Skip to content
SP 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

Note: This guide follows English-language naming conventions and terminology standards common in international development teams. Examples use English identifiers and comments to maximize compatibility across codebases and tooling.

Bridge Pattern for Decoupling UI Components from Themes

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

FAQ

Q: How is this different from Strategy? A: Strategy changes behavior of a single object. Bridge separates two entire class hierarchies so each can evolve independently.

Q: Can I use this for database backends? A: Yes. The abstraction is your repository interface; implementations are SQL, MongoDB, or DynamoDB adapters.