Design Patterns
Custom hooks, compound components, render props, HOCs, and controlled vs uncontrolled components.
Overview#
Modern React composes behavior with hooks; older codebases used render props and HOCs to share logic before hooks existed. Know all of them — interviews probe the legacy ones and expect you to explain why hooks replaced them.
flowchart TD
A["Share logic between components"] --> B["Custom hook (modern default)"]
A --> C["Render props (legacy)"]
A --> D["HOC (legacy)"]
E["Share structure/UI composition"] --> F["Compound components"]
Custom hooks — the modern default 🟢#
Extract stateful logic into a use* function. It's just a function calling other hooks; each caller gets its own isolated state. Replaces both render props and HOCs for logic reuse without wrapper nesting.
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn((v) => !v), []);
return [on, toggle];
}
function Panel() {
const [open, toggle] = useToggle();
return <button onClick={toggle}>{open ? "Hide" : "Show"}</button>;
}
Compound components#
Components that share implicit state via Context and only make sense together (like <select>/<option>). The parent owns state; children read it through context. Gives a flexible, declarative API without prop-drilling.
import { createContext, useContext, useState } from "react";
const TabsContext = createContext(null);
function Tabs({ children, defaultValue }) {
const [active, setActive] = useState(defaultValue);
return (
<TabsContext.Provider value={{ active, setActive }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
function Tab({ value, children }) {
const { active, setActive } = useContext(TabsContext);
return (
<button aria-selected={active === value} onClick={() => setActive(value)}>
{children}
</button>
);
}
function TabPanel({ value, children }) {
const { active } = useContext(TabsContext);
return active === value ? <div role="tabpanel">{children}</div> : null;
}
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// Usage — clean, self-describing API:
<Tabs defaultValue="a">
<Tabs.Tab value="a">First</Tabs.Tab>
<Tabs.Tab value="b">Second</Tabs.Tab>
<Tabs.Panel value="a">Panel A</Tabs.Panel>
<Tabs.Panel value="b">Panel B</Tabs.Panel>
</Tabs>
🎯 Consumers arrange the pieces freely while shared state stays encapsulated — the key selling point over a monolithic <Tabs items={[...]}/> prop API.
Render props (legacy)#
Pass a function as a prop (often children) that receives state and returns UI. Solved logic reuse pre-hooks but causes "wrapper hell" / nested callbacks.
function Mouse({ children }) {
const [pos, setPos] = useState({ x: 0, y: 0 });
return (
<div onMouseMove={(e) => setPos({ x: e.clientX, y: e.clientY })}>
{children(pos)}
</div>
);
}
<Mouse>{({ x, y }) => <p>{x}, {y}</p>}</Mouse>
HOCs (legacy)#
A higher-order component is a function Component -> Component that injects props. Still seen in libraries (connect, withRouter). Downsides: prop collisions, wrapper nesting, unclear data origin.
function withUser(Wrapped) {
return function WithUser(props) {
const user = useAuth();
return <Wrapped {...props} user={user} />;
};
}
const ProfileWithUser = withUser(Profile);
⚠️ Hooks superseded HOCs and render props for logic sharing — they avoid extra tree nesting and make data flow explicit. Prefer a custom hook.
Container / presentational (legacy)#
Split "smart" containers (fetch/state) from "dumb" presentational components (props → UI). Hooks made the strict split less necessary since any component can hold logic via hooks, but the separation of concerns idea still guides good structure.
Controlled vs uncontrolled components 🎯#
| Controlled | Uncontrolled | |
|---|---|---|
| Source of truth | React state | The DOM |
| Read value | From state | Via ref |
| Set value | value + onChange |
defaultValue, DOM |
| Use when | Validation, formatting, dependent fields | Simple/one-shot, file inputs, perf |
// Controlled
const [name, setName] = useState("");
<input value={name} onChange={(e) => setName(e.target.value)} />;
// Uncontrolled
const ref = useRef();
<input defaultValue="hi" ref={ref} />; // read ref.current.value on submit
⚠️ <input type="file"> is always uncontrolled (read-only value). Switching an input between controlled/uncontrolled at runtime warns — pick one.
Interview Q&A#
Q1. Why did custom hooks replace render props and HOCs? They share stateful logic without adding wrapper components, avoiding nesting/"wrapper hell", prop collisions, and unclear data flow. Logic is a plain function each caller instantiates independently.
Q2. What are compound components and why use them?
Related components sharing implicit state via Context (e.g. Tabs/Tab/TabPanel). They give a flexible, declarative composition API and encapsulate shared state without prop-drilling.
Q3. Controlled vs uncontrolled inputs?
Controlled inputs derive their value from React state (value+onChange) — best for validation/formatting. Uncontrolled inputs let the DOM hold the value, read via ref — simpler and needed for file inputs.
Q4. What is a HOC and a downside? A function taking a component and returning an enhanced one that injects props. Downsides: wrapper nesting, prop-name collisions, and obscured data origin — reasons hooks replaced them.
Q5. Is container/presentational still relevant? The strict pattern is legacy since hooks let any component hold logic, but its underlying principle — separating data/behavior from pure rendering — still improves testability and reuse.