# 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 errors ``` ## Composition: compound components ```tsx export function Card({ children }: { children: React.ReactNode }) { return
{children}
; } Card.Header = function CardHeader({ children }: { children: React.ReactNode }) { return
{children}
; }; Card.Body = function CardBody({ children }: { children: React.ReactNode }) { return
{children}
; }; // Usage — caller controls content, Card controls chrome:

Invoices

``` Escape hatch: if slots must be validated/reordered, accept named element props (`header={

}`) — 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(''); setQuery(e.target.value)} /> // Uncontrolled — value only needed on submit
{ e.preventDefault(); const data = new FormData(e.currentTarget); save(data.get('email')); }}>
``` ## Custom hooks ```tsx // Reusable logic = hook. Name starts with `use`, returns plain values. function useDebouncedValue(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 behavior ``` ## Measured 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 - **`useEffect` for derivation** is the top React bug source — if the effect only calls `setState` from other state/props, delete it and compute during render. - **Index as `key`** breaks reordering/deletion — use a stable id; index is acceptable only for static, never-reordered lists. - **Object/array literals in JSX props** (`style={{…}}`, `options={[…]}`) defeat `React.memo` children — 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. - **`useCallback` without 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.