Section 4: Component Design Patterns — Deep Dive
These patterns are about how you structure components when your app grows. Interviewers use these to probe whether you understand React's philosophy, not just its syntax. The key insight: React is fundamentally about composition — every pattern either embraces that or is a workaround that eventually got replaced by hooks.
The Evolution Timeline (Read This First)
Understanding why each pattern exists saves you from memorizing them in isolation:
Pre-hooks era (Class Components)
│
├─ Problem: "How do I share stateful logic between components?"
│
├─ Solution 1: Higher-Order Components (HOC) → awkward, wrapper hell
│ └─ Problem: prop naming collisions, DevTools noise
│
├─ Solution 2: Render Props → more flexible than HOC
│ └─ Problem: callback pyramid / "Christmas tree" JSX
│
└─ Hooks era (React 16.8+)
├─ Custom Hooks → superseded HOC & Render Props
├─ Composition (children prop) → always was the right answer
└─ Compound Components → still valuable for UI kitsWhen an interviewer asks about HOC or Render Props, your answer should show you know where they came from and what replaced them. That's the senior signal.
4.1 Composition vs Inheritance
The Core Idea
OOP teaches you to share behavior via inheritance: class SpecialButton extends Button. React says: don't do that. Share behavior by putting components inside other components.
| Approach | React's Take |
|---|---|
| Inheritance ("is-a") | SpecialButton extends Button — avoided in React |
| Composition ("has-a") | <Modal><ConfirmDialog /></Modal> — the React way |
🔍 Your Code: Composition in Action — Home.jsx
// Home.jsx — the entire page is assembled by composing smaller components
return (
<div className="page-shell ...">
<div className="w-full max-w-2xl">
{/* These are all separate components composed together */}
<ThemeToggle /> {/* standalone toggle — doesn't need to know about links */}
<OverviewAnalytics /> {/* fetches its OWN data — self-contained */}
<LinkList {/* purely presentational — just renders what you give it */}
links={links}
linksLoading={linksLoading}
handleToggle={handleToggle}
togglingId={togglingId}
onEdit={(link) => setEditingLink(link)}
onDelete={(link) => setDeletingLink(link)}
/>
{/* Modals composed inline — rendered only when needed */}
{editingLink && <EditLinkModal link={editingLink} onClose={...} onSave={handleEdit} />}
{deletingLink && <DeleteConfirm link={deletingLink} onClose={...} onConfirm={handleDelete} />}
</div>
</div>
);What this demonstrates:
ThemeToggle,OverviewAnalytics,LinkList,EditLinkModal,DeleteConfirmare all separate componentsHomecomposes them — it's the conductor, not a god component- Each child component is self-contained (you could drop
ThemeToggleanywhere in the app) - This is NOT inheritance anywhere — no
extendsin sight
📝 The children Prop — The Most Important Composition Tool
// A generic Modal "shell" — it doesn't care what goes inside
function Modal({ children, onClose }) {
return (
<div
className="fixed inset-0 flex items-center justify-center z-50"
onClick={onClose}
>
<div
className="modal-panel w-full max-w-md p-8"
onClick={e => e.stopPropagation()} // prevent click-through
>
{children} {/* ← the consumer controls what renders inside */}
</div>
</div>
);
}
// Usage — different content, same shell behavior:
<Modal onClose={handleClose}>
<EditLinkForm link={link} />
</Modal>
<Modal onClose={handleClose}>
<DeleteConfirmDialog slug={link.slug} onConfirm={handleDelete} />
</Modal>Your DeleteConfirm and EditLinkModal are basically doing this inline — they're each a self-contained modal with their own JSX structure. The children pattern lets you extract the common modal shell into one place and vary only the content.
📝 Specialization via Props (Not Inheritance)
The OOP instinct when you need a "special button" is to extend:
// ❌ OOP instinct (don't do this in React)
class DangerButton extends Button {
render() {
return super.render(); // tight coupling, fragile
}
}React way — specialize via composition/props:
// ✅ React way: compose with props
function Button({ children, variant = 'primary', onClick, disabled }) {
const styles = {
primary: 'bg-blue-600 text-white',
danger: 'bg-red-600 text-white',
ghost: 'bg-transparent border border-current',
};
return (
<button
className={`py-2 px-4 rounded ${styles[variant]}`}
onClick={onClick}
disabled={disabled}
>
{children}
</button>
);
}
// Usage:
<Button variant="primary">Save</Button>
<Button variant="danger" onClick={handleDelete}>Delete</Button>
<Button variant="ghost">Cancel</Button>One component, infinite specializations, no class hierarchy.
[!IMPORTANT] Interview line: "The React team explicitly says there's no use case for inheritance hierarchies in component trees. Composition via
childrenor props gives you the same flexibility without coupling classes to each other."
4.2 Higher-Order Components (HOC)
What It Is
A function that takes a component and returns a new component with added behavior wrapped around it.
withAuth(MyPage)
→ returns a new component that:
- checks if user is logged in
- if yes: renders <MyPage>
- if no: redirects to /login📝 Classic HOC Example: Auth Gating
// withAuth.js — an HOC
function withAuth(WrappedComponent) {
// Return a new component
return function AuthenticatedComponent(props) {
const { isAuth, loading } = AppData(); // custom hook
if (loading) return <LoadingSpinner />;
if (!isAuth) return <Navigate to="/login" />;
// Pass all original props through — WrappedComponent doesn't know about auth
return <WrappedComponent {...props} />;
};
}
// Usage:
const ProtectedHome = withAuth(Home);
const ProtectedCreateLink = withAuth(CreateLink);
// In App.jsx:
<Route path="/" element={<ProtectedHome />} />
<Route path="/create-link" element={<ProtectedCreateLink />} />🔍 Your Code: What Your App Does Instead (the Modern Way)
Your App.jsx does auth gating inline using Navigate — this is simpler than an HOC for route protection:
// App.jsx — Lines 25-34: inline auth gating without HOC
const { isAuth, loading } = AppData();
<Route path="/" element={isAuth ? <Home /> : <Navigate to="/login" />} />
<Route path="/create-link" element={isAuth ? <CreateLink /> : <Navigate to="/login" />} />
<Route path="/analytics/:id" element={isAuth ? <LinkAnalytics /> : <Navigate to="/login" />} />This does the same job as withAuth() HOC but without the extra abstraction layer. For a simple app, this is better — you can see the auth logic right in the route definition.
An HOC becomes worth it when you have 20+ routes that all need the same protection — then a withAuth() wrapper removes the repetition. At 5-6 routes, the inline ternary is cleaner.
📝 Another Real HOC: withLogging (DevMode Performance Tracking)
// Practical HOC: logs when and why a component renders
function withRenderLogging(WrappedComponent) {
const displayName = WrappedComponent.displayName || WrappedComponent.name;
function LoggedComponent(props) {
console.log(`[Render] ${displayName}`, props);
return <WrappedComponent {...props} />;
}
LoggedComponent.displayName = `withRenderLogging(${displayName})`;
return LoggedComponent;
}
// Usage (dev only):
export default withRenderLogging(LinkList);[!NOTE] Always set
displayNameon HOC-returned components. Without it, React DevTools showsComponentinstead ofwithRenderLogging(LinkList), making debugging impossible.
⚠️ HOC Problems (Why Hooks Replaced Them)
// "Wrapper hell" in React DevTools:
<withAuth(withLogging(withTheme(withAnalytics(MyPage))))>
<withLogging(withTheme(withAnalytics(MyPage)))>
<withTheme(withAnalytics(MyPage))>
<withAnalytics(MyPage)>
<MyPage />
</withAnalytics(MyPage)>
</withTheme(...)>
</withLogging(...)>
</withAuth(...)>Problem 1: Prop collision. If two HOCs both inject a prop named user, the second silently overwrites the first.
Problem 2: Origin mystery. Inside MyPage, you receive a user prop — but which HOC injected it? You have to trace up the wrapper stack.
Problem 3: DevTools noise. Nested component tree is hard to read.
The hook equivalent (same logic, none of the problems):
// No wrappers, no prop injection, same logic:
function MyPage() {
const { user } = useAuth(); // was withAuth HOC
const theme = useTheme(); // was withTheme HOC
useRenderLogger('MyPage'); // was withLogging HOC
// ...
}[!TIP] Interview line: "HOCs are still valid for cross-cutting concerns that need to wrap the component render cycle itself (like error boundaries, which can't be hooks). But for logic reuse, custom hooks are simpler — no extra DOM nodes, no prop collisions, no wrapper hell."
4.3 Render Props
What It Is
A component receives a function as a prop (or as children) and calls that function to produce its output, passing it internal state.
<DataFetcher url="/api/links">
{({ data, loading, error }) => ...render something with data...}
</DataFetcher>The component with render props handles the logic, and the consumer decides how to render based on the results.
📝 Classic Render Prop: MouseTracker
// The component manages the stateful logic
function MouseTracker({ children }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
<div
style={{ width: '100%', height: '300px', border: '1px solid #ccc' }}
onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}
>
{/* Call the children function with the state */}
{children(pos)}
</div>
);
}
// Usage — consumer controls rendering:
<MouseTracker>
{({ x, y }) => <p>Mouse is at: {x}, {y}</p>}
</MouseTracker>
// Reuse with totally different rendering:
<MouseTracker>
{({ x, y }) => <Tooltip style={{ left: x, top: y }}>Hello!</Tooltip>}
</MouseTracker>📝 Practical Render Prop: DataFetcher
This is one you'd actually use in a pre-hooks codebase:
// Handles fetching logic, delegates rendering
class DataFetcher extends React.Component {
state = { data: null, loading: true, error: null };
componentDidMount() {
fetch(this.props.url)
.then(r => r.json())
.then(data => this.setState({ data, loading: false }))
.catch(error => this.setState({ error, loading: false }));
}
render() {
// Call the render prop with current state
return this.props.children(this.state);
}
}
// Usage:
<DataFetcher url="/api/v1/my-links">
{({ data, loading, error }) => {
if (loading) return <Spinner />;
if (error) return <ErrorBanner />;
return <LinkList links={data.links} />;
}}
</DataFetcher>The Modern Equivalent: Custom Hook
The same logic as DataFetcher, but as a hook:
function useFetch(url) {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
let cancelled = false;
setState({ data: null, loading: true, error: null });
fetch(url)
.then(r => r.json())
.then(data => { if (!cancelled) setState({ data, loading: false, error: null }); })
.catch(error => { if (!cancelled) setState({ data: null, loading: false, error }); });
return () => { cancelled = true; };
}, [url]);
return state;
}
// Usage — cleaner, flat JSX:
function LinkListPage() {
const { data, loading, error } = useFetch('/api/v1/my-links');
if (loading) return <Spinner />;
if (error) return <ErrorBanner />;
return <LinkList links={data.links} />;
}Same logic. No render function nesting. No extra component in the DevTools tree.
[!TIP] Interview line: "Render props and HOCs solved the same problem — sharing stateful logic without inheritance. Hooks made both largely obsolete for new code. But I still see render props in libraries like React Router's
<Route render={...}>(older API) and Downshift (headless dropdown library)."
4.4 Compound Components
What It Is
This is different from HOC/Render Props — it's not about logic reuse, it's about flexible UI kit design.
A group of components that implicitly share state via Context, letting the consumer control the layout while the parent owns the shared logic.
// The consumer controls structure:
<Tabs>
<Tabs.List>
<Tabs.Tab>Overview</Tabs.Tab>
<Tabs.Tab>Analytics</Tabs.Tab>
<Tabs.Tab>Settings</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel><OverviewContent /></Tabs.Panel>
<Tabs.Panel><AnalyticsContent /></Tabs.Panel>
<Tabs.Panel><SettingsContent /></Tabs.Panel>
</Tabs.Panels>
</Tabs>The <Tabs> parent manages activeIndex state. The <Tabs.Tab> children read/write it via Context without you passing it explicitly.
📝 Building a Real Compound Component
This is the kind of thing you'd be asked to design live:
// 1. Create the context
const TabsContext = createContext(null);
// 2. Parent component — owns the shared state, provides it
function Tabs({ children, defaultIndex = 0 }) {
const [activeIndex, setActiveIndex] = useState(defaultIndex);
return (
<TabsContext.Provider value={{ activeIndex, setActiveIndex }}>
<div>{children}</div>
</TabsContext.Provider>
);
}
// 3. Child components — consume the shared state via Context
Tabs.List = function TabsList({ children }) {
return <div role="tablist" className="flex gap-2 border-b">{children}</div>;
};
Tabs.Tab = function Tab({ children, index }) {
const { activeIndex, setActiveIndex } = useContext(TabsContext);
const isActive = activeIndex === index;
return (
<button
role="tab"
aria-selected={isActive}
onClick={() => setActiveIndex(index)}
className={`px-4 py-2 text-sm border-b-2 transition-colors ${
isActive ? 'border-blue-500 text-blue-600' : 'border-transparent text-gray-500'
}`}
>
{children}
</button>
);
};
Tabs.Panels = function TabsPanels({ children }) {
return <div className="mt-4">{children}</div>;
};
Tabs.Panel = function TabPanel({ children, index }) {
const { activeIndex } = useContext(TabsContext);
if (activeIndex !== index) return null; // only render active panel
return <div role="tabpanel">{children}</div>;
};Why this is better than a config-array approach:
// ❌ Config array — consumer can't control layout or insert custom elements:
<Tabs items={[
{ label: 'Overview', content: <OverviewContent /> },
{ label: 'Analytics', content: <AnalyticsContent /> },
]} />
// ✅ Compound — consumer controls everything between the components:
<Tabs>
<CustomHeader /> {/* can insert anything */}
<Tabs.List>
<Tabs.Tab index={0}>Overview</Tabs.Tab>
<MyBetaBadge /> {/* can add decorations */}
<Tabs.Tab index={1}>Analytics</Tabs.Tab>
</Tabs.List>
<Tabs.Panels>
<Tabs.Panel index={0}><OverviewContent /></Tabs.Panel>
<Tabs.Panel index={1}><AnalyticsContent /></Tabs.Panel>
</Tabs.Panels>
</Tabs>🔍 Where This Pattern Exists in Your App (Implicitly)
Your LinkAnalytics.jsx uses the BreakdownCard component multiple times with different data:
// BreakdownCard — Lines 216-250: generic card that's specialized via props
const BreakdownCard = ({ title, data, dataKey }) => {
const total = data.reduce((sum, item) => sum + item.clicks, 0);
return (
<div className="card">
<h3>{title}</h3>
{data.map(item => (
<div key={item[dataKey]}>
<span>{item[dataKey]}</span> {/* ← dataKey determines which field to show */}
<div style={{ width: `${(item.clicks / total) * 100}%` }} />
</div>
))}
</div>
);
};
// Same component, 5 different data sources:
<BreakdownCard title="Top Referrers" data={referrers} dataKey="referrer" />
<BreakdownCard title="Top Countries" data={countries} dataKey="country" />
<BreakdownCard title="Devices" data={devices.devices} dataKey="label" />This is composition by specialization via props — one generic component configured for different use cases. It's the correct React approach, and it's a microcosm of the larger Compound Components philosophy.
📝 A Compound Component Relevant to Your App: Accordion for Analytics
If you were building an "expand to see details" section for each link:
const AccordionContext = createContext(null);
function Accordion({ children }) {
const [openId, setOpenId] = useState(null);
const toggle = (id) => setOpenId(prev => prev === id ? null : id);
return (
<AccordionContext.Provider value={{ openId, toggle }}>
<div>{children}</div>
</AccordionContext.Provider>
);
}
Accordion.Item = function AccordionItem({ id, children }) {
return <div className="border-b">{children}</div>;
};
Accordion.Trigger = function AccordionTrigger({ id, children }) {
const { openId, toggle } = useContext(AccordionContext);
return (
<button onClick={() => toggle(id)} className="w-full text-left py-3 flex justify-between">
{children}
<span>{openId === id ? '▲' : '▼'}</span>
</button>
);
};
Accordion.Content = function AccordionContent({ id, children }) {
const { openId } = useContext(AccordionContext);
if (openId !== id) return null;
return <div className="py-3">{children}</div>;
};
// Usage — consumer controls structure, accordion owns open/close logic:
<Accordion>
{links.map(link => (
<Accordion.Item key={link.id} id={link.id}>
<Accordion.Trigger id={link.id}>{link.title}</Accordion.Trigger>
<Accordion.Content id={link.id}>
<BreakdownCard title="Clicks" data={link.analytics} dataKey="date" />
</Accordion.Content>
</Accordion.Item>
))}
</Accordion>[!TIP] Interview line for a "design a Tabs component" question: "I'd use the Compound Components pattern with Context —
<Tabs>owns the active index state and provides it via Context, and<Tabs.Tab>/<Tabs.Panel>consume it. This gives the consumer full layout control while keeping the shared state logic encapsulated in one place."
4.5 Container / Presentational Pattern
What It Is
Split a component into two roles:
- Container ("smart") — fetches data, holds state, handles events, has NO JSX
- Presentational ("dumb") — receives props, renders UI, has NO logic/state
🔍 Your Code Is Already Doing This — Home.jsx + LinkList.jsx
Container — Home.jsx: (data, state, handlers — no actual UI rendering of links)
// Home.jsx handles ALL the logic:
const [links, setLinks] = useState([]); // ← state
useEffect(() => { fetchData(); }, []); // ← data fetching
const handleToggle = async (linkId) => { ... }; // ← event handler
const handleEdit = async (linkId, payload) => { ... }; // ← event handler
const handleDelete = async (linkId) => { ... }; // ← event handler
// Passes prepared data + handlers DOWN to presentational component:
<LinkList
links={links} // data ↓
linksLoading={linksLoading} // state ↓
handleToggle={handleToggle} // handler ↓
onEdit={(link) => setEditingLink(link)} // handler ↓
onDelete={(link) => setDeletingLink(link)} // handler ↓
/>Presentational — LinkList.jsx: (NO state, NO API calls — pure rendering)
// LinkList.jsx receives everything via props:
export const LinkList = ({ links, linksLoading, handleToggle, togglingId, onEdit, onDelete }) => {
return (
<div>
{linksLoading ? <Spinner /> : links.map(link => (
<div key={link.id}>
{/* just renders what it receives — no fetching, no state */}
<a href={`${BACKEND_URL}/${link.slug}`}>{link.slug}</a>
<button onClick={() => onEdit(link)}>Edit</button>
<button onClick={() => onDelete(link)}>Delete</button>
<button onClick={() => handleToggle(link.id)}>Toggle</button>
</div>
))}
</div>
);
};This is textbook Container/Presentational. Home is the container. LinkList is purely presentational — it receives fully-prepared data and callbacks, never touches the API.
🔍 Another Example: OverviewAnalytics.jsx — Self-Contained Component
Interestingly, OverviewAnalytics is the opposite of the pattern — it's a component that handles its own data fetching AND its own rendering:
// OverviewAnalytics.jsx — does its OWN fetching inside (NOT the container/presentational split)
export const OverviewAnalytics = () => {
const [loading, setLoading] = useState(true);
const [overview, setOverview] = useState(null);
useEffect(() => {
api.get("/api/v1/analytics/overview").then(r => setOverview(r.data));
}, []);
return (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-6">
{/* renders its own data */}
</div>
);
};This is NOT container/presentational — it's a self-contained "smart" component. This is fine for a component that owns its entire concern (overview stats are only ever shown in one place). Container/Presentational is most valuable when you want to reuse the presentational part with different data sources.
📝 Practical Trade-off Table
LinkList is presentational ← can you test it in isolation? YES.
Just pass mock `links` array → see if it renders correctly.
No API mocking needed.
OverviewAnalytics is self-contained ← to test it, you MUST mock the API.
But it's simpler to write and understand.| Container + Presentational Split | Self-Contained Component | |
|---|---|---|
| Testability | ✅ Easy — test presentation with mock props | ❌ Needs API mocking |
| Reusability | ✅ Presentational part reusable anywhere | ❌ Only works in its own context |
| Simplicity | ❌ More files, more boilerplate | ✅ Everything in one place |
| Best for | Shared UI, complex components | Localized, single-use components |
📝 Modern Reality — The Pattern Has Evolved
With hooks, the strict "container = class, presentational = functional" distinction blurred. Today:
// Modern equivalent: co-locate logic in one functional component with hooks
// No need to literally split into two files unless you want the testability/reuse benefit
function LinkList() {
const { data: links, loading } = useFetch('/api/v1/my-links'); // custom hook handles fetching
const handleDelete = useCallback(() => { ... }, []);
return (
<div>
{loading ? <Spinner /> : links.map(link => <LinkRow key={link.id} link={link} onDelete={handleDelete} />)}
</div>
);
}This is effectively a container and presentational merged — but the LinkRow is still a pure presentational component. The principle (separate data concerns from render concerns) lives on, even if the two-file naming convention doesn't.
[!IMPORTANT] Interview line: "I still apply the principle — presentational components get their data from props and don't have side effects. But I don't rigidly name files 'Container' vs 'Presentational' anymore; hooks make it easy to separate those concerns within a single component or to extract them into a custom hook."
🔥 Full Section Interview Quick-Fire
| Question | Answer |
|---|---|
| Why does React avoid inheritance? | React's composition model is more flexible; the team found no real use cases for component class hierarchies. children prop and props-as-specialization replace inheritance entirely. |
| What is an HOC? Write one in 30s. | A function that takes a component, returns a new one with added behavior. const withAuth = C => props => isAuth ? <C {...props}/> : <Navigate to="/login"/> |
| HOC vs custom hook? | HOC wraps the render (adds extra DOM nodes, prop collisions, DevTools noise). Hook is called inside the component — cleaner, no wrapper, no collisions. Prefer hooks for logic reuse. |
| When would you still use an HOC? | When you need to wrap the rendering itself — e.g., error boundaries (class-only), legacy class components, third-party libraries that expect HOC pattern. |
| What's a render prop? | A component that accepts a function as children (or a named prop) and calls it with internal state to let the consumer control rendering. |
| Why is render prop better than HOC? | More flexible — consumer controls rendering directly. No prop collision. But nesting multiple render props gets messy ("callback hell"). |
| What replaced both HOC and render props? | Custom hooks — same logic reuse, no extra components, no nesting, cleaner composition. |
| What are Compound Components? | Components that share implicit state via Context so the consumer can arrange them flexibly (e.g., <Tabs>, <Accordion>). |
| Container vs Presentational? | Container handles data/logic, presentational just renders props. Your Home.jsx is the container; LinkList.jsx is presentational. Valuable for testability and reuse. |
🏋️ Practice Exercises
-
Look at App.jsx — convert the inline
isAuth ? <Home/> : <Navigate/>pattern into anwithAuthHOC. Then argue (out loud) whether the HOC or the inline ternary is better here and why. -
LinkAnalytics.jsx has a
BreakdownCardat the bottom — explain why it's defined in the same file but would make more sense as a Compound Component if analytics grew significantly. -
Write from scratch: A
<Modal>+<Modal.Header>+<Modal.Body>+<Modal.Footer>compound component. The parent should provide backdrop dismiss behavior; children access it via Context. This is a live-coding favorite — practice until it's automatic. -
Explain out loud the evolution: HOC → Render Props → Custom Hooks. Why did each replace the previous? What problem did each solve? What problems did it have?