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
Overview
The Bridge pattern decouples an abstraction from its implementation so the two can vary independently. Instead of a class hierarchy that combines component types with rendering platforms, Bridge builds two separate hierarchies: one for abstractions (the components) and one for implementations (the renderers or themes).
This resource applies the pattern to a concrete frontend problem — pluggable UI themes and rendering engines in TypeScript. You’ll see the two-hierarchy structure first with platform renderers, then with themes, and finally how composition enables swapping the theme at runtime. For the general-purpose version with Python and Java examples, see the Bridge pattern.
When to Use
- You need to support multiple platforms or themes without subclassing explosion
- Changes to implementation shouldn’t require recompiling the abstraction layer
- Both dimensions — what the object does and how it renders — evolve independently
- You want to swap rendering behavior at runtime (a theme switcher is the classic case)
Skip it when a single method or config flag would do. If only one dimension varies, a Strategy is simpler and does the same job.
Problem
Supporting Button, Checkbox, and Slider across Web, iOS, and Android produces 9 subclasses: WebButton, iOSButton, AndroidButton, WebCheckbox, iOSCheckbox, and so on. Add a fourth platform or a new component and the matrix grows again — every new type touches every platform, and every new platform touches every type.
The same thing happens with themes. Three components and three themes (light, dark, high-contrast) mean another combinatorial family, and the duplication doesn’t stop at class count: styling logic gets copy-pasted between subclasses, so fixing a border-radius bug means editing every leaf class that shares it. Inheritance forces you to pick one axis as the “main” hierarchy and bolt the other on with conditionals — which is how WebDarkButton and IOSLightCheckbox are born.
Solution
Split the two dimensions. UIComponent (the abstraction) holds a reference to a UIRenderer (the implementation) and delegates rendering to it. Components and renderers multiply independently: M components + N renderers instead of M×N subclasses.
// 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
function submitForm(): void { /* ... */ }
const webButton = new Button(new WebRenderer(), 'Submit', submitForm);
const nativeButton = new Button(new NativeRenderer(), 'Submit', submitForm);
console.log(webButton.render()); // <button onclick="submitForm()">Submit</button>
console.log(nativeButton.render()); // [Native Button: Submit]
TypeScript allows NativeRenderer.renderButton to skip the onClick parameter — implementing classes may accept fewer arguments than the interface declares, which keeps console-style renderers honest about what they ignore.
- Companion code: the bridge-pattern-ui-themes companion repo packages these examples as runnable TypeScript files.
Variation: Theme Bridge
The same structure works for themes instead of platforms — the implementation hierarchy is Theme rather than UIRenderer. Nothing about the bridge itself changes: the abstraction still holds a reference to an interface and delegates to it. That’s the point of the pattern — once the split exists, it generalizes to whatever second dimension you plug in:
// 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
The pattern has four roles:
- Abstraction (
UIComponent) defines the high-level interface clients call and holds the reference to the implementor. - Refined Abstraction (
Button,Checkbox,ThemedButton) extends the abstraction with component-specific behavior — it never renders directly. - Implementation (
UIRenderer,Theme) declares the platform or theme interface the abstractions consume. - Concrete Implementation (
WebRenderer,NativeRenderer,DarkTheme) provides the actual platform-specific output.
The dotted edge is the “bridge”: the abstraction owns a reference to an implementation object and calls into it. Because that link is composition, not inheritance, either hierarchy can grow without touching the other.
Runtime Theme Switching
Composition also means you can replace the implementation while the app runs. Give the component a setter and the theme becomes swappable:
class Button extends UIComponent {
constructor(theme: Theme, private label: string) { super(theme); }
setTheme(theme: Theme): void {
this.theme = theme;
}
render(): string { return this.theme.renderButton(this.label); }
}
let currentTheme: Theme = new LightTheme();
const button = new Button(currentTheme, "Submit");
console.log(button.render()); // Light theme
button.setTheme(new DarkTheme());
console.log(button.render()); // Dark theme
In this variant Theme exposes renderButton directly, so DarkTheme needs its own output — a darker primary and stronger contrast — otherwise the demo can’t show the bridge doing anything:
class DarkTheme implements Theme {
primary = "#1e293b";
background = "#0f172a";
text = "#f8fafc";
renderButton(label: string): string {
return `<button style="background:${this.primary};color:${this.text};padding:10px 20px;border:1px solid ${this.text}">${label}</button>`;
}
}
Trade-offs
- One more level of indirection: every render call hops through an interface. It’s cheap in practice, but it does make stack traces and onboarding slightly harder — a bug report points at
Button, but the defect may live in whichever renderer it holds. - Two hierarchies to keep honest: the implementation interface must cover everything every abstraction needs — adding a method to
UIRendererforces all concrete renderers to implement it, and forgetting one ships a runtime gap, not a compile error. - Worth it only when both axes vary: a single varying dimension pays the complexity cost for no benefit. If you’ll only ever have one theme, keep the styles inline.
- Interface design becomes a real decision: put too much in the implementation interface and it turns into a god-interface; put too little and abstractions start downcasting or bypassing the bridge.
Common Mistakes
- Confusing Bridge with Adapter: Adapter makes unrelated interfaces compatible after the fact; Bridge separates interface from implementation up front, on purpose.
- Reaching for Bridge when a Strategy is enough — Strategy swaps one algorithm; Bridge separates two whole hierarchies.
- Putting rendering logic in the abstraction anyway, so the “bridge” carries no weight and you’re back to M×N classes.
- Making the implementation member
protectedand then trying to assign it from outside — use a setter likesetTheme()or re-instantiate the component.
Further Reading
Frequently Asked Questions
How is this different from Strategy?
Strategy changes the behavior of a single object at runtime. Bridge separates two entire class hierarchies so each evolves independently. If the relationship is permanent — a component always has a theme — Bridge fits better.
Can I use this for database backends?
Yes. The abstraction is your repository interface; implementations are SQL, MongoDB, or DynamoDB adapters — the same two-hierarchy shape applied to persistence instead of rendering.
Is this pattern suitable for small projects?
Usually no. With a handful of components and one platform, the extra interfaces cost more than they save. Introduce it when the M×N pain actually shows up.
Does Bridge work with dependency injection?
Very well. Register the concrete implementation in your DI container and inject it into the abstraction — swapping implementations becomes a configuration change, which pairs naturally with runtime theme switching.
Can I apply it partially, on just one component?
Yes. Teams often start with the component that hurts most — say, theming on Button — and widen the implementation interface as other components join. The pattern tolerates incremental adoption.
Related Resources
Bridge Pattern: Decouple Abstraction from Implementation
Split a class into two hierarchies — abstraction and implementation — so both can evolve independently. Includes Python, Java, and JavaScript examples.
PatternAdapter 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
PatternMediator Pattern for Decoupled UI Components
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