Patterns — Cheat Sheet
React · 7 topics. Download the PDF or the Instagram carousel and share it.
Custom Hooks
A custom hook extracts stateful logic so it can be reused and tested. It shares behaviour, never state — every caller gets its own copy.
- ✓A custom hook is a function starting with "use" that calls other hooks — that prefix is what the lint rules key on
- ✓Hooks are matched by call order, so they must never be called conditionally or inside a loop
- ✓Early returns go after all hook calls, never between them
- ✓Hooks share logic, not state — two callers get two independent copies
- ✓Extract when the pattern repeats or deserves its own test; wrapping a single useState is not worth the indirection
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value)
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay)
return () => clearTimeout(id)
}, [value, delay])
return debounced
}
function useLocalStorage(key, initial) {
const [value, setValue] = useState(() => {
try { return JSON.parse(localStorage.getItem(key)) ?? initial }
catch { return initial }
})
useEffect(() => {
try { localStorage.setItem(key, JSON.stringify(value)) } catch {}
}, [key, value])
return [value, setValue]
}
// Return an array for a two-value tuple people will rename,
// an object when there are several named things:
return { data, isLoading, error, refetch }Context — Sharing Without Prop Drilling
Context passes a value to a whole subtree without threading props through it. It is a delivery mechanism, not a state manager, and every consumer re-renders when the value changes.
- ✓Context delivers a value to a subtree; it is not a state manager and has no selectors
- ✓Every consumer re-renders when the provider value changes, so memoise the value object
- ✓Split contexts by concern and by update frequency, and separate state from actions
- ✓Passing rendered children removes much prop drilling without any context at all
- ✓Use context for rarely-changing shared values; use a store for high-frequency state many components slice differently
const ThemeContext = createContext(null)
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('system')
// Without useMemo this object is new on EVERY render of the
// provider, so every consumer re-renders even if theme is the same
const value = useMemo(
() => ({ theme, setTheme, isDark: theme === 'dark' }),
[theme],
)
return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>
}
export function useTheme() {
const ctx = useContext(ThemeContext)
if (!ctx) throw new Error('useTheme must be used inside <ThemeProvider>')
return ctx // never undefined from here on
}
// Export the hook, not the context. Consumers then cannot use it
// outside a provider, and you can change the implementation freely.Choosing a State Management Approach
Most applications need far less global state than they install. Separate server data from client state first, and the remaining global state is usually small enough for a light store.
- ✓Classify state as server, URL, local or global before choosing a tool — most of it is not global
- ✓A query library handles server data, which is the majority of what ends up in stores
- ✓A store's selectors let components subscribe to slices, which is exactly what context cannot do
- ✓Zustand-style stores need no provider and can be read outside React, which suits interceptors and tests
- ✓Redux Toolkit is worth its weight for complex cross-cutting state or an existing codebase, not for caching responses
// 1. SERVER state — fetched, cached, can change without you // -> TanStack Query. Not a store. // This is 70% of what people put in Redux. // 2. URL state — filters, page, tab, search, selected id // -> useSearchParams. Shareable and refresh-proof for free. // 3. LOCAL UI state — is this dropdown open, this input's draft // -> useState in the component that owns it // 4. GLOBAL client state — session, theme, cart, sidebar collapsed // -> context for rarely-changing values // -> a store when it changes often or many components slice it // Work through 1-3 honestly and 4 is often two or three values. // The signal you actually need a store: // - many components read different SLICES of one changing object // - context re-renders are measurably hurting // - state must survive route changes and be updated from anywhere
memo, useMemo and useCallback
Memoisation trades memory and complexity for skipped work. Applied without measuring, it usually makes code slower to read and no faster to run.
- ✓memo skips a re-render, useMemo caches a value, useCallback caches a function identity
- ✓memo is defeated by a new object or arrow function prop — both halves are needed or neither helps
- ✓Justified cases: genuinely expensive computation, dependency identity, memoised expensive children, and context values
- ✓Confirm with the React DevTools Profiler rather than memoising on suspicion
- ✓Moving state down or passing children usually beats memoising, and React Compiler removes most manual cases
// memo — skip re-rendering a component when its props are shallow-equal
const ProblemRow = memo(function ProblemRow({ problem, onOpen }) { ... })
// useMemo — cache a computed VALUE between renders
const stats = useMemo(() => computeStats(attempts), [attempts])
// useCallback — cache a FUNCTION identity between renders
const onOpen = useCallback((slug) => navigate(`/p/${slug}`), [navigate])
// The three work together or not at all. memo compares props with
// Object.is, so a new function or object prop defeats it:
const Row = memo(RowImpl)
<Row problem={p} onOpen={() => open(p.id)} /> // new arrow each render
// -> memo never skips
// Both halves are required: memo on the child AND stable props.
// One without the other is pure cost.Error Boundaries
Without a boundary, one thrown error unmounts your entire application and leaves a blank white page. A boundary contains the damage to one part of the screen.
- ✓An uncaught render error unmounts the entire React tree, leaving a blank page
- ✓Boundaries are still class components — getDerivedStateFromError renders the fallback, componentDidCatch logs
- ✓They do not catch event handlers, async code, timeouts or SSR errors, which need their own handling
- ✓Layer them: one around the app, one per route, and one around each independent widget
- ✓The fallback must offer a way forward, and boundaries should reset on navigation
class ErrorBoundary extends Component {
state = { error: null }
static getDerivedStateFromError(error) {
return { error } // render the fallback
}
componentDidCatch(error, info) {
reportError(error, { componentStack: info.componentStack }) // log it
}
render() {
if (this.state.error) {
return this.props.fallback?.(this.state.error, () => this.setState({ error: null }))
?? <DefaultErrorFallback />
}
return this.props.children
}
}
// react-error-boundary wraps this with a friendlier API, including
// a reset that also resets the subtree:
<ErrorBoundary
FallbackComponent={ErrorFallback}
onReset={() => queryClient.resetQueries()}
resetKeys={[location.pathname]} // auto-reset on navigation
>Portals, Modals and Overlays
A portal renders a child into a different DOM node while keeping it in the React tree. It is how modals escape overflow and stacking contexts — and it is the easy half of building one.
- ✓A portal changes where a child renders in the DOM while keeping it in the React tree, so context and event bubbling still work
- ✓Portals free overlays from ancestor overflow, transform and z-index stacking contexts
- ✓A usable modal needs focus move, focus trap, focus restore, Escape, scroll lock and correct ARIA — the portal is the easy part
- ✓Use a headless library for dialogs rather than rebuilding focus trapping in every project
- ✓Layer overlays with z-index tokens from a single portal root; escalating numbers indicate a stacking-context problem
import { createPortal } from 'react-dom'
function Modal({ children, onClose }) {
return createPortal(
<div className="backdrop" onClick={onClose}>
<div role="dialog" aria-modal="true" onClick={e => e.stopPropagation()}>
{children}
</div>
</div>,
document.body, // where it renders
)
}
// DOM position: document.body — free of any ancestor's overflow,
// transform, filter or z-index stacking context.
// React position: still a child of the component that rendered it,
// so context works and events bubble to the React parent.
// That last part surprises people: a click inside the portal fires
// the onClick of the React ancestor, even though the DOM nodes are
// nowhere near each other.
// Portals are also right for tooltips, dropdowns, popovers and
// toasts — anything that must not be clipped by its container.Designing a Component API
Compound components, render props and headless hooks are three answers to the same question: how flexible should this component be, and who decides how it looks?
- ✓A growing list of boolean props signals that the component should be composed rather than configured
- ✓Compound components share state implicitly through a private context, so callers just assemble parts
- ✓A headless hook provides behaviour with no markup, which is why headless libraries dominate for complex widgets
- ✓Spread remaining props and forward the ref so callers can pass className, aria-* and measure the node
- ✓Support both controlled and uncontrolled usage where it makes sense, and name props for meaning not appearance
// Version 1
<Card title="Two Sum" />
// Eighteen months later
<Card title="Two Sum" showIcon hideFooter compact variant="pro"
headerRight={<Badge />} bodyClassName="p-0" noBorder collapsible />
// Nobody knows which combinations were ever tested.
// Compound components — the caller assembles the parts
<Card>
<Card.Header>
<Card.Title>Two Sum</Card.Title>
<ProBadge />
</Card.Header>
<Card.Body><Description /></Card.Body>
<Card.Footer><SolveButton /></Card.Footer>
</Card>
// Shared state stays implicit, via a private context
const TabsContext = createContext(null)
export function Tabs({ children, defaultValue }) {
const [value, setValue] = useState(defaultValue)
return <TabsContext.Provider value={{ value, setValue }}>{children}</TabsContext.Provider>
}
Tabs.List = TabsList; Tabs.Trigger = TabsTrigger; Tabs.Panel = TabsPanel
// The caller never wires value and onChange between the parts.