Advanced Component Patterns — Compound Components, Render Props, and HOCs
Day 17 of 40 — React System Design Series

Hi, I’m Richa — a Senior Frontend Engineer with 5+ years of experience building scalable, production-grade web interfaces for enterprise and consumer applications. I work primarily with React, TypeScript, and modern frontend architectures, focusing on component systems, performance, and maintainability. Most of my experience comes from building real-world products in regulated domains like banking and insurance, where clarity, reliability, and long-term ownership matter more than quick demos. Through this blog, I write about frontend engineering fundamentals, scalable UI design, problem-solving, and the lessons I’ve learned working on large codebases. My goal is to share practical insights — not shortcuts — for developers who want to grow strong engineering foundations. I also mentor early-career developers and strongly believe that curiosity, asking the right questions, and understanding why something works are more important than memorizing tools. If you’re serious about improving as an engineer, you’re in the right place.
Hey everyone, Richa here! 👋
Day 17. And today we go somewhere that separates mid-level devs from senior devs in React interviews.
Advanced component patterns.
Compound components. Render props. Higher-Order Components.
These aren't just interview trivia. They're solutions to real problems — situations where basic props and state aren't expressive enough. Where you need components that are flexible, composable, and don't force consumers into rigid APIs.
I used to just add more props when a component needed more flexibility. showHeader, showFooter, headerTitle, footerContent... Until one day a component had 23 props and I realised something had gone very wrong.
These patterns are the answer to that problem.
Pattern 1 — Compound Components
The problem it solves: You have a component that is made of multiple related pieces. The consumer needs to control the layout and which pieces appear — but the pieces need to share state internally.
Classic example: a Tabs component.
The naive approach:
// ❌ The prop-heavy version — rigid and hard to customise
<Tabs
tabs={['Overview', 'Details', 'Reviews']}
activeTab={activeTab}
onTabChange={setActiveTab}
tabContent={[<Overview />, <Details />, <Reviews />]}
showBorder={true}
tabPosition="top"
contentPadding="large"
/>
This grows forever. Every new customisation request means a new prop.
The compound component approach:
// ✅ Compound components — consumer controls the structure
<Tabs defaultTab="overview">
<Tabs.List>
<Tabs.Tab value="overview">Overview</Tabs.Tab>
<Tabs.Tab value="details">Details</Tabs.Tab>
<Tabs.Tab value="reviews">Reviews</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="overview"><Overview /></Tabs.Panel>
<Tabs.Panel value="details"><Details /></Tabs.Panel>
<Tabs.Panel value="reviews"><Reviews /></Tabs.Panel>
</Tabs>
The consumer controls the layout completely. The tabs and panels communicate through shared state — but that state is internal. The consumer doesn't manage it.
Building it:
// components/Tabs/Tabs.tsx
import { createContext, useContext, useState, type ReactNode } from 'react';
type TabsContextType = {
activeTab: string;
setActiveTab: (tab: string) => void;
};
const TabsContext = createContext<TabsContextType | null>(null);
function useTabsContext() {
const ctx = useContext(TabsContext);
if (!ctx) throw new Error('Tabs sub-components must be used inside <Tabs>');
return ctx;
}
// Root component
function Tabs({
children,
defaultTab,
}: {
children: ReactNode;
defaultTab: string;
}) {
const [activeTab, setActiveTab] = useState(defaultTab);
return (
<TabsContext.Provider value={{ activeTab, setActiveTab }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// Sub-components
function TabList({ children }: { children: ReactNode }) {
return <div className="tab-list" role="tablist">{children}</div>;
}
function Tab({ value, children }: { value: string; children: ReactNode }) {
const { activeTab, setActiveTab } = useTabsContext();
return (
<button
role="tab"
aria-selected={activeTab === value}
className={`tab ${activeTab === value ? 'active' : ''}`}
onClick={() => setActiveTab(value)}
>
{children}
</button>
);
}
function TabPanel({ value, children }: { value: string; children: ReactNode }) {
const { activeTab } = useTabsContext();
if (activeTab !== value) return null;
return <div role="tabpanel" className="tab-panel">{children}</div>;
}
// Attach sub-components to the root
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
export { Tabs };
The pattern:
Create a context to share state between sub-components
Root component owns the state and provides it
Sub-components read from context
Attach sub-components to the root with
Tabs.List = TabList
Other great uses for compound components:
<Select>/<Select.Option><Accordion>/<Accordion.Item>/<Accordion.Content><Menu>/<Menu.Item>/<Menu.Divider><Form>/<Form.Field>/<Form.Error>
Pattern 2 — Render Props
The problem it solves: You want to share stateful logic between components, but the rendering output is completely different in each case.
Classic example: a component that tracks mouse position, hover state, or scroll position — and lets the consumer decide what to render with that data.
// A component that tracks hover state and shares it via render prop
type HoverTrackerProps = {
children: (isHovered: boolean) => ReactNode;
};
function HoverTracker({ children }: HoverTrackerProps) {
const [isHovered, setIsHovered] = useState(false);
return (
<div
onMouseEnter={() => setIsHovered(true)}
onMouseLeave={() => setIsHovered(false)}
>
{children(isHovered)} {/* call children as a function */}
</div>
);
}
Usage:
// Consumer controls what gets rendered with the hover state
<HoverTracker>
{(isHovered) => (
<button style={{ background: isHovered ? '#0070f3' : '#fff' }}>
{isHovered ? 'Click me!' : 'Hover over me'}
</button>
)}
</HoverTracker>
// Same logic, completely different output
<HoverTracker>
{(isHovered) => (
<img
src={isHovered ? '/hover-image.png' : '/default-image.png'}
alt="product"
/>
)}
</HoverTracker>
The hover logic lives in one place. The rendered output is totally flexible.
A more real-world example — a data fetching render prop:
type FetchRenderProps<T> = {
url: string;
children: (state: {
data: T | null;
isLoading: boolean;
error: string | null;
}) => ReactNode;
};
function Fetch<T>({ url, children }: FetchRenderProps<T>) {
const [data, setData] = useState<T | null>(null);
const [isLoading, setIsLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetch(url)
.then(res => res.json())
.then(setData)
.catch(err => setError(err.message))
.finally(() => setIsLoading(false));
}, [url]);
return <>{children({ data, isLoading, error })}</>;
}
// Usage
<Fetch<User[]> url="/api/users">
{({ data: users, isLoading, error }) => {
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <UserList users={users!} />;
}}
</Fetch>
💡 Honest note: In 2025, custom hooks have largely replaced render props for logic sharing.
useHover(),useFetch()— cleaner, less nesting, same idea. But render props still show up in component APIs (especially in libraries like React Query, Formik, and Downshift) and in interview questions. Know the pattern.
Pattern 3 — Higher-Order Components (HOCs)
The problem it solves: You want to add behaviour to many components without modifying each one — wrapping them to enhance them.
An HOC is a function that takes a component and returns a new, enhanced component.
// An HOC that adds loading + error boundary behaviour
function withLoadingState<P extends object>(
WrappedComponent: React.ComponentType<P>
) {
return function WithLoadingState(
props: P & { isLoading?: boolean; error?: string | null }
) {
const { isLoading, error, ...rest } = props;
if (isLoading) return <Spinner />;
if (error) return <ErrorMessage message={error} />;
return <WrappedComponent {...(rest as P)} />;
};
}
// Usage
const UserListWithLoading = withLoadingState(UserList);
// Now UserList automatically handles loading + error states
<UserListWithLoading
users={users}
isLoading={isLoading}
error={error}
/>
A more practical HOC — auth guard:
function withAuth<P extends object>(WrappedComponent: React.ComponentType<P>) {
return function WithAuth(props: P) {
const { user, isLoading } = useAuthContext();
if (isLoading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
return <WrappedComponent {...props} />;
};
}
// Wrap any component to make it require auth
const ProtectedDashboard = withAuth(DashboardPage);
const ProtectedProfile = withAuth(ProfilePage);
When HOCs make sense:
Cross-cutting concerns that apply to many components (logging, error boundaries, auth checks)
When you need to work with class components (render props and hooks aren't available)
Wrapping third-party components to add behaviour without modifying them
When HOCs become a problem:
// ❌ HOC hell — hard to follow, prop collision issues
export default withRouter(
withAuth(
withTheme(
withLogging(
MyComponent
)
)
)
);
Multiple HOCs nested together become hard to read and debug. Modern React usually favours hooks over HOCs for logic reuse.
💡 The 2025 reality: HOCs are less common in new code but you'll encounter them everywhere in established codebases and libraries. React Router v5 used
withRouter. Redux usedconnect. Many component libraries still use them. Know how they work.
Choosing the right pattern
| Pattern | Best for | Avoid when |
|---|---|---|
| Compound components | Related UI pieces that share state internally (Tabs, Accordion, Select, Menu) | Simple components with no internal state sharing |
| Render props | Sharing stateful logic where render output varies widely | Simple cases where a custom hook is cleaner |
| HOCs | Cross-cutting concerns, wrapping third-party components, class component support | New code where hooks can do the same job more cleanly |
The modern hierarchy (2025):
Custom hooks — first choice for sharing logic (Day 18)
Compound components — first choice for flexible component APIs
Render props — when the consumer really needs to control rendering
HOCs — when wrapping third-party components or supporting class components
Today's takeaway
These patterns exist because React components have a fundamental tension: you want to be flexible enough for any use case but not so generic that the API becomes a wall of props.
Compound components — let consumers control structure and layout, while the root component manages shared state
Render props — let consumers control what gets rendered with shared logic
HOCs — let you add behaviour to any component without modifying it
The senior move isn't reaching for these patterns by default. It's recognising the exact moment when a component is hitting the limits of basic props and state — and knowing which pattern fits.
See you tomorrow for Day 18 — Designing custom hooks: what belongs in a hook vs a component vs a utility.
← Day 16 — Folder Structure → Day 18 — Designing Custom Hooks
Part of the React System Design Series — 40 days, one topic per day.






