You build a component. It’s simple and clean—until someone needs “just one more variant.”
You add a flag, then another, maybe a conditional for a “special case.”
Suddenly, that tidy file has more if
s than a choose-your-own-adventure novel.
That’s exactly the situation the Open/Closed Principle (OCP) aims to prevent.
Open for extension, closed for modification.
In React, that means:
Don’t rewrite or add conditionals every time requirements grow.
Instead, design components that can be extended—and React’s composition model gives you that for free.
The smell: configuration-driven components that keep growing
Here’s a familiar anti-pattern:
// Modal.tsx (BEFORE)
import React from "react";
type ModalProps = {
title: string;
content: string;
showFooter?: boolean;
footerText?: string;
onConfirm?: () => void;
showCloseButton?: boolean;
};
export function Modal({
title,
content,
showFooter = true,
footerText = "OK",
onConfirm,
showCloseButton = true,
}: ModalProps) {
return (
<div className="modal">
<header>
<h3>{title}</h3>
{showCloseButton && <button>x</button>}
</header>
<main>{content}</main>
{showFooter && (
<footer>
<button onClick={onConfirm}>{footerText}</button>
</footer>
)}
</div>
);
}
This works fine—until you need a new variation:
- A modal with a form
- A modal with multiple action buttons
- A modal that shows progress
Each new use case means adding props, conditionals, and more if
statements.
Every change risks breaking something that already works.
The OCP mindset: composition over configuration
Instead of adding more props, make your component composable.
Let consumers decide what goes inside, while keeping the outer shell stable.
// Modal.tsx (AFTER)
import React from "react";
type ModalProps = {
children: React.ReactNode;
};
export function Modal({ children }: ModalProps) {
return (
<div className="modal">
<div className="modal-content">{children}</div>
</div>
);
}
Now the modal just provides structure.
Everything inside is up to the consumer:
// Example usages
<Modal>
<h3>Delete item?</h3>
<p>This action cannot be undone.</p>
<button>Confirm</button>
</Modal>
<Modal>
<h3>Upload progress</h3>
<ProgressBar value={75} />
<div className="actions">
<button>Cancel</button>
<button disabled>Uploading…</button>
</div>
</Modal>
No new props, no conditionals, no internal edits.
The Modal
component is closed for modification, yet open for extension via composition.
Why composition works
Composition flips the responsibility:
- The component defines structure and styling (the container).
- The consumer defines behavior and content (the variation).
The component doesn’t need to know what’s inside—it just needs to provide the right “slot” for it.
That’s the Open/Closed Principle expressed the React way.
Other idiomatic OCP patterns in React
React has a few other ways to create these extension points.
1. Render Props — when extension needs behavior
Render props let the parent control what gets rendered while the component controls how state or effects are managed.
// DataLoader.tsx
import React, { useEffect, useState } from "react";
type DataLoaderProps<T> = {
url: string;
render: (data: T | null, loading: boolean, error: Error | null) => React.ReactNode;
};
export function DataLoader<T>({ url, render }: DataLoaderProps<T>) {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetch(url)
.then((r) => r.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
}, [url]);
return <>{render(data, loading, error)}</>;
}
Consumers decide how to display data, errors, or loading states:
<DataLoader
url="/api/users"
render={(data, loading, error) => {
if (loading) return <Spinner />;
if (error) return <ErrorMessage />;
return <UserList users={data} />;
}}
/>
The data-fetching logic stays untouched; new visuals just plug in.
2. Slots — named composition for structured layouts
Sometimes you want predictable structure (like header/body/footer) while still allowing flexibility.
// Card.tsx
type CardProps = {
header?: React.ReactNode;
children: React.ReactNode;
footer?: React.ReactNode;
};
export function Card({ header, children, footer }: CardProps) {
return (
<div className="card">
{header && <div className="card-header">{header}</div>}
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
Consumers can mix and match freely:
<Card
header={<h2>User Info</h2>}
footer={<button>Save</button>}
>
<ProfileForm />
</Card>
The card stays unchanged even as layouts evolve.
3. Hooks — extending logic instead of markup
Hooks let you extend or compose behavior rather than structure.
// useModal.ts
import { useState } from "react";
export function useModal() {
const [open, setOpen] = useState(false);
const toggle = () => setOpen((o) => !o);
return { open, toggle };
}
Different components can reuse and extend this logic however they like:
const { open, toggle } = useModal();
<button onClick={toggle}>Toggle</button>
{open && (
<Modal>
<h3>Hello</h3>
<button onClick={toggle}>Close</button>
</Modal>
)}
The behavior is extendable and composable, yet the hook itself remains stable.
Practical benefits
- Stable core components — no rewrites for new cases
- Reduced risk — fewer regressions from prop bloat
- Composable API — developers build new behavior from existing parts
- Clear ownership — each unit does one thing and exposes extension seams
When you’re violating OCP
- You’re adding new props or
if
branches every sprint - Components in
common/
change constantly for unrelated features - Consumers need to fork or copy code to get small variations
In those cases, you’re missing a clean extension point.
Wrap-up
React makes the Open/Closed Principle feel natural: design components that can be composed, not configured.
If you’re adding another boolean prop or switch case, ask yourself:
“Could this instead be a child, a render prop, or a hook?”
When you embrace composition, your components stop growing in complexity and start growing in capability.
Top comments (0)