224 companion flashcards · AI-assisted study content · Open the deck →
This deck dives into the trickier corners of React Hooks, moving beyond basic useState and useEffect into the patterns and pitfalls that show up in real applications. Cards explore questions like why effects can run twice in development, what causes infinite loops, and how useCallback and useMemo actually differ in practice. You'll also find prompts on custom hook conventions, the Rules of Hooks, and patterns for fetching data, debouncing values, and avoiding stale closures.
It's best suited for developers who already feel comfortable writing components with hooks and want to deepen their mental model. If you've shipped a few React projects but still get caught off guard by re-renders, stale state, or performance hiccups when adding Context, this deck will help you build more reliable instincts for when to reach for which hook.
To get the most out of these cards, try answering in your own words before flipping, and come back to the trickier ones after a day or two so the patterns have time to settle. Pair the review with a small coding exercise, like refactoring a component to use useReducer or adding cleanup to a fetch hook, so the concepts move from memory into muscle memory.
React Hooks come with two hard rules that govern where and how they can be called. First, hooks must always be invoked at the top level of a React function — inside a component or another hook — never inside loops, conditions, or try/catch blocks. The reason is that React reconciles hook state by call order: each render must call the same hooks in the same order, or the linter will refuse to compile and React will throw at runtime. Second, hooks can only be called from React functions: components, custom hooks, or other hooks. Calling them from plain utilities or module-level code has no component instance to attach state to, which is why libraries expose them with the use prefix. The react-hooks/exhaustive-deps ESLint rule and React DevTools both rely on this naming convention to recognize hooks and inspect their state. Conditional hooks like if (x) useState(0) are the canonical violation: the hook count changes between renders, the call order drifts, and React can no longer associate state with the right slot.
StrictMode is the other big behavioral constraint to internalize. In development, React intentionally mounts every component, runs its effects, unmounts it, and remounts it again. This double-invocation surfaces effects that miss cleanups: a socket left open, a timer left ticking, a subscription left dangling. Production builds skip this rehearsal, so the same code behaves differently between dev and prod, but the discipline you build under StrictMode is what makes your effects safe in production. You should never write logic that assumes a single execution. The same principle explains why render itself runs twice in StrictMode: React wants to surface any non-pure side effect that mutates state during render.
Another foundational trap is calling setState directly inside the render body. Doing so schedules another render, which re-runs the function, which calls setState again — an infinite loop. The exception is the "derived state from props" pattern, where you setState only if a prop actually changed, but React's official guidance is to compute derived values during render or to reset state via a key prop instead. Related mistakes include setting state on another component from inside render (defer to useEffect), and conditionally rendering numbers in JSX with {count && <X/>}, which renders the literal 0 when count is zero. Coerce with !!count or write {count > 0 ? <X/> : null}. Throughout all of this, the underlying theme is the same: hooks give you a controlled way to express state and effects, and they only stay controlled if you respect their invariants.
useEffect is the standard place to synchronize a component with an external system: subscriptions, timers, DOM mutations, analytics, and network requests. Three dependency-array shapes control its lifecycle. With [], the effect runs once on mount and its cleanup runs on unmount. With [dep], it re-runs whenever the dep changes, and cleanup runs before each re-run as well as on unmount. With no array at all, it runs after every render — almost never what you want. The cleanup function must reverse whatever the effect did: clear timers, remove listeners, abort fetches, disconnect observers. Forgetting cleanup is the single most common source of memory leaks and double-fire bugs, especially under StrictMode. Effects run in declaration order on mount; cleanup runs in reverse order on unmount, which is why later effects can rely on earlier ones still being alive.
Timing matters. useEffect runs asynchronously after the browser paints, so users may briefly see stale UI before the effect runs. When you need to read layout — for example, getBoundingClientRect — and apply changes synchronously to avoid flicker, use useLayoutEffect; it blocks the paint. Heavy work in useLayoutEffect delays rendering, so use it sparingly and only for measurement-and-update patterns. Effects do not participate in Suspense: useEffect cannot throw a Promise and be caught by a boundary, because by the time it runs the paint has already happened. For data that needs Suspense integration, use a data library, Server Components, or the use hook in render.
Two recurring footguns deserve special attention. First, the effect callback cannot be async, because returning a Promise breaks the contract that expects either a cleanup function or void. The fix is to define an inner async function and invoke it, or to use AbortController for cancellable work and call controller.abort() in cleanup. Second, an effect that updates state based on the same state it reads will loop unless guarded — track a request id in a ref and ignore stale responses, or set an exit condition in a ref. The same race-condition pattern shows up with two effects in flight writing to the same state: the slower one overwrites the newer result, so track a monotonically increasing id and discard responses whose id is no longer the latest. Whenever you find yourself reaching for useEffect, ask the "you might not need an effect" question first: derived values, event handlers, and expensive computations often belong outside it, with the effect reserved for genuine synchronization with an external system.
useRef returns a mutable container whose .current property persists across renders without triggering a re-render when mutated. That makes it the right tool for storing DOM nodes, timer ids, the latest value of some state, instance-like variables on a function component, and WebSocket or observer handles. Because writing to .current does not schedule a render, refs are also the standard escape hatch when you need to read or mutate something synchronously inside a callback. Two TypeScript flavors matter: RefObject<T> exposes { current: T \| null } and is set by React, while MutableRefObject<T> exposes { current: T } and is something you manage directly.
The canonical pattern for reading the latest state inside a long-lived callback is the ref-plus-effect combo. You create a ref, then in an effect you assign the current state into the ref on every render. Inside the callback — say, an interval or an event listener — you read ref.current instead of the closed-over state variable. This is how you fix the classic stale-closure trap where setInterval(() => console.log(count), 1000) logs the initial value forever because the callback captured the count from the first render. A usePrev hook (returning the value from the previous render) and a useIsMounted hook (returning whether the component is still mounted) follow the same pattern: assign in an effect, read in callbacks. Stable callbacks that always see the latest state have been an open problem; the community pattern combines useRef with useCallback to produce a function whose identity never changes but whose body always reads the freshest state, and the official useEvent / useEffectEvent RFC (available in React canary) formalizes this with a stable callback that closes over the latest values without forcing effect deps to change.
Refs also escape the declarative model when you genuinely need imperative handles. forwardRef passes a ref through a component to a child DOM node, and useImperativeHandle customizes what the parent sees — for example, exposing only { focus, clear } instead of the raw DOM element. React 19 simplifies this significantly: function components now accept ref as a regular prop, removing the need for forwardRef in most cases. Ref callbacks themselves gained a superpower: in React 19, a ref callback can return a cleanup function that runs when the ref is detached, which makes merge-refs utilities and one-off subscriptions cleaner. A simple merge helper combines multiple refs into one callback that fans out to each. Use these imperative tools sparingly — exposing internal state up the tree is usually a sign you should lift that state to a parent instead.
Three primitives form the foundation of render performance work. useMemo(factory, deps) memoizes the result of factory and recomputes only when a dep changes. useCallback(fn, deps) is shorthand for useMemo(() => fn, deps) — it memoizes the function itself. React.memo(Component) skips a re-render of the wrapped component when its props are referentially equal. These three are often paired: a parent uses useCallback to stabilize a handler, the child is wrapped in React.memo to benefit, and useMemo stabilizes object or array props that would otherwise be new identities each render. Object literals in JSX — for example, <Row style={{ margin: 10 }} /> — create new references every render and silently break memoization. Always pass useMemo-stabilized values or hoist constants outside the component.
Memoization is not free. Every memo adds bookkeeping: dep comparison, cache lookup, more closure allocations. It pays off only when the work it avoids outweighs that overhead. Common signs that memoization is wasted include object deps that change every render, functions that never get passed to memoized children, and components whose render cost is dominated by DOM diffing rather than JS. The card on memo pitfalls is explicit: do not wrap tiny components, do not memoize when deps always change, and profile before optimizing. The React Profiler and the "why did this render?" hints in DevTools are the right way to confirm. A render-counter ref (const r = useRef(0); r.current++; console.log(...)) is a quick-and-dirty debugging aid for spotting extra renders during development.
Two big shifts are reshaping this landscape. First, the React Compiler (sometimes called "Forget") analyzes your code at build time and inserts memoization automatically, removing most manual useMemo and useCallback usage once it stabilizes — but you should still profile to confirm, and code written for the compiler reads better than code written for hand-rolled memoization. Second, list virtualization libraries like react-window keep the DOM bounded by rendering only visible rows, providing orders-of-magnitude wins for long lists that no amount of memoization can match. Underneath all of this, the key prop on lists should be a stable unique id, not the array index; index keys preserve state by position rather than by item, so reorders cause wrong state to land on the wrong row. Memoize rows, pass stable callbacks via useCallback, and key by stable id — that combination covers most list-performance work.
useReducer is the right primitive when the next state depends on the previous state, when the state shape is complex (nested objects, multiple sub-fields), or when updates are better expressed as named actions than as scattered setters. A reducer centralizes transitions in a pure function — easier to test, easier to extend with discriminated-union action types that give full TypeScript inference inside the case clauses. Lazy initial state (useReducer(reducer, initArg, init)) lets you defer an expensive computation. Pairing useReducer with Immer lets you write mutating drafts that produce immutable updates ergonomically. Two state-setter patterns are worth knowing everywhere: the lazy initializer useState(() => expensive()) runs the factory only on mount (avoid the trap of useState(expensive()), which runs every render), and the functional updater setCount(c => c + 1) avoids stale-closure bugs when several updates are batched before the next render.
Context is React's built-in way to share state across a subtree, but it has a sharp edge: every consumer re-renders whenever the context value changes. Mitigations include splitting contexts by domain (so an unrelated update doesn't ripple), memoizing the value passed to the provider with useMemo, and adopting a selector pattern via libraries like use-context-selector to subscribe to slices. The classic performance pattern is to split into StateContext and DispatchContext; because dispatch is referentially stable across renders, consumers that only dispatch don't need to re-render when state changes. Lifting state up is still the right call for two or three components that share state — reach for Context only when the scope is broader, and avoid it for high-frequency updates where many consumers would otherwise thrash.
For app-wide state, dedicated stores typically beat hand-rolled Context-plus-reducer. The unifying primitive is useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?), which subscribes to an external store with concurrent-rendering-safe semantics; its getSnapshot must return referentially stable values or React will warn about tearing, and the third argument is required for SSR. Zustand exposes a hook created from a store factory and supports slice selectors that re-render only when that slice changes; Jotai composes primitive atoms into derived atoms; Redux Toolkit's createSlice plus useSelector and useDispatch remains popular in larger apps, especially when paired with shallowEqual so that selecting an object doesn't force a re-render when only one of its keys changed. Compound components follow a related idea: a parent provides a Context, and children read it through a custom hook — perfect for accordions, tabs, and menus where siblings need shared state but a parent prop-drilling would be brittle.
Custom hooks are just functions whose names start with use and that call other hooks. The prefix is not cosmetic — React's linter and DevTools use it to recognize hook calls and surface their state. The real reason to extract a custom hook is reuse: any time two components want the same combination of state, effect, and cleanup, lifting it into a hook keeps both call sites in sync. A tiny useToggle, a slightly larger useCounter, and a more substantial useFormStatus all share the same shape: state plus bound actions returned from one function. Common reusable hooks include useDebounced, useThrottle, useLocalStorage, useWindowSize, useMediaQuery, useFetch, useScript, useOnClickOutside, and useKeyboard.
The two trickiest families are debouncing and data fetching. A useDebounced(value, ms) hook keeps an internal state, sets a timeout when the value changes, and clears the timeout in cleanup, so the returned value lags behind by ms after the input stops changing. A throttled hook uses a ref to track the last timestamp and either ignores intermediate calls or schedules a trailing one. For fetching, the canonical skeleton uses an "off" flag in the effect: declare let off = false, set it true in cleanup, and guard setData with !off so a slow response can't setState after unmount. In modern code, an AbortController passed as signal to fetch does the same job more explicitly, and a ref-tracked request id lets you ignore responses that have been superseded by a newer request. Combining both gives you a hook that survives StrictMode double-mounts and rapidly changing URLs without leaking listeners.
A few cross-cutting rules apply to nearly every custom hook. Cleanup is non-negotiable: if your hook subscribes, listens, or schedules a timer, the effect must return a function that undoes it; otherwise StrictMode's double-mount will double-fire and unmounted components will leak. For SSR safety, initialize from localStorage lazily inside useState and persist in an effect that only runs after mount; reading browser-only APIs during render causes hydration mismatches. UI-bound hooks follow the same recipe: useScript appends a tag and tracks loaded/error state with cleanup; useOnClickOutside listens on document for mousedown and fires when the target is outside the ref; useHover wires onMouseEnter and onMouseLeave; useMediaQuery listens to matchMedia's change event; a viewport scroll hook uses a requestAnimationFrame ref to throttle. Some side effects belong in custom hooks too: setting document.title on a prop change with cleanup that restores the previous title; sending analytics on route change; or implementing form draft autosave with a debounced effect. Across all of these, the same warning holds: if your hook is not synchronizing with an external system, you probably don't need an effect at all.
React 18 introduced two primitives for keeping the UI responsive during heavy work. useTransition returns [isPending, startTransition] and lets you mark a state update as low-priority: React will keep the current screen interactive while it processes the transition in the background, perfect for filtering a large list or switching tabs in a heavy view. useDeferredValue returns a deferred copy of a value that React updates after urgent work completes — handy for typing into a search box where the list re-filter is expensive. The difference is precise: useTransition wraps a setter (you trigger the update), useDeferredValue wraps a value (React decides priority). Pairing them with useMemo and Suspense gives a smooth experience even when caches revalidate in the background. flushSync is the escape hatch when you genuinely need to flush updates synchronously, for example before reading layout that depends on the new state — use it rarely, because it bypasses batching.
React 19 adds several new hooks. The use hook reads a Promise or a Context value and, unlike useContext, can be called inside conditions and loops. Promise reads integrate with Suspense: wrap the consuming tree in a boundary with a fallback to handle the suspended state. useOptimistic shows a predicted value while an async action is in flight and reconciles when the action resolves — perfect for chat messages, likes, and deletes. useActionState (which replaces useFormState) manages state tied to a form action and returns the latest result. useFormStatus, used inside any descendant of a <form>, exposes pending, data, method, and action. The <form action={asyncFn}> prop ties it all together, with Server Actions for the backend half. Errors thrown inside a Server Action bubble to the nearest error boundary or surface through useActionState; an ErrorBoundary class component (or the react-error-boundary library) wraps a subtree to handle render errors gracefully, since no built-in functional equivalent exists yet.
Server Components and Server Actions reshape where rendering and data fetching happen. A Server Component runs once on the server, ships no JavaScript, and can fetch data directly — but it cannot use hooks, because hooks require a client instance. The "use client" directive at the top of a file marks it as a Client Component; "use server" marks a function (or all functions in a file) as a Server Action callable from the client. A Client Component can render a Server Component only by passing it as children — composition, not direct import. Combine Server Components with Suspense boundaries: a child suspends on a Promise, and the boundary catches it, showing a fallback until resolved. For mutating data, revalidatePath('/items') and revalidateTag('items') invalidate Next.js caches from inside Server Actions, and redirect('/done') performs a navigation as part of the action. The cache() function memoizes calls per request, deduplicating data fetches across a render tree.
Outside the server/client split, dedicated data libraries are usually the best way to share async state across components. TanStack Query (useQuery, useMutation, useInfiniteQuery), SWR, and Apollo handle caching, deduplication, retries, background refetching, pagination, and optimistic updates — most of what you'd otherwise reinvent poorly in a useFetch hook. useInfiniteQuery returns { data, fetchNextPage, hasNextPage } for "load more" patterns. Pair mutations with onMutate for optimistic writes and onError to revert; invalidateQueries on success triggers background refetch. For client-only forms, React Hook Form is uncontrolled by default for performance; Formik and controlled patterns with useReducer are alternatives. The underlying pattern across all of these is the same: external state deserves an external store, and the store should integrate with Suspense and concurrent rendering rather than fighting them via useEffect.
function usePrev<T>(v: T) {
const ref = useRef<T>();
useEffect(() => { ref.current = v; });
return ref.current;
}AbortController: pass signal to fetch; abort in cleanup.Drill this topic
224 flashcards on React Hooks Advanced Patterns — free, no signup needed to start.
Study React Hooks Advanced Patterns flashcardsLearnWiki pages are generated with AI assistance from LearnCoachAssist's reviewed study catalog and may contain errors — verify anything critical against your course materials.