Making React Components Open‑Closed: Extensible Patterns and Refactoring
This article explains how the Open‑Closed Principle applies to React development, demonstrating anti‑patterns and refactoring techniques—including base components, composition, higher‑order components, and custom hooks—to create extensible, maintainable UI elements while keeping tests simple.
The Open‑Closed Principle (OCP) states that software entities should be open for extension but closed for modification; in React this means building components that can be extended without changing existing code.
Problems with Closed Components
A common anti‑pattern is hard‑coding variant handling inside a component, which forces developers to modify the component each time a new variant is added.
<code>// Don't do this
const Button = ({ label, onClick, variant }: ButtonProps) => {
let className = "button";
// Directly modify for each variant
if (variant === "primary") {
className += " button-primary";
} else if (variant === "secondary") {
className += " button-secondary";
} else if (variant === "danger") {
className += " button-danger";
}
return (
<button className={className} onClick={onClick}>
{label}
</button>
);
};
</code>Building Open Components
Refactor by extracting a base component and creating thin variant wrappers, so new variants can be added without touching the original code.
<code>type ButtonBaseProps = {
label: string,
onClick: () => void,
className?: string,
children?: React.ReactNode,
};
const ButtonBase = ({ label, onClick, className = "", children }: ButtonBaseProps) => (
<button className={`button ${className}`.trim()} onClick={onClick}>
{children || label}
</button>
);
// Variant components
const PrimaryButton = (props: ButtonBaseProps) => (
<ButtonBase {...props} className="button-primary" />
);
const SecondaryButton = (props: ButtonBaseProps) => (
<ButtonBase {...props} className="button-secondary" />
);
const DangerButton = (props: ButtonBaseProps) => (
<ButtonBase {...props} className="button-danger" />
);
// Add new variant without modifying original component
const OutlineButton = (props: ButtonBaseProps) => (
<ButtonBase {...props} className="button-outline" />
);
</code>Component Composition Pattern
Using composition and render props enables flexible UI structures while keeping the core component closed.
<code>type CardProps = {
title: string,
children: React.ReactNode,
renderHeader?: (title: string) => React.ReactNode,
renderFooter?: () => React.ReactNode,
className?: string,
};
const Card = ({ title, children, renderHeader, renderFooter, className = "" }: CardProps) => (
<div className={`card ${className}`.trim()}>
{renderHeader ? renderHeader(title) : <div className="card-header">{title}</div>}
<div className="card-content">{children}</div>
{renderFooter && renderFooter()}
</div>
);
const ProductCard = ({ product, onAddToCart, ...props }: ProductCardProps) => (
<Card {...props} renderFooter={() => (
<button onClick={onAddToCart}>Add to Cart - ${product.price}</button>
)} />
);
</code>Higher‑Order Component Extension
A higher‑order component (HOC) can add cross‑cutting concerns such as loading states without altering the wrapped component.
<code>type WithLoadingProps = { isLoading?: boolean };
const withLoading = <P extends object>(WrappedComponent: React.ComponentType<P>) => {
return ({ isLoading, ...props }: P & WithLoadingProps) => {
if (isLoading) {
return <div className="loader">Loading...</div>;
}
return <WrappedComponent {...props as P} />;
};
};
// usage
const UserProfileWithLoading = withLoading(UserProfile);
</code>Custom Hook Following OCP
Encapsulating data fetching in a hook makes it reusable and extensible; additional hooks can compose it without modifying the original implementation.
<code>const useDataFetching = <T,>(url: string) => {
const [data, setData] = useState<T | null>(null);
const [error, setError] = useState<Error | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
fetchData();
}, [url]);
const fetchData = async () => {
try {
const response = await fetch(url);
const result = await response.json();
setData(result);
} catch (e) {
setError(e as Error);
} finally {
setLoading(false);
}
};
return { data, error, loading, refetch: fetchData };
};
const useUserData = (userId: string) => {
const result = useDataFetching<User>(`/api/users/${userId}`);
const updateUser = async (data: Partial<User>) => {
// update logic
};
return { ...result, updateUser };
};
</code>Testing Advantages
Because components are closed for modification, tests can focus on each variant independently, simplifying the test suite.
<code>describe("ButtonBase", () => {
it("renders with custom className", () => {
render(<ButtonBase label="Test" onClick={() => {}} className="custom" />);
expect(screen.getByRole("button")).toHaveClass("button custom");
});
});
// New variant tests
describe("PrimaryButton", () => {
it("includes primary styling", () => {
render(<PrimaryButton label="Test" onClick={() => {}} />);
expect(screen.getByRole("button")).toHaveClass("button button-primary");
});
});
</code>Key Takeaways
Prefer composition over modification—extend via props and render props.
Create a stable base component that remains closed for changes.
Leverage higher‑order components and custom hooks for reusable extensions.
Identify extension points early to anticipate future variations.
Use TypeScript to enforce type‑safe extensions.
Conclusion
Applying the Open‑Closed Principle in React leads to components that are easier to maintain, test, and extend; combined with SOLID principles, it supports building robust, scalable front‑end architectures.
KooFE Frontend Team
Follow the latest frontend updates
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.