useEffect Pitfalls Interviewers Love to Ask
Stale closures, missing cleanup, fetch races, Strict Mode's double run, infinite loops and effects you don't need, plus how useLayoutEffect differs in timing.
How React really renders: hooks, reconciliation, keys, memoization, state management and effects.
Stale closures, missing cleanup, fetch races, Strict Mode's double run, infinite loops and effects you don't need, plus how useLayoutEffect differs in timing.
Render vs commit, the diffing rules React relies on, why index keys break inputs, how a key resets state, and what Fiber and batching change about updates.
Why components re-render, what memo really compares, how referential equality decides everything, when memoization is wasted, and where the React Compiler fits.
React is a JavaScript library for building user interfaces out of reusable components. It focuses on the view layer; routing, data fetching and build tooling come from the ecosystem or from a framework like Next.js or React Router.
Declarative means you describe what the UI should look like for the current state, and React works out how to update the DOM to match. You don't call appendChild or classList.add by hand; you write {isOpen && <Menu />} and change isOpen. The mental model is roughly UI = f(state).
This makes components predictable, because their output depends only on props and state, and lets you build complex screens by composing small pieces. React also batches and minimizes the DOM work for you. It isn't tied to the browser either: react-dom renders to the DOM, while React Native renders to native mobile views.
react-dom, React NativeLikely follow-up: How is React different from a full framework like Angular?
The virtual DOM is React's in-memory description of the UI: a tree of plain JavaScript objects called React elements, produced when your components render. <button className="primary">Save</button> becomes an object roughly like { type: 'button', props: { className: 'primary', children: 'Save' } }.
When state changes, React re-renders the affected components to get a new tree, compares it with the previous one (reconciliation), and then applies only the necessary changes to the real DOM in the commit phase.
The point isn't that the virtual DOM is faster than the DOM: a hand-written, targeted DOM update is always faster. The benefit is that you write simple declarative code that conceptually re-renders everything, while React keeps the actual DOM work small and batched. Internally, React tracks this work in a structure called the Fiber tree.
Likely follow-up: What heuristics does React use when diffing two trees?
JSX is a syntax extension that lets you write markup-like code inside JavaScript. Browsers can't run it, so a compiler such as Babel, SWC, esbuild or TypeScript turns it into plain function calls. With the modern automatic runtime, a JSX element compiles to a jsx(type, props) call imported from react/jsx-runtime. The older classic transform produced React.createElement(...), which is why old files had to import React.
Rules worth knowing:
className, htmlFor, onClick), and {} embeds any expression, but not statements like if or for.<Card /> are components; lowercase tags are DOM elements.{} are escaped, which protects against XSS.// Source
const el = <Button variant="primary">Save</Button>;
// Output of the automatic JSX runtime (roughly)
import { jsx as _jsx } from 'react/jsx-runtime';
const el = _jsx(Button, { variant: 'primary', children: 'Save' });jsx() from react/jsx-runtime; classic: React.createElement{} holds expressions, not statementsA component is a reusable, self-contained piece of UI. In modern React it's a JavaScript function that receives a single props object and returns JSX. Its name must start with a capital letter, so JSX treats it as a component rather than an HTML tag.
Props are how a parent passes data and behavior down to a child: strings, numbers, objects, callbacks like onSelect, even other JSX. The special children prop holds whatever you nest between the opening and closing tags, which enables composition such as <Card><Avatar /></Card>.
Props are read-only. A component must never modify its own props; if a value needs to change over time, it belongs in state, either in this component or in a parent that passes a new value down. Default values are usually written as destructuring defaults, for example function Button({ size = 'md' }).
children enables compositionProps are inputs a component receives from its parent. The component can't change them; it re-renders with new props when the parent re-renders and passes different values.
State is data a component owns and manages itself, created with useState or useReducer. Updating it through the setter schedules a re-render of that component and its children.
A useful way to decide:
The two connect: a parent's state is often passed to a child as props, together with a callback so the child can ask the parent to update it. That's React's one-way data flow.
Likely follow-up: When would you lift state up?
useState work, and why does logging the state right after calling its setter show the old value?easyuseState(initial) returns a pair: the current value and a setter. React stores the value outside your function, attached to this component's position in the tree, so it survives between renders.
Calling the setter doesn't change the variable you already have. It queues an update and schedules a re-render; on the next render, useState returns the new value. So after setCount(count + 1), logging count still shows the old value. Each render sees its own snapshot of state, and a const from that render never changes.
Other details:
Object.is, React skips the re-render.useState(() => parse(data)), so it runs only on the first render.setCount(c => c + 1).function Counter() {
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // still 0 on the first click
}
return <button onClick={handleClick}>{count}</button>;
}Object.is skips the re-renderLikely follow-up: What happens if you call setCount(count + 1) three times in one handler?
useEffect. When does it run, and how does the dependency array change that?easyuseEffect(setup, deps?) lets a component synchronize with something outside React: a subscription, a timer, a network request, a non-React widget. React runs the setup after the render is committed, usually after the browser has painted, so it doesn't block the screen update.
The dependency array controls when it runs again:
[]: only after the first render (mount).[a, b]: after mount, and after any render where a or b changed, compared with Object.is.If setup returns a function, that's the cleanup. React calls it before re-running the effect with new dependencies, and when the component unmounts. Every reactive value the effect reads (props, state, functions declared in the component) should be listed; the react-hooks/exhaustive-deps lint rule enforces this. Think "what does this effect stay in sync with", not "which lifecycle moment is this".
useEffect(() => {
const id = setInterval(() => setSeconds(s => s + 1), 1000);
return () => clearInterval(id); // cleanup
}, []); // reads no reactive values, so no dependencies[]: mount; [a]: when a changesLikely follow-up: Why does my effect run twice in development?
key, and what can go wrong if you use the array index as the key?easyWhen rendering a list, React uses key to match each child with its counterpart from the previous render. Keys tell React which item is which, so when the list changes it can move, insert or remove the right DOM nodes and keep each item's state attached to the correct item.
With the index as key, identity is tied to position, not to the data. If you insert at the top, delete from the middle, sort or filter, items shift to different indexes. React then reuses the wrong component instances: an uncontrolled input keeps text typed into another row, a checkbox stays checked on the wrong item, and more rows re-render than necessary.
Good keys are stable and unique among siblings, typically an id from the data. The index is acceptable only for static lists that never reorder or change. Never generate keys during render with Math.random(): every render would remount every item. Keys aren't passed to the component as a prop.
// Buggy if todos can be reordered, inserted or removed
{todos.map((todo, i) => <TodoRow key={i} todo={todo} />)}
// Stable identity
{todos.map(todo => <TodoRow key={todo.id} todo={todo} />)}Class components extend React.Component, keep state in this.state, update it with this.setState, and put side effects in lifecycle methods like componentDidMount, componentDidUpdate and componentWillUnmount. Function components are plain functions that return JSX and use hooks for state and effects.
Why function components with hooks won:
this: no binding event handlers, and no surprises from reading this.props in async callbacks after it changed.Classes are still supported, and you still need one for an error boundary, because there's no hook equivalent of componentDidCatch. New code should use function components.
this.state, setState, lifecycle methodsthis bindingHooks are functions whose names start with use that let function components use React features: state (useState), effects (useEffect), context (useContext), refs and more. They were introduced in React 16.8.
The rules:
return.The reason is that React doesn't identify hooks by name; it relies on call order. On every render it walks through the hook calls in sequence and matches each one to its stored state. If a condition skips one call, every later hook reads the wrong slot. eslint-plugin-react-hooks catches violations. The exception is React 19's use API, which can be called conditionally.
function Profile({ user }) {
if (!user) return null; // early return...
const [open, setOpen] = useState(false); // ...makes this hook conditional: bug
// Fix: call all hooks first, then return early
}use for state, effects, context, refsuse is the exception and may be conditionalLikely follow-up: Why can use be called conditionally when other hooks cannot?
When two components need to share the same data or stay in sync, you move that state to their closest common parent. The parent owns the state and passes it down as props, together with callbacks the children call to request changes.
Classic example: an accordion where only one panel can be open at a time. If each Panel has its own isOpen state, they can't coordinate. Lift it into Accordion as activeIndex, then pass each panel isActive and an onShow callback.
This keeps a single source of truth: each piece of state lives in exactly one component, and everything else derives from it. The trade-off is that the parent now re-renders on every change, and props may have to travel through several levels. Lift state only as high as it needs to go; if you're lifting very high just to reach distant components, consider composition, context or a store.
function Accordion() {
const [activeIndex, setActiveIndex] = useState(0);
return (
<>
<Panel title="About" isActive={activeIndex === 0} onShow={() => setActiveIndex(0)} />
<Panel title="Pricing" isActive={activeIndex === 1} onShow={() => setActiveIndex(1)} />
</>
);
}Likely follow-up: What is prop drilling, and how do you avoid it?
In React, data flows down the tree: parents pass values to children through props, and children can't modify those props. When state changes, the new values flow down on the next render. This makes it easy to trace where a value comes from: follow the props up to the component that owns the state.
A child communicates upward by calling a callback that the parent passed as a prop, such as onChange(newValue) or onDelete(id). The child only reports what happened; the parent owns the state and decides how to update it.
Compared with two-way binding, like [(ngModel)] in Angular, this is more explicit: a controlled input needs both value and onChange. The payoff is predictability. There's exactly one place that can change a given piece of state, which makes updates easier to reason about and debug.
useMemo and useCallback, and how could you write one using the other?midBoth cache something between renders and recompute only when a dependency changes (compared with Object.is). The difference is what they cache:
useMemo(() => compute(a, b), [a, b]) calls your function and caches its return value.useCallback(fn, [a, b]) caches the function itself, without calling it.So useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).
Use useMemo to skip an expensive calculation, like filtering a large list, or to keep an object or array referentially stable. Use useCallback when a function is passed to a memo-wrapped child or used as a dependency of an effect or another hook. Without a consumer that cares about identity, useCallback achieves nothing: the inline function is still created on every render, React just hands back the cached one. Both are performance hints, so your code must still behave correctly without them.
// Caches a value
const visibleTodos = useMemo(() => filterTodos(todos, tab), [todos, tab]);
// Caches a function
const handleSelect = useCallback(id => setSelectedId(id), []);
// The same thing written with useMemo
const handleSelect2 = useMemo(() => id => setSelectedId(id), []);useMemo caches a computed valueuseCallback caches a function referenceuseCallback(fn, deps) equals useMemo(() => fn, deps)React.memo, but it still re-renders every time the parent does. What are the likely causes?midmemo skips a re-render only when every prop is equal to its previous value by Object.is, which is a shallow reference comparison. The usual culprit is a prop recreated on every parent render:
style={{ color: 'red' }}, options={[1, 2]},onClick={() => select(id)},children, since each render creates new element objects.Each render makes a new reference and {} !== {}, so the comparison fails. Fixes: hoist constants outside the component, wrap objects in useMemo and callbacks in useCallback, or pass primitives instead of objects.
Also check what memo can't block: the child still re-renders when its own state changes or when a context it reads changes. You can pass a custom comparison function as the second argument, but it must compare every prop, including functions, or you'll get stale closures. The React Compiler can handle most of this automatically.
const PriceChart = memo(function PriceChart({ data, options, onPointClick }) {
/* expensive rendering */
});
function Dashboard({ data }) {
// A new object and a new function on every render: memo never skips
return <PriceChart data={data} options={{ grid: true }} onPointClick={p => log(p)} />;
}memo shallowly compares props with Object.isuseMemo and useCallbackuseRef and useState? When would you keep a value in a ref instead of state?midBoth persist a value across renders, but they behave differently when the value changes:
useState gives you a value and a setter; updating it triggers a re-render, and the new value appears in the next render's snapshot.useRef gives you a mutable object, { current }, that stays the same object for the component's lifetime. Assigning ref.current = x takes effect immediately and does not re-render.Use state for anything that affects what's on screen. Use a ref for values that event handlers or effects need but rendering doesn't: an interval or timeout id, an AbortController, the previous value of a prop, a flag like "already sent analytics", or a DOM node via ref={inputRef}.
Avoid reading or writing ref.current during render (lazy initialization aside). React doesn't know when a ref changes, so UI that depends on it can silently get out of sync.
function Stopwatch() {
const [elapsed, setElapsed] = useState(0); // shown on screen: state
const intervalRef = useRef(null); // just an id: ref
function start() {
intervalRef.current = setInterval(() => setElapsed(e => e + 1), 1000);
}
function stop() {
clearInterval(intervalRef.current);
}
return <Timer seconds={elapsed} onStart={start} onStop={stop} />;
}setCount(c => c + 1)?midBatching means React groups several state updates made in the same event into a single re-render. Since React 18 with createRoot, this automatic batching also applies inside promises, setTimeout and native event handlers, not only in React event handlers. It avoids wasted renders and half-updated UI.
Batching interacts with snapshots. Inside one handler, count is fixed for that render, so calling setCount(count + 1) three times queues "set to 1" three times, and the result is 1. An updater function is queued instead of a value; React applies the queue in order, passing each updater the result of the previous one, so three setCount(c => c + 1) calls produce 3.
Use the updater form whenever the next state depends on the previous one, especially in async callbacks, intervals or effects where the captured value may be stale. If you really need an update applied synchronously, for example before measuring the DOM, flushSync from react-dom exists, but it's rarely needed.
function addThreeBuggy() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1); // 0 -> 1: every call uses the same snapshot
}
function addThree() {
setCount(c => c + 1);
setCount(c => c + 1);
setCount(c => c + 1); // 0 -> 3: each updater gets the latest value
}createRootLikely follow-up: What does flushSync do, and when is it needed?
Prop drilling is passing props through several layers of components that don't use them, only so that a deeply nested component can receive them. It makes the intermediate components noisy, couples them to data they don't care about, and makes refactoring painful.
Ways to avoid it, roughly in the order I'd try them:
children or other props, so the component that owns the data renders the deep component directly, e.g. <Layout sidebar={<UserMenu user={user} />} />. This often removes the drilling entirely.Passing a prop through two or three levels is fine and explicit; it's only a problem when it becomes deep and widespread.
children or element props firstuseContext? What happens if there is no matching provider?midThree steps:
const ThemeContext = createContext('light'). The argument is the default value.<ThemeContext value={theme}>; older versions use <ThemeContext.Provider value={theme}>.useContext(ThemeContext) in any descendant.useContext returns the value of the closest provider above the calling component, so nested providers can override the value for a subtree. If there's no provider above, it returns the default passed to createContext, which never changes. A common pattern is a null default plus a custom hook that throws a helpful error when the provider is missing.
When the provider's value changes, every consumer re-renders, so avoid passing a fresh object on every render. React 19 can also read context with use(ThemeContext), which, unlike useContext, may be called inside conditions.
const ThemeContext = createContext(null);
export function useTheme() {
const value = useContext(ThemeContext);
if (value === null) throw new Error('useTheme must be used inside ThemeProvider');
return value;
}
export function ThemeProvider({ children }) {
const [theme, setTheme] = useState('light');
const value = useMemo(() => ({ theme, setTheme }), [theme]);
return <ThemeContext value={value}>{children}</ThemeContext>;
}createContext(default), provide a value, read with useContext<Context value> works as the providerLikely follow-up: What are the performance pitfalls of Context?
Strictly speaking, Context is a dependency injection mechanism: it passes a value down the tree without props. It doesn't manage anything itself; the state still lives in useState or useReducer in the provider component. Context plus useReducer gives you a small store, but with limits:
Redux Toolkit provides a single store with actions, reducers, selectors, middleware and excellent devtools, including time-travel debugging. It suits large apps and teams that want strict conventions. Zustand is a minimal hook-based store with selector subscriptions and very little boilerplate. With both, a component re-renders only when the data it selects changes.
My rule of thumb: Context for low-frequency values like theme, locale or auth; a store for frequently updated, widely shared client state; and neither for server data, which belongs in TanStack Query or a framework's loaders.
useState/useReducer in the provideruseReducer instead of useState?miduseReducer(reducer, initialState) returns [state, dispatch]. Instead of setting state directly, event handlers dispatch actions that describe what happened, like { type: 'added', text }, and a pure reducer(state, action) function computes the next state.
I reach for it when:
status, data and error for a request,dispatch also has a stable identity, so it can go through context or to memoized children without useCallback. For simple independent values, like a toggle or a single input, useState is shorter and clearer. The reducer must be pure and return new objects instead of mutating; in development, StrictMode calls it twice to surface impurity.
function reducer(todos, action) {
switch (action.type) {
case 'added':
return [...todos, { id: action.id, text: action.text, done: false }];
case 'toggled':
return todos.map(t => (t.id === action.id ? { ...t, done: !t.done } : t));
default:
throw new Error('Unknown action: ' + action.type);
}
}
// In the component
const [todos, dispatch] = useReducer(reducer, []);
// In a handler: dispatch({ type: 'toggled', id: todo.id });dispatch has a stable identityuseState for simple independent valuesThe most common pattern is controlled inputs: keep the values in state, set each input's value from state, and update it in onChange. With several fields, one state object plus a single change handler keyed by the input's name keeps it short.
For submission, handle onSubmit on the <form> element rather than a button's click, so pressing Enter works too. Call e.preventDefault() to stop the browser's full-page submission, then validate and send the data.
Details worth mentioning:
checked instead of value, and you read e.target.checked.htmlFor and id, for accessibility.In React 19 you can also pass a function to the form's action prop; it receives the FormData and React tracks the pending state for you.
function SignupForm({ onSignup }) {
const [form, setForm] = useState({ email: '', password: '' });
const handleChange = e => setForm(f => ({ ...f, [e.target.name]: e.target.value }));
return (
<form onSubmit={e => { e.preventDefault(); onSignup(form); }}>
<input name="email" value={form.email} onChange={handleChange} />
<input name="password" type="password" value={form.password} onChange={handleChange} />
<button>Sign up</button>
</form>
);
}value from state, update in onChangename for many fieldsonSubmit and call e.preventDefault()checked; label every inputReact decides whether state changed by comparing the old and new values with Object.is. If you mutate an object and pass the same reference back, as in user.name = 'Ada'; setUser(user), React sees no change and can skip the re-render. Even when something else triggers a render, mutation breaks everything that relies on comparing references, such as memo, useMemo and effect dependencies, and it corrupts the previous render's snapshot.
Treat state as immutable and create new values instead:
setUser({ ...user, name: 'Ada' }). Spread is shallow, so copy each nested level you change.[...items, item], remove with filter, update with map returning a new object for the changed item.[...items].sort(), or use toSorted().push, splice, sort, reverse and direct assignment all mutate in place. For deeply nested updates, Immer lets you write mutating-style code that produces immutable copies.
Object.ismap, filterpush, splice, sort on state; copy firstcomponentDidMount and componentWillUnmount map to hooks?midThe rough mapping:
constructor and initial state: useState(initial) or a lazy initializer.componentDidMount: useEffect(() => { ... }, []).componentDidUpdate: useEffect with the relevant dependencies (which also runs after mount).componentWillUnmount: the cleanup function returned from an effect.shouldComponentUpdate or PureComponent: memo.getDerivedStateFromProps: usually compute the value during render, or reset with a key.componentDidCatch and getDerivedStateFromError: no hook equivalent; you still need a class error boundary.But I'd stress that effects aren't lifecycle methods. An effect describes how to start and stop synchronizing with something, based on its dependencies. Instead of spreading one subscription across mount, update and unmount methods, a single effect sets it up, cleans it up, and re-runs when the values it uses change. Thinking in lifecycles is how people end up with missing dependencies and stale data.
[]; unmount: the effect cleanupshouldComponentUpdate maps to memoYes. By default, when a component renders, React re-renders every component it renders, recursively, whether or not their props changed. React doesn't compare props unless you opt in. Usually that's fine: renders are cheap, and React only touches the DOM where the output actually differs.
When a child is expensive, the options are:
children: when a component re-renders because of its own state, the children elements it received from its parent are the same objects as before, so React skips re-rendering them.memo the expensive child and keep its props referentially stable with useMemo and useCallback.Measure first with the React DevTools Profiler. With the React Compiler enabled, much of this memoization happens automatically.
childrenmemo plus stable props for expensive childrenLikely follow-up: How would you find out which components re-render and why?
First measure, don't guess. The React DevTools Profiler shows which components render, how often and how long they take; the browser's Performance panel shows long tasks, layout thrashing and network waterfalls. Profile a production build, because development mode is much slower.
Then fix what the profile shows:
children, split contexts, memo expensive children with stable props, or enable the React Compiler.useMemo heavy calculations and virtualize long lists.startTransition or useDeferredValue so typing stays responsive.lazy, trim dependencies, optimize images, and remove request waterfalls by fetching in parallel or on the server.Afterwards, verify the improvement with the same measurement, and watch real-user metrics like INP and LCP.
memoLikely follow-up: How can you tell why a particular component re-rendered?
A component has to return a single root, but wrapping siblings in an extra div adds meaningless nodes to the DOM. A Fragment groups children without creating any DOM element. You write it as <>...</> or <Fragment>...</Fragment>.
Why it matters:
dt/dd pairs can't wrap them in a div without producing invalid markup.The short syntax can't take any attributes. When you render fragments in a list, use the explicit form so you can give each one a key: <Fragment key={item.id}>. The key is the only attribute you'd normally pass to a Fragment.
<>...</> or explicit <Fragment><Fragment key> when mapping over a list&&?easyJSX is just JavaScript, so you use ordinary JavaScript:
if with an early return for whole-component branches: if (!user) return <Login />.{isLoading ? <Spinner /> : <Table />}.&& to render something or nothing: {hasError && <Alert />}.null to render nothing at all.The && gotcha: a && b evaluates to a when a is falsy. React renders nothing for false, null and undefined, but it does render the number 0. So {items.length && <List />} shows a stray "0" when the list is empty. Make the left side a real boolean, items.length > 0 && ..., or use a ternary.
Also remember that switching between different component types at the same position unmounts one and mounts the other, so its state is lost.
// Renders "0" when there are no messages
{messages.length && <Badge count={messages.length} />}
// Renders nothing when there are no messages
{messages.length > 0 && <Badge count={messages.length} />}if/early return, ternaries, && or nulla && b returns a when it is falsy0 renders as text; false, null, undefined do notitems.length > 0 && or a ternaryIn React you build complex components by combining smaller ones rather than extending base component classes. The React team has long said they haven't found use cases where they'd recommend component inheritance hierarchies, and in practice you almost never see class FancyButton extends Button.
Composition tools:
children: generic containers like Card, Modal or Layout don't know their content ahead of time; they render whatever is nested inside them.<Layout header={<Nav />} sidebar={<Filters />} />.DangerButton that renders <Button variant="danger" {...props} />.Composition keeps components loosely coupled and their relationships explicit. It also has practical wins: passing JSX as children avoids prop drilling, because the owner renders the deep component directly, and a component re-rendering because of its own state doesn't re-render the children it was handed.
children for generic containersAll three solve "reuse stateful logic", and they arrived in roughly this order:
withAuth(Dashboard) or the old Redux connect. Downsides: extra wrapper layers, props whose origin is unclear, and name collisions when several HOCs are stacked.const user = useUser(). No extra components in the tree, explicit inputs and outputs, and easy to compose and test.Today I default to custom hooks. HOCs still appear in libraries for cross-cutting wrappers, and render props or function-as-children remain useful when a component should let its caller control what gets rendered with its data, such as a virtualized list's row renderer.
// Higher-order component
const DashboardWithUser = withUser(Dashboard);
// Render prop
<UserLoader render={user => <Dashboard user={user} />} />
// Custom hook
function Dashboard() {
const user = useUser();
return <h1>Hi, {user.name}</h1>;
}useDebouncedValue custom hook for a search input.midThe hook keeps its own debounced state. Whenever value changes, the effect starts a timer that copies the value into that state after delay milliseconds.
The key is the cleanup. If value changes again before the timer fires, React runs the previous effect's cleanup first, which clears the pending timer, so only the last value within the window gets through. The cleanup also clears the timer on unmount, so nothing updates after the component is gone.
Design points I'd mention:
use... and calls hooks at the top level, so it follows the Rules of Hooks.value and delay are dependencies, so changing the delay works correctly.query, so typing is instant; only debouncedQuery drives the fetch or filter.Unlike useDeferredValue, this uses a fixed delay, which is what you want when the goal is fewer network requests rather than smoother rendering.
function useDebouncedValue(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
// Usage
const debouncedQuery = useDebouncedValue(query, 400);value and delay are dependenciesuseDeferredValue suits renderingIf nothing catches it, React unmounts the entire root and the user sees a blank page. That's deliberate: leaving corrupted UI on screen, like a checkout showing the wrong total, is considered worse than removing it.
To contain it you use error boundaries: components that catch errors thrown while rendering the tree below them and render a fallback instead. They're still class components: static getDerivedStateFromError switches to the fallback, and componentDidCatch logs the error. Many teams use the react-error-boundary package, which adds reset support. In React 19, createRoot also accepts onCaughtError and onUncaughtError options for centralized reporting.
Place boundaries deliberately: one near the root as a last resort, one per route, and around independent widgets so a broken sidebar doesn't take down the whole page. Boundaries don't catch errors in event handlers or asynchronous code, so handle those with try/catch.
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() {
return { hasError: true };
}
componentDidCatch(error, info) {
logToService(error, info.componentStack);
}
render() {
return this.state.hasError ? this.props.fallback : this.props.children;
}
}getDerivedStateFromError and componentDidCatchtry/catchCode splitting breaks the JavaScript bundle into smaller chunks that load on demand, so users download only the code for what they're looking at. It improves initial load and time to interactive.
The mechanism is dynamic import(), which bundlers like Vite and webpack turn into a separate chunk. In React you combine it with lazy and <Suspense>: const Settings = lazy(() => import('./Settings')) loads the chunk the first time Settings renders, while the nearest Suspense boundary shows a fallback.
Where to split:
import() inside an event handler.Watch out for over-splitting, which adds request overhead, and for spinners on every click: you can preload a chunk, for example on hover, by calling the same import() early. Wrap lazy areas in an error boundary, since loading a chunk can fail.
import() with lazy and SuspenseReact passes your handlers a SyntheticEvent: a cross-browser wrapper around the native event with the familiar interface, such as e.target, e.preventDefault() and e.stopPropagation(). The underlying event is available as e.nativeEvent.
Differences from plain DOM handling:
onClick={handleClick}, not strings.e.preventDefault(); returning false does nothing.document) and dispatches through the component tree.onChange fires on every keystroke, like the native input event, not only when the field loses focus.e inside async code is safe.Capture-phase handlers use a Capture suffix, like onClickCapture.
e.nativeEventpreventDefault() explicitlyonChange fires on every keystrokeparams is a new object on every render, and effect dependencies are compared with Object.is. Two different objects are never equal, so the effect runs after every render. Each run fetches and calls setData with a new array, which triggers another render, which creates another params object, and so on forever.
Fixes, best first:
[query] as the dependency.useMemo(() => ({ q: query, limit: 20 }), [query]).The same trap appears with functions declared in the component body and used in the effect, and with an effect that updates state it also depends on, like setCount(count + 1) with [count]. Don't silence the lint rule to stop the loop; restructure so the dependencies are honest. While you're there, add a cleanup that ignores stale responses.
function Results({ query }) {
const [data, setData] = useState([]);
const params = { q: query, limit: 20 };
useEffect(() => {
fetchResults(params).then(setData);
}, [params]);
return <List items={data} />;
}Object.is, so the effect always re-runsEvery render creates new functions that close over that render's props and state. A stale closure happens when a function created during an old render keeps running later and still reads that old snapshot.
Here the effect runs only once, so the interval callback captured count from the first render, where it's 0. Every tick calls setCount(0 + 1): the display goes to 1 and stays there.
Fixes:
setCount(c => c + 1) always receives the latest state, so the effect no longer needs count at all.useEffectEvent, added in React 19.2.The exhaustive-deps lint rule flags most of these. The same bug shows up with useCallback and useMemo that have missing dependencies.
function Timer() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
setCount(count + 1);
}, 1000);
return () => clearInterval(id);
}, []);
return <p>{count}</p>;
}setCount(c => c + 1)They differ in where and when the HTML is produced:
Hydration follows SSR or SSG: React runs in the browser, renders the same components, and attaches event handlers and state to the existing HTML instead of recreating it, using hydrateRoot. The client's first render must match the server HTML. With Suspense, React can stream HTML and hydrate parts of the page independently.
hydrateRootLikely follow-up: What causes a hydration mismatch?
createPortal(children, domNode) from react-dom renders children into a different DOM node, often one directly under document.body, while keeping them at the same place in the React tree.
Typical uses are modals, tooltips, dropdown menus and toasts. A modal rendered deep inside a card with overflow: hidden, or inside a stacking context created by z-index or transform, gets clipped or layered incorrectly. A portal moves the DOM output out of that container, while you still write the modal right next to the button that opens it.
Because a portal stays in the React tree:
onClick handlers on the components that render it, which can be surprising.For accessibility you still need focus management, closing on Escape and the right ARIA roles; the native dialog element is a good alternative for modals.
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
);
}createPortal renders into another DOM nodeoverflow clipping and z-index stacking contextsforwardRef used for, and what changed about passing refs to components in React 19?midBefore React 19, ref was a special attribute that React removed from props, so a function component couldn't receive it. If a parent wrote <TextInput ref={inputRef} /> to focus the inner input, TextInput had to be wrapped in forwardRef, which passed the ref as a second argument that the component forwarded onto a DOM element.
In React 19, function components can read ref directly as a prop: function TextInput({ ref, ...props }). forwardRef still works but is no longer needed, and the React team has said it will be deprecated in a future version. For class components nothing changed: a ref on a class component still points to the instance and isn't passed as a prop.
Related tools: useImperativeHandle lets a component expose a limited API, like { focus, scrollToTop }, instead of the whole DOM node, and in React 19 a ref callback can return a cleanup function.
// React 18 and earlier
const TextInput = forwardRef(function TextInput(props, ref) {
return <input ref={ref} {...props} />;
});
// React 19
function TextInput({ ref, ...props }) {
return <input ref={ref} {...props} />;
}
// The parent is the same in both cases
<TextInput ref={inputRef} />ref was not a regular prop before React 19forwardRef passed the ref as a second argumentref as a propforwardRef still works but is slated for deprecationuseImperativeHandle exposes a custom handleIt's not a bug. In development, <StrictMode> mounts each component, runs its effects, then simulates an unmount by running the cleanups, and runs the effects again. It does this deliberately, to reveal effects that don't clean up after themselves. It never happens in production builds.
So the double connection is telling you the effect is missing a correct cleanup. Make setup and cleanup symmetric: if the effect calls connection.connect(), the cleanup must call connection.disconnect(). Then connect, disconnect, connect looks the same to the user as a single connect. The same goes for subscriptions (unsubscribe), timers (clear) and fetches (abort, or ignore the stale result).
Don't work around it with a "has already run" ref: that hides the symptom but keeps the bug, and React can legitimately remount components while preserving their state. For logic that must run exactly once when the app starts, put it at module level, outside any component.
The tooltip probably measures itself in a useEffect and then stores its position in state. useEffect usually runs after the browser paints, so the user sees one frame of the tooltip in its initial position; then the effect measures, sets state, and a second paint moves it.
The fix is useLayoutEffect. It runs after React has updated the DOM but before the browser paints. You read layout with getBoundingClientRect(), set state, and React processes that update before the screen is painted, so the user only ever sees the final position.
Caveats:
Default to useEffect, and switch to the layout version only for this kind of visual measurement.
useEffect usually runs after paint, causing a visible jumpuseLayoutEffect runs after DOM updates, before paintthis.handleClick = this.handleClick.bind(this), and why is that unnecessary in function components?midIn JavaScript, this is determined by how a function is called, not where it's defined. onClick={this.handleClick} passes the method as a bare function reference. When React later calls it, there's no object before the dot, so this is undefined (class bodies always run in strict mode) and this.setState throws a TypeError.
The classic fixes:
this.handleClick = this.handleClick.bind(this).handleClick = () => { ... }, because arrow functions capture this lexically from the instance.onClick={() => this.handleClick()}, which creates a new function on every render.Function components avoid the problem entirely, because there is no this. Handlers are ordinary functions declared inside the component that close over the current render's props and state. The trade-off shifted from binding bugs to being aware of stale closures, but the code is simpler.
this depends on how a function is calledthis.handleClick loses its receiverbind, class field arrows or inline arrowsthis; handlers close over valuesuseEffect?midFetching in an effect looks simple, but a production-quality version needs a lot: loading and error states, ignoring stale responses when inputs change, caching so revisiting a screen is instant, deduplicating identical requests from several components, refetching stale data, retries, pagination, and refreshing data after a mutation.
Server data isn't really client state; it's a cache of something the server owns. TanStack Query, SWR, RTK Query and Apollo (for GraphQL) are built around that idea. You call useQuery with a queryKey and a queryFn, and get back data, error and status flags. The key identifies the cache entry, so every component asking for the same key shares one request and one cached result. After a write, useMutation plus invalidateQueries refetches the affected data.
Effects also create request waterfalls: a child can't start fetching until its parent has rendered. Frameworks avoid that by fetching in route loaders or Server Components, the other good alternative.
function Todos({ filter }) {
const { data, isPending, error } = useQuery({
queryKey: ['todos', filter],
queryFn: () => fetchTodos(filter),
});
if (isPending) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <TodoList todos={data} />;
}Before React 18, once React started rendering an update it had to finish synchronously, so a big render could block typing and clicks. Concurrent rendering makes rendering interruptible: React can start rendering an update, pause to handle something more urgent, and then resume, restart or throw away the unfinished work. Changes are committed only when a render completes, so users never see half-finished UI.
It's opt-in per update rather than a global mode, and it requires createRoot. The APIs:
startTransition and useTransition mark a state update as non-urgent; urgent updates like typing interrupt it, and isPending lets you show feedback.useDeferredValue renders with a lagging copy of a value, then catches up in the background.<Suspense> with transitions keeps the previous UI on screen instead of showing a fallback while new content loads.Concurrency doesn't make rendering itself faster; it changes scheduling so the app stays responsive. It's also why render functions must be pure: a render may run more than once before it's committed.
Likely follow-up: How is useDeferredValue different from debouncing?
The main differences:
map. Angular uses HTML templates with its own syntax, such as @if, @for and property and event bindings.[(ngModel)] and updates the view through change detection, increasingly driven by signals.Neither is better overall. React offers flexibility and a huge ecosystem, but teams must choose libraries and conventions. Angular offers consistency out of the box, which suits large teams, at the cost of a steeper learning curve.
Fiber is the reconciliation engine React has used since React 16, a rewrite of the older "stack" reconciler. The old reconciler walked the component tree recursively and synchronously, so a large update couldn't be paused and could block the main thread.
Fiber represents each component instance, DOM element or fragment as a fiber: a plain object holding its type, props, state and hooks, pending work, and pointers to its child, sibling and parent. Because traversal is a loop over these linked nodes rather than deep recursion, React can split rendering into small units of work, pause between them to yield to the browser, prioritize urgent updates, and resume or discard work.
React keeps two trees: the current tree that's on screen and a work-in-progress tree being built, swapped on commit. The render phase is interruptible; the commit phase is synchronous, so the DOM never shows a partial update.
Fiber is the foundation for error boundaries, Suspense, transitions and concurrent rendering. It's an internal implementation detail you don't use directly.
The main technique is virtualization, also called windowing: render only the rows visible in the viewport, plus a small overscan buffer, instead of all 10,000. The scroll container keeps the full list height through a spacer or padding, and rows are positioned by index, so as you scroll the same few dozen DOM nodes are reused with new data. Libraries include TanStack Virtual, react-window and react-virtuoso, which handles variable heights well.
Supporting measures:
keys and memo on the row component, so scrolling doesn't re-render every visible row.useMemo, and use useDeferredValue for the search query.Trade-offs worth mentioning: variable row heights need measuring, the browser's find-in-page can't see rows that aren't rendered, and screen readers need extra ARIA hints about the total size. For moderately long pages, CSS content-visibility: auto is a lighter alternative.
React splits an update into a render phase, where it calls your components to compute JSX, and a commit phase, where it applies the changes to the DOM and then runs effects. Rendering is treated as a pure calculation: given the same props, state and context, a component should return the same JSX and change nothing outside itself.
React relies on this because a render isn't guaranteed to be committed. With concurrent features, React may start, pause, discard or repeat a render, and StrictMode renders twice in development precisely to expose impure components.
Side effects that don't belong in render:
localStorage or a global cache,ref.current, except for lazy initialization.Mutating an object you created during the same render is fine; that's local mutation. Side effects belong in event handlers, or in effects when they synchronize with an external system. Purity is also what lets the React Compiler memoize safely.
userId changes?midReact preserves a component's state as long as the same component type renders at the same position in the tree. So when <Profile userId={id} /> receives a new id, its internal state, such as a half-typed comment, is kept, which is often a bug.
The idiomatic fix is a key: <Profile key={userId} userId={userId} />. When the key changes, React treats it as a different component: it unmounts the old instance, discarding its state and all of its children's state, and mounts a fresh one. No effect is needed.
Avoid resetting state in a useEffect that watches the prop: the component first renders with the stale state and then again after the reset, and you have to remember to reset every piece of state. If only part of the state should reset, it's usually cleaner to restructure, for example by storing a selected id instead of a copied object, so the value is derived during render.
key remounts with fresh state'use client' directive do? Does it mean the component only renders in the browser?hardNo, and that's a common misconception. In an app using React Server Components, like the Next.js App Router, components are Server Components by default. 'use client' at the top of a file marks a boundary: that module, and every module it imports, becomes part of the client bundle, so its components can use state, effects, event handlers and browser APIs.
Client Components are still pre-rendered to HTML on the server and then hydrated in the browser. "Client" means the code also ships to and runs in the browser, not that the server skips it. That's why browser-only APIs like window still belong in effects or event handlers.
Other details:
children to a Client Component stays on the server.dangerouslySetInnerHTML?midBy default React escapes strings rendered in JSX: <div>{comment}</div> inserts the value as text, so an injected <script> tag or onerror attribute shows up as literal characters instead of executing. That removes the most common XSS vector.
dangerouslySetInnerHTML={{ __html: html }} bypasses this and sets innerHTML directly. The awkward name and object shape are deliberate, so it stands out in code review. If the HTML comes from users or any untrusted source, sanitize it first with a library like DOMPurify, or render Markdown to React elements instead of raw HTML.
Other holes that escaping doesn't cover:
href or src: a javascript: URL can run code when clicked, so allow only safe protocols like https:.dangerouslySetInnerHTML or event handlers.Add defense in depth with a Content Security Policy.
dangerouslySetInnerHTML bypasses escaping; sanitize with DOMPurifyjavascript: protocolsgetBy, queryBy and findBy queries, and which query types should you prefer?midThey differ in what happens when the element isn't there:
getBy... returns the element or throws if there's no match, or more than one. Use it for elements that should be present right now.queryBy... returns null when nothing matches, which makes it the one for asserting absence, e.g. expect(screen.queryByText('Error')).not.toBeInTheDocument().findBy... returns a promise that retries until the element appears or a timeout (1 second by default) passes. Use it with await for async UI, like data that arrives after a fetch.Each has an All variant (getAllBy, queryAllBy, findAllBy) for multiple matches.
For the query type, follow the library's priority: ByRole (with a name option) first, then ByLabelText, ByPlaceholderText and ByText, and ByTestId only as a last resort, since users can't see test ids. Prefer screen over destructuring from render, and user-event over fireEvent for realistic interactions.
test('shows the user after loading', async () => {
render(<UserCard id="1" />);
expect(screen.getByRole('progressbar')).toBeInTheDocument();
expect(await screen.findByRole('heading', { name: 'Ada' })).toBeInTheDocument();
expect(screen.queryByRole('progressbar')).not.toBeInTheDocument();
});getBy throws when missing: for elements that should existqueryBy returns null: for asserting absencefindBy returns a retrying promise: for async UIByRole; use ByTestId as a last resortAll variants handle multiple matchesMost accessibility in React is simply good HTML, plus care in the areas a single-page app makes easy to get wrong:
button for actions, a for navigation, label for inputs, headings in order. A clickable div needs a role, tabIndex and keyboard handlers that a button gives you for free.htmlFor instead of for, while aria-* attributes keep their HTML names, e.g. aria-expanded={open}.aria-labelledby and aria-describedby from useId, which stays consistent between server and client, instead of hard-coded ids that clash when a component renders twice.aria-live region for async updates like "Saved".eslint-plugin-jsx-a11y, axe, and role-based queries in Testing Library.For complex widgets like comboboxes, follow the WAI-ARIA patterns or use accessible headless libraries such as React Aria or Radix.
htmlFor, aria-* attributes, and useId for unique idsDuring hydration, React expects the browser's first render to produce exactly the HTML the server sent. When it doesn't, React reports a hydration error. It can recover from some mismatches by re-rendering the affected part on the client, which throws away that server HTML and costs performance, and attribute differences aren't guaranteed to be patched, so treat every mismatch as a bug.
Common causes:
Date.now(), Math.random(), locale- or timezone-dependent formatting.typeof window !== 'undefined', localStorage or window.innerWidth.div inside a p, which the browser's parser rearranges.Fixes: make the first client render match the server, then switch to client-only values in an effect, for example with a mounted flag set in useEffect. Generate ids with useId rather than counters or random values, and compute timestamps once on the server and pass them down. For unavoidable text differences, suppressHydrationWarning silences the warning on a single element.
useId for ids; suppressHydrationWarning as a last resortuseActionState work? Walk through a form that displays a validation error returned by its action.harduseActionState(action, initialState) wraps an action and keeps track of its result. It returns [state, formAction, isPending]: the latest value returned by the action (starting as initialState), a wrapped action to pass to a form's action prop or a button's formAction, and a pending flag.
When the form is submitted, React calls your action with the previous state first and the FormData second; that extra first argument changes the action's signature. Whatever it returns, or resolves to, becomes the new state, so a validation error or success message is simply returned, as in subscribe above. The action runs inside a transition, so isPending stays true until it finishes, and repeated submissions are processed in order, since each receives the previous state.
Worth knowing: uncontrolled fields are reset after the action completes, even when it returned an error, so if you want to keep what the user typed, return it in state and render it back as the input's defaultValue. With a framework that supports Server Functions, the action can be a 'use server' function.
async function subscribe(prevState, formData) {
const email = formData.get('email');
if (!email.includes('@')) return { error: 'Enter a valid email' };
await saveSubscriber(email);
return { error: null };
}
function NewsletterForm() {
const [state, formAction, isPending] = useActionState(subscribe, { error: null });
return <form action={formAction}>
<input name="email" />
<button disabled={isPending}>Subscribe</button>
{state.error && <p role="alert">{state.error}</p>}
</form>;
}[state, formAction, isPending]FormDataisPending tracks ituseOptimistic, and how would you use it to make a "like" button feel instant?harduseOptimistic(state, updateFn) shows a temporary, optimistic version of some state while an async action is in progress. It returns [optimisticState, addOptimistic]. Calling addOptimistic(value) makes React compute updateFn(currentState, value) and render that immediately. When the action finishes, the optimistic value is dropped and the component shows whatever the real state is by then.
For a like button: inside the action, call addOptimisticLike(1) first so the count bumps instantly, then await the server call. On success, the real likes has been updated, so dropping the optimistic value changes nothing visible. On failure, the real state never changed, so the UI reverts automatically; you should also tell the user it failed.
Rules: call addOptimistic inside an Action or startTransition, such as a function passed to a form's action prop; React warns if you call it outside one. Keep updateFn pure. It fits likes, toggles and appending a pending message to a chat list.
function LikeButton({ likes, likeAction }) {
const [optimisticLikes, addOptimisticLike] = useOptimistic(
likes,
(current, amount) => current + amount
);
async function handleLike() {
addOptimisticLike(1); // shown immediately
await likeAction(); // server call; the parent then passes the new likes
}
return <form action={handleLike}><button>Like ({optimisticLikes})</button></form>;
}[optimisticState, addOptimistic]updateFn(current, value) computes the optimistic valuestartTransitionuse API, and how is it different from other hooks?harduse(resource) reads the value of a promise or a context during render.
With a promise, use suspends the component until it resolves: the nearest <Suspense> boundary shows its fallback, and once the promise resolves React renders again with the value. If the promise rejects, the nearest error boundary shows its fallback; alternatively, attach .catch() to the promise to provide a fallback value.
With a context, use(ThemeContext) works like useContext.
What's different: unlike hooks, use can be called inside conditions and loops, though still only inside components or hooks, and not inside a try/catch block.
The important caveat: don't create the promise during render in a Client Component, as in use(fetch(url)). Every render would create a new promise, so the component would keep suspending. The promise should be created outside render and cached: typically a Server Component starts the fetch and passes the promise to a Client Component, or a Suspense-enabled library or framework provides it.
try/catchCompound components are a set of components that work together and share implicit state, much like the native select and option elements. Instead of one component with a huge configuration prop, you expose composable parts such as Tabs, Tab and TabPanel, often attached as Tabs.Tab. The parent owns the state (the active tab) and shares it with its parts through context, so consumers control the markup, order and styling while the logic stays inside.
Benefits: a flexible, readable API, no explosion of props like renderTabLabel or tabClassName, and consumers can put their own elements between the parts.
Implementation tips:
value plus onChange) and uncontrolled (defaultValue) usage.Older implementations used React.Children.map and cloneElement to inject props, but that breaks as soon as a child is wrapped in another element, so context is preferred. Headless libraries like Radix UI and Headless UI use this pattern heavily.
const TabsContext = createContext(null);
function Tabs({ defaultValue, children }) {
const [active, setActive] = useState(defaultValue);
const value = useMemo(() => ({ active, setActive }), [active]);
return <TabsContext value={value}>{children}</TabsContext>;
}
function Tab({ value, children }) {
const { active, setActive } = useContext(TabsContext);
return <button role="tab" 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;
}cloneElementuseMemo and useCallback?hardThe React Compiler is a build-time tool, typically run as a Babel plugin, that analyzes components and hooks and inserts memoization automatically. It caches computed values, callbacks and JSX at a fine-grained level, so work is skipped when inputs haven't changed, much like hand-written memo, useMemo and useCallback, but applied consistently.
It depends on your code following the Rules of React: pure rendering, no mutation of props or state, and hooks called unconditionally. When it can't be sure a component is safe to optimize, it leaves that component as is rather than risk breaking it, and its ESLint rules report code that breaks the rules.
So with the compiler enabled, you rarely need to add manual memoization in new code. But:
useMemo and useCallback calls remain valid, and removing them can change behavior when they stabilize effect dependencies,It's opt-in, and it won't fix slow algorithms or poor state architecture.
memo, useMemo, useCallbackUse useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot?):
subscribe(callback) registers a listener with the source and returns an unsubscribe function.getSnapshot() returns the current value. React calls it during render and re-renders when it returns a different value, compared with Object.is.getServerSnapshot() provides the value used during server rendering and hydration.Why not useState plus a subscription in useEffect? With concurrent rendering, React may render parts of the tree at different times; if the store changes in between, components can show different values for the same data, which is called tearing. useSyncExternalStore guarantees a consistent snapshot, and it reads the value during render, so there's no first render with a stale value.
Gotchas: getSnapshot must return a cached value, not a new object on each call, or React re-renders endlessly; and subscribe should be defined outside the component, or memoized, so React doesn't resubscribe on every render. Libraries like Redux and Zustand use this hook under the hood.
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);
}useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot)getSnapshot must return a cached, immutable valuesubscribe outside the componentReconciliation is how React works out what to change in the DOM after a re-render. Rendering produces a new tree of elements; React compares it with the previous tree and applies the minimal set of DOM operations in the commit phase.
A general tree diff is far too expensive, so React uses two heuristics that make it linear:
div to span, or ProfileA to ProfileB): React tears down the old subtree, including its state, and mounts a new one.For lists, React matches children by key. Stable, unique keys let it move, insert and delete items correctly. Index keys break down when items are reordered, inserted or removed, because state and DOM values stay attached to positions rather than to items.
Likely follow-up: Why is using the array index as a key a problem? · How can you deliberately reset a component's state?
A component re-renders when its own state changes, when its parent re-renders, or when a context it reads changes. "Props changed" isn't a separate trigger: props only change because the parent rendered.
An update has two phases:
useEffect).A re-render doesn't necessarily change the DOM: if the output is the same, the commit has nothing to do. Setting state to the same value (by Object.is) lets React bail out. To avoid re-rendering expensive children you can wrap them in memo, move state closer to where it's used, or pass children in as props.
Object.is)Likely follow-up: Does a child re-render if its props didn't change? · How does passing children from a parent help avoid re-renders?
useEffect and useLayoutEffect, and when would you use each?midBoth run after React has updated the DOM; the difference is timing relative to the browser's paint.
useEffect normally runs after the browser has painted, so it doesn't block the screen update. It's the default for side effects: fetching, subscriptions, timers, analytics, syncing with non-React widgets. (For effects caused by a discrete interaction such as a click, React may flush them before paint.)useLayoutEffect runs synchronously after DOM mutations but before paint. Use it when you must read layout, e.g. getBoundingClientRect, and immediately re-render: positioning a tooltip or popover so the user never sees it flicker in the wrong place.Because layout effects block painting, overusing them hurts performance, so default to useEffect and reach for the layout version only for visual measurements. useLayoutEffect also doesn't run during server rendering, so layout-dependent UI needs a sensible initial render.
useLayoutEffect runs before paint and blocks ituseEffect; layout effects cost performanceLikely follow-up: In what order do parent and child effects run?
useEffect cleanup function run, and how do you avoid race conditions when fetching data in an effect?midThe cleanup runs before the effect runs again with changed dependencies, and when the component unmounts. In development, StrictMode also runs one extra setup-then-cleanup cycle on mount to prove the cleanup is correct.
A fetch race happens when a dependency changes quickly: request A starts, then request B, but A resolves last and overwrites B's result. Two standard fixes:
ignore flag: declare let ignore = false in the effect, set it to true in the cleanup, and only call setState when !ignore.AbortController: pass its signal to fetch and call controller.abort() in the cleanup, which also cancels the network request.In production code I'd usually use a data-fetching library like TanStack Query or SWR, or the framework's loaders, which handle caching, deduplication and races for you.
Likely follow-up: Why does the effect run twice in development?
memo, useMemo and useCallback. When are they worth using?midmemo wraps a component so React skips re-rendering it when every prop is equal (Object.is) to last time. useMemo caches a computed value between renders until its dependencies change. useCallback caches a function's identity; it's useMemo for functions.
They work together: memo only helps if the props are stable, so object and function props passed to a memoized child often need useMemo/useCallback. Stable identities also matter for effect dependency arrays.
I use them for measured problems: a genuinely expensive calculation, a large subtree re-rendering on every keystroke, or a dependency that would otherwise re-trigger an effect. They aren't free: they add comparisons, memory and complexity, and wrong dependencies cause stale values. Often restructuring, like moving state down or passing children, fixes re-renders more cleanly. The React Compiler can also apply this memoization automatically.
memo skips a re-render when props are shallowly equaluseMemo caches values; useCallback caches function identitymemo needs stable props to be effectiveLikely follow-up: Why does passing an inline object to a memoized child defeat memo?
A controlled input gets its value from React state through value plus onChange, so React is the single source of truth. You can validate or format on every keystroke, derive other UI from the value, and reset the field by setting state.
An uncontrolled input keeps its value in the DOM. You give it a defaultValue and read the value when you need it, through a ref or from FormData on submit.
Controlled inputs suit instant validation, dependent fields and complex forms, but each keystroke re-renders the owning component. Uncontrolled inputs are simpler and cheaper, and React 19 form actions lean on them: the action receives FormData, and uncontrolled fields reset after a successful action. Form libraries like React Hook Form are largely uncontrolled for performance.
Don't switch a field between the two: going from value={undefined} to a string triggers a warning.
value + onChange)defaultValue + ref or FormData)A custom hook is a function whose name starts with use and that calls other hooks. It lets you extract stateful logic, like tracking online status, debouncing a value or managing a WebSocket, and reuse it across components.
Custom hooks share logic, not state: each component that calls useOnlineStatus() gets its own independent state and effects. To share the same state you lift it up or use context or a store.
A good custom hook:
useDebouncedValue,I avoid hooks that just wrap lifecycles, like useMount, because they hide what the effect is actually synchronizing with.
use that calls other hooksLikely follow-up: How would you test a custom hook?
Every component that reads a context re-renders when the provider's value changes by Object.is, and memo on the consumer doesn't prevent it. Two things commonly go wrong:
value={{ user, setUser }} creates a new object on every render of the provider, so all consumers re-render even when nothing changed. Memoize the value with useMemo, and callbacks with useCallback.Mitigations: split contexts by how often they change, for example separate state and dispatch contexts; keep providers as low in the tree as possible; pass stable values. For high-frequency, widely shared state, use a store with selector-based subscriptions (Zustand, Redux, Jotai, or useSyncExternalStore) so a component only re-renders when the slice it selects changes. Context shines for low-frequency values like theme, locale and the current user.
memo does not block itLikely follow-up: How does useSyncExternalStore let a store avoid unnecessary renders?
An error boundary is a component that catches errors thrown while rendering the tree below it and shows a fallback UI instead. Without one, an uncaught rendering error unmounts the entire React root.
There's still no hook for this: you write a class component with static getDerivedStateFromError to render the fallback and componentDidCatch to log, or use the react-error-boundary package, which adds reset support.
Boundaries don't catch errors in event handlers, in asynchronous code like timers or promise callbacks, or in the boundary itself. For handlers you use try/catch; if you want the boundary to show the failure, store the error in state and throw it during render.
In practice I place a boundary at route level and around independent widgets, so one broken widget doesn't take down the page, and I report caught errors to a monitoring service.
getDerivedStateFromError / componentDidCatch, or react-error-boundaryLikely follow-up: How would you let the user retry after an error?
Suspense and lazy work together, and where would you place Suspense boundaries?midlazy code-splits a component: const Editor = lazy(() => import('./Editor')) loads the module the first time Editor renders. The module needs a default export, and lazy should be called at module top level.
<Suspense fallback={...}> shows its fallback while anything inside it is suspended: a lazy component still loading, a promise read with use(), or data from a Suspense-enabled framework or library. It does not detect data fetched in useEffect.
Boundary placement is a UX decision. One boundary around the page means one spinner until everything is ready; nested boundaries reveal content progressively, for example the header first and then the comments. If already-visible content suspends again, it's replaced by the fallback, unless the update is a transition, in which case React keeps showing the old UI. On the server, Suspense boundaries enable streaming SSR: the shell streams first and each boundary's HTML follows when ready.
lazy + dynamic import() for code splitting; default export; declared at top leveluse() and Suspense-enabled data sources, not useEffect fetchingStrictMode do, and why do components and effects run twice in development?mid<StrictMode> is a development-only tool for finding bugs early; it has no effect in production builds. In development it:
The extra cycle simulates React unmounting and remounting a component with its state preserved, something features that hide and restore UI rely on. The right response is to make rendering pure and effects symmetric, never to remove StrictMode. For fetch-on-mount, the duplicate is harmless if you ignore or abort the stale request.
Server Components run only on the server, at request time or build time, and their code never ships to the browser. They can be async, read databases or files directly, and keep secrets and heavy dependencies off the client. Their rendered output is serialized and streamed to the browser.
Client Components are the components we've always written: they're pre-rendered to HTML too, but they also hydrate and run in the browser, so they can use state, effects, event handlers and browser APIs. You mark the boundary with 'use client' at the top of a file; that module and everything it imports become client code.
Rules of thumb: keep data fetching and static content in Server Components, push 'use client' down to the interactive leaves, and pass Server Components into Client Components as children. Props crossing the boundary must be serializable. 'use server' is different: it marks Server Functions that the client can call, such as form actions. RSC need framework support, like the Next.js App Router.
'use client' marks the boundary; imported modules become client code'use server' marks Server Functions, not Server ComponentsLikely follow-up: How can a Client Component render a Server Component?
useTransition and useDeferredValue. How are they different from debouncing?hardBoth mark some rendering work as non-urgent, so urgent updates like typing and clicking stay responsive. A transition render can be interrupted and restarted when newer input arrives.
useTransition returns [isPending, startTransition]. You wrap a state update you own: startTransition(() => setTab('posts')). It suits tab switches and navigation, gives you isPending for a subtle loading indicator, and keeps already-visible content on screen instead of showing a Suspense fallback. In React 19 you can pass it an async function, which React calls an Action.useDeferredValue(value) defers a value: the component first re-renders with the old value, then renders the new value in the background. Use it when you don't control the update, such as a prop, or to keep an input urgent while a heavy list reads the deferred query.Unlike debouncing, there's no fixed delay: on a fast device the deferred render happens almost immediately, and slow renders get interrupted rather than queued.
useTransition wraps a state update you own and gives isPendinguseDeferredValue lags a value you receiveI start by classifying the state:
useState or useReducer in the component that owns it, lifted only as far as needed.Once server state is in a cache, the remaining global state is usually small. I'd choose Redux Toolkit for large teams that want strict conventions and devtools, and Zustand for lightweight shared state.
I test behavior the way a user experiences it, not implementation details. With React Testing Library I render the component, find elements the way a user would (getByRole, getByLabelText), interact through user-event, and assert on what's visible.
I avoid asserting on internal state, hook call counts or component instances, because those tests break on refactors that don't change behavior, while missing real bugs.
The mix I aim for:
Role-based queries double as a light accessibility check. For async UI I use findBy* queries or waitFor instead of arbitrary timeouts.
A ref is a mutable box, { current }, that persists across renders; changing it does not trigger a re-render. Two main uses:
AbortController, the previous value of a prop.Don't read or write ref.current during rendering (apart from lazy initialization), because that makes render impure; use refs in effects and event handlers.
In React 19, function components receive ref as a regular prop, so function Input({ ref, ...props }) { return <input ref={ref} {...props} /> } just works and forwardRef is no longer needed. Ref callbacks can now return a cleanup function. To expose a limited API instead of the raw DOM node, use useImperativeHandle.
current that survives renders without causing re-rendersref is a normal prop; forwardRef not neededuseImperativeHandle exposes a custom handleuseEffect? Give examples.midEffects are for synchronizing with external systems: the network, subscriptions, timers, DOM outside React. Many effects in real code don't do that; they just add an extra render and bugs.
Common cases to avoid:
setFullName(first + ' ' + last), compute fullName during render, with useMemo if it's expensive.submitted flag.key so React remounts the component.Rule of thumb: if code runs because the component was displayed, it's an effect; if it runs because the user did something, it belongs in an event handler.
key instead of an effectIn React 19, an Action is a function, usually async, that runs inside a transition. You can pass one directly to <form action={fn}>: React calls it with the form's FormData, so you don't write e.preventDefault() or manual loading flags. After a successful action, React resets the form's uncontrolled fields.
The supporting hooks:
useActionState(action, initialState) returns [state, formAction, isPending]. The action receives the previous state and the form data and returns the next state, which is handy for validation errors.useFormStatus() from react-dom returns pending, data and more for the parent form, so it must be called in a component rendered inside the <form>, like a SubmitButton.useOptimistic shows an optimistic value while an action is pending; once it settles, the UI shows the real state again.With a framework that supports Server Functions, the action can be a 'use server' function, and forms can even submit before hydration.
form action={fn} calls fn with FormData inside a transitionuseActionState returns state, a wrapped action and isPendinguseFormStatus reads the parent form, so call it in a child componentuseOptimistic for optimistic UI during an actionNo questions match that filter.