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
- Abstraction defines the high-level interface clients use
- Refined Abstraction extends the abstraction with variant behavior
- Implementation defines the platform or theme interface
- 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.
Is this pattern suitable for small projects?
For small projects with few components, this pattern may add unnecessary complexity. Start simple and introduce the pattern when you feel the pain it solves.
How does this pattern compare to alternatives?
Each pattern makes different trade-offs. Review the variants table above and consider your specific constraints: team size, performance requirements, and future scaling plans.
Can I partially apply this pattern?
Yes. Many teams adopt patterns incrementally. Start with the core idea and add sophistication as needed. The pattern is a guide, not a strict blueprint.
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. Related Resources
Adapter Pattern for Integrating External REST APIs
Use the Adapter pattern to normalize responses from external REST APIs into a consistent internal model without leaking third-party formats into your domain
PatternAbstract Factory for Cross-Platform UI Component Families
Create families of related objects without specifying concrete classes, enabling platform-specific implementations that share a common interface
PatternDependency Injection Container in TypeScript
Build a lightweight DI container that resolves class dependencies automatically, enabling testable, loosely-coupled applications without frameworks like Angular or InversifyJS
PatternFlyweight Pattern for Efficient Large-Scale Object Sharing
Use the Flyweight pattern to minimize memory usage by sharing as much data as possible between similar objects, essential for rendering large datasets
PatternMediator Pattern for Loose Component Coupling in
Reduce chaotic dependencies between UI components by introducing a mediator that centralizes communication, preventing explicit references between peers
RecipeSPA Performance: Code Splitting and Lazy Loading
Improve single-page application load times by splitting bundles at route and component level, implementing lazy loading with React.lazy and live imports