Patterns — Building React Components
Contents
- Props API: variant unions and discriminated props
- Composition: compound components
- State placement: the decision table
- Controlled vs uncontrolled inputs
- Custom hooks
- Measured memoization
- Gotchas
Props API: variant unions and discriminated props
tsx
type ButtonProps = {
variant?: 'primary' | 'danger' | 'ghost'; // one axis, one prop
size?: 'sm' | 'md' | 'lg';
disabled?: boolean; // true independent boolean is fine
onClick: () => void;
children: React.ReactNode;
};
export function Button({ variant = 'primary', size = 'md', ...rest }: ButtonProps) { /* … */ }Discriminated union when props only make sense together:
tsx
type AlertProps =
| { kind: 'info'; message: string }
| { kind: 'error'; message: string; retry: () => void }; // retry exists only for errorsComposition: compound components
tsx
export function Card({ children }: { children: React.ReactNode }) {
return <section className="card">{children}</section>;
}
Card.Header = function CardHeader({ children }: { children: React.ReactNode }) {
return <header className="card-header">{children}</header>;
};
Card.Body = function CardBody({ children }: { children: React.ReactNode }) {
return <div className="card-body">{children}</div>;
};
// Usage — caller controls content, Card controls chrome:
<Card>
<Card.Header><h2>Invoices</h2></Card.Header>
<Card.Body><InvoiceTable rows={rows} /></Card.Body>
</Card>Escape hatch: if slots must be validated/reordered, accept named element props
(header={<h2>…</h2>}) — still elements, not strings-plus-config.
State placement: the decision table
| The value is… | Home | Tool |
|---|---|---|
| Fetched from an API | Query cache | TanStack Query / SWR |
| Shareable via link (filters, tab, page) | URL | useSearchParams |
| Form input mid-edit | Local component | useState / form library |
| Theme, auth, locale (rarely changes, read widely) | Context | createContext |
| Everything else | Lowest component that uses it | useState |
Controlled vs uncontrolled inputs
tsx
// Controlled — other UI reacts per keystroke
const [query, setQuery] = useState('');
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results filter={query} />
// Uncontrolled — value only needed on submit
<form onSubmit={(e) => {
e.preventDefault();
const data = new FormData(e.currentTarget);
save(data.get('email'));
}}>
<input name="email" defaultValue={user.email} />
</form>Custom hooks
tsx
// Reusable logic = hook. Name starts with `use`, returns plain values.
function useDebouncedValue<T>(value: T, delayMs = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(id);
}, [value, delayMs]);
return debounced;
}
const debouncedQuery = useDebouncedValue(query); // any component, same behaviorMeasured memoization
tsx
// 1. Measure first: React DevTools Profiler → record → find components
// re-rendering with unchanged props AND non-trivial render cost.
// 2. Then, and only then:
const Row = React.memo(function Row({ item }: { item: Item }) { /* … */ });
// Parent must keep prop identities stable for memo to work:
const onSelect = useCallback((id: string) => setSelected(id), []);
// Comment the reason so the next reader knows it's load-bearing:
// memo: 5k rows re-rendered on every keystroke before this (Profiler 2026-08).Gotchas
useEffectfor derivation is the top React bug source — if the effect only callssetStatefrom other state/props, delete it and compute during render.- Index as
keybreaks reordering/deletion — use a stable id; index is acceptable only for static, never-reordered lists. - Object/array literals in JSX props (
style={{…}},options={[…]}) defeatReact.memochildren — hoist or memoize them only where memo is in play. - Context triggers re-render of every consumer on any value change — split contexts (state vs dispatch) or keep fast-changing values out of context.
useCallbackwithout a memoized child is pure overhead — it saves nothing.- Stale closure in intervals/subscriptions: include deps or use a ref; an empty dep array freezes captured state at mount time.
'use client'at the page root opts the whole tree out of server rendering — place it at the interactive leaf component instead.