React Live Regions and Screen Reader Announcements
Announce cart updates, result counts and errors to screen readers from React: why live regions must already exist, and how to repeat a message.
How React really renders: hooks, reconciliation, keys, memoization, state management and effects.
Official reference: react.dev
Announce cart updates, result counts and errors to screen readers from React: why live regions must already exist, and how to repeat a message.
Why every React context consumer re-renders when any part of the value changes, and how splitting state, actions and slow-changing data fixes it.
Split React bundles with lazy and Suspense, preload chunks before the click, and handle the chunk-load failures that follow every deploy.
Events from a React portal bubble through the React tree, not the DOM. Here is what that breaks in click-outside logic, and how to fix it.
What StrictMode calls twice in development, the exact mount, unmount, mount sequence in React 19, and how to fix the effects it exposes.
Show a sent message before the server answers with useOptimistic, see how it rolls back on failure, and avoid the outside-transition trap.
Expose a small focus() and clear() API from a React component with useImperativeHandle, pass refs as props in React 19, and know when not to.
Fix the React warning 'Cannot update a component while rendering a different component': a child sets parent state during render.
Fix 'Invalid hook call' in React: hooks in class components, callbacks or plain functions, duplicate React copies and mismatched react-dom.
Fix 'Maximum update depth exceeded' in React: effects with no or unstable dependencies, setState in componentDidUpdate and ref callbacks.
Fix 'Objects are not valid as a React child' in React: rendering objects, Dates, Errors or props, plus async components on the client.
Why React state isn't updated immediately after setState: render snapshots, batching, functional updaters, mutation and when to use useEffect.
Fix 'Too many re-renders' in React: a state setter runs during render, often onClick={setX(1)} or state synced in the component body.
Keep a search input responsive with useDeferredValue, show users when results are stale, and see why memo is required and fetches aren't reduced.
Build an accessible React modal: native dialog with showModal, focus on open, Escape to close, focus restoration and testing with keyboard only.
Stop syncing state with useEffect: compute filtered lists, totals and validity during render in React, and use useMemo only for real cost.
When React runs useEffect cleanup, why Strict Mode mounts twice, and how to clean up listeners, timers, sockets and fetches without leaks.
What React error boundaries catch and miss, where to place them so one failing widget does not blank the page, and how to reset after errors.
How useSyncExternalStore subscribes React to an external store without tearing, why getSnapshot must be cached, and how server snapshots work.
What causes React hydration mismatch errors: dates, random ids, window checks and bad HTML nesting, plus fixes with useId, effects and Suspense.
Why React list keys matter: how index keys attach state to the wrong row after deletes or reordering, and how to pick a stable, unique key.
When to choose useReducer over useState: model status as one field, reject impossible transitions in the reducer, and test it in plain Node.
useRef vs useState in React: refs change without re-rendering, state drives the UI. Learn which values belong where, with timer and DOM examples.
Reset a React form when the selected record changes by giving it a key: how keys control state identity and why useEffect resets go wrong.
Flags pile up when the card tries to know every layout: showHeader, headerIcon, showFooter, footerAlign, compact. Each new screen adds one, and soon nobody can test the combinations.
I'd make the card handle only the frame, meaning padding, border, radius and elevation, and let callers pass content. children covers the body and named slots cover the rest: <Card header={<h3>Pro plan</h3>} footer={<Button>Upgrade</Button>}>. For more structure, compound components work well: Card.Header, Card.Body and Card.Footer share styling through context, and callers arrange them.
Variants are for design rules that genuinely repeat, like variant: 'outlined' | 'elevated', typed as a union so a typo fails to compile. Domain logic, such as whether a plan is the user's current one, stays in the caller.
Semantics matter too: the caller picks the heading level, since the card can't know where it sits on the page, and a clickable card shouldn't nest a button inside a link. I'd test the few combinations the product actually uses.
childrenLikely follow-up: When would you choose compound components with context over slot props?
Because EmailField is a new function every time SignupForm renders. React keeps a component's state only while the element at the same position has the same type, and for a component the type is the function itself. When the user picks a plan, SignupForm re-renders and creates a different EmailField, so React sees a new component type. It unmounts the old one and mounts a fresh one with email back to an empty string, and the input loses focus.
The fix is defining EmailField at module level and passing what it needs as props. If it needs parent state, pass the value and a setter, or lift the state up.
The same reset happens with a key that changes every render, like key={Math.random()}, or with lazy() called inside a component. When you want a reset, use a deliberate key, such as key={userId}.
A quick test: type an email, change the plan, and assert the email is still there.
function SignupForm() {
const [plan, setPlan] = useState('free');
function EmailField() {
const [email, setEmail] = useState('');
return <input value={email} onChange={(e) => setEmail(e.target.value)} />;
}
return (
<>
<EmailField />
<PlanPicker value={plan} onChange={setPlan} />
</>
);
}key values cause the same resetkey when you want a resetLikely follow-up: How would you reset a form on purpose when the user switches to a different record?
Store the id, not the object. const [selectedId, setSelectedId] = useState<string | null>(null), then derive the record during render with items.find(i => i.id === selectedId) ?? null. For a big list, a Map built with useMemo makes that lookup cheap.
If you store the whole object instead, the selection is a copy taken at click time. The list refreshes, the price or status changes, and the details panel keeps showing old values because nothing updates the copy. You now have two sources of truth that drift apart.
Deriving also forces you to handle the case people forget: the selected record disappears after a refresh, because someone deleted it or a filter now excludes it. The derived value becomes null, and you decide what that means, either clearing the selection or showing 'This item is no longer available' so the user isn't confused. I'd write a test for exactly that.
The same rule applies to anything you can compute: keep the minimal source, here the id, and calculate the rest during render.
Likely follow-up: Where would the selected id live if the selection had to survive a reload or be shared as a link?
Keep the saved record and the user's draft separate, with the draft starting as a copy. What you shouldn't do is useEffect(() => setDraft(record), [record]): a background refetch, such as TanStack Query refetching on window focus, then wipes whatever the user was typing.
Two events look similar but need different handling. Switching to a different record should reset the draft, and the clean way is <SettingsForm key={record.id} record={record} />, which remounts the form. A refresh of the same record shouldn't touch the user's edits.
For refreshes, compare the draft with the record to find dirty fields. If nothing is dirty, adopt the new data. If the user has edits and the server version changed, show a banner such as 'This was updated elsewhere' with options to reload or keep editing.
On save, send the version or ETag the draft started from. If the server answers 409 because someone else saved first, you can show the conflict instead of silently overwriting their change.
key={record.id} resets on a record switchLikely follow-up: How would you track which individual fields are dirty?
An IME builds characters in stages. While composing, the input holds provisional text, and React's onChange fires at each step because it follows the native input event. Code that treats every change as final text breaks that.
The common breakages:
value on each change rewrites the text mid-composition, which can cancel the composition or duplicate characters. Format on blur, or after onCompositionEnd.e.nativeEvent.isComposing, and search when composition ends.keydown. Ignore it while isComposing is true; in Safari that keydown can arrive with isComposing false, so also check keyCode === 229.Android keyboards like Gboard use composition even for English words, so this isn't only about Asian languages.
I'd test with real IMEs: Japanese on macOS and Windows, and Gboard on Android. Simulated key events in unit tests won't reproduce these bugs.
onChange fires during compositionisComposing before searching or submitting on EnterkeyCode 229Likely follow-up: How would you build search-as-you-type so it waits for composition to finish?
On the client, show a pending state and block repeats. With React 19 form actions, useActionState returns [state, formAction, isPending], and a submit button rendered inside the form can read useFormStatus().pending. Disabling the button while pending also stops Enter from resubmitting, because implicit submission does nothing when the default button is disabled. With a plain onSubmit, a ref flag set at the start catches a second event that arrives before the re-render.
That reduces duplicates but can't rule them out: a request can time out after the server committed it, and the user retries. So the server enforces it. The form sends an idempotency key generated once with crypto.randomUUID(), and the server returns the original result for a repeated key. A unique constraint on the order reference adds a second guard.
After a failure, the button re-enables and the input stays. In React 19, uncontrolled fields reset after a form action finishes, even when it returned an error, so return the submitted values and feed them back through defaultValue.
isPending or useFormStatus().pending disables submitonSubmit handlersdefaultValueLikely follow-up: Why must useFormStatus be called from a component inside the form, not from the component that renders the form?
The connection depends on the room, so one effect creates it with roomId as its dependency, and its cleanup closes it. When the room changes, React runs the old room's cleanup before the new setup, so you never hold two connections. Strict Mode in development connects, disconnects and reconnects on mount, which tests the cleanup.
Listeners belong in the same effect, so they go away with the connection. The awkward part is values the handler reads that shouldn't cause a reconnect, like muted here. Listing muted as a dependency would drop and reopen the socket every time the user toggles sound. useEffectEvent, stable since React 19.2, gives a handler that always sees the latest props without being a dependency. On older versions, a ref holding the latest value does the same job.
To clear the previous room's messages, the parent renders the component with key={roomId}, so each room gets fresh state. I'd test fast room switching and assert there's one open connection and no duplicate messages.
function ChatRoom({ roomId, muted }) {
const [messages, setMessages] = useState([]);
const onMessage = useEffectEvent((msg) => {
setMessages((list) => [...list, msg]);
if (!muted) playSound();
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on('message', (msg) => onMessage(msg));
conn.connect();
return () => conn.disconnect();
}, [roomId]);
return <MessageList messages={messages} />;
}
// Parent: <ChatRoom key={roomId} roomId={roomId} muted={muted} />roomId as the dependencyuseEffectEvent for values that shouldn't reconnectLikely follow-up: What goes wrong if onMessage is a regular function listed in the effect's dependencies?
Strict Mode runs setup, cleanup, then setup again on mount in development. It simulates a remount, which real apps also do with Fast Refresh or React's <Activity>. If two subscriptions survive, the cleanup doesn't fully undo the setup.
Here the setup is asynchronous. The first cleanup runs before subscribe() resolves, while sub is still undefined, so it does nothing, and that first subscription lives on beside the second. The fix is a flag: let cancelled = false in the effect, set to true in cleanup, and when the promise resolves, unsubscribe at once if cancelled is true.
Other causes: removing a listener with a different function than the one added, such as a fresh arrow function in removeEventListener, or unsubscribing from a different object.
I wouldn't add a useRef(false) 'already subscribed' guard: it silences the check but breaks on real remounts. And I'd verify by counting live subscriptions, not console logs.
useEffect(() => {
let sub;
client.subscribe(channel).then((s) => {
sub = s;
});
return () => sub?.unsubscribe();
}, [channel]);Likely follow-up: Why does React run this check, and which real features depend on effects surviving a remount?
It returns what callers need in domain terms. A useCart() that returns { items, total, addItem, removeItem } is harder to misuse than one that hands out raw setItems and lets every caller invent its own update logic. For two values a tuple like useState's works; beyond that, named fields avoid order mistakes.
Functions it returns should keep their identity between renders, through useCallback or the React Compiler, because callers will put them in effect dependency arrays. An onChange that changes every render makes their effects re-run constantly.
State must live in React, not in module scope. A let cache = [] at the top of the hook's file is shared by every component using the hook. That's a bug unless sharing is the point, and then a real store read through useSyncExternalStore fits better.
Document what isn't obvious: what happens on unmount, whether it's safe during SSR, and what errors look like, such as { status: 'error', error }. I'd test it with renderHook, and with two components using it at once.
renderHook and two consumersLikely follow-up: When should a custom Hook take an options object instead of positional arguments?
useState calls with useReducer?midWhen pieces of state change together and some combinations should be impossible. A checkout wizard with step, values, isSubmitting, error and isDone in separate useState calls can end up with isSubmitting and error both set, or isDone while still on step 2, because every handler must update four things correctly.
With a reducer, each transition is one named action: next, back, submitStarted, submitSucceeded, submitFailed, reset. The rules live in one function: submitFailed sets the error and returns to editing, and back is ignored while submitting. Modeling status as 'editing' | 'submitting' | 'done' instead of booleans removes most impossible states outright.
Reducers must be pure, so the network call stays in the event handler: dispatch submitStarted, await the request, then dispatch success or failure. Strict Mode calls reducers twice in development, which exposes hidden side effects.
Testing gets easier too, since the reducer is a plain function and reducer(state, action) needs no rendering. For two independent fields, though, useState stays simpler.
status union instead of several booleansLikely follow-up: How would you type the actions so TypeScript rejects one with the wrong payload?
I ask three questions: who reads it, how often it changes, and who updates it.
Context fits values that many components at different depths need and that change rarely: theme, locale, the signed-in user, feature flags. In React 19 you render <ThemeContext value={theme}> and read it with useContext or use.
The cost is how updates spread. Every component that reads a context re-renders when its value changes by Object.is, and there's no way to subscribe to just one field. Passing value={{ user, setUser }} creates a new object on every render, so every consumer re-renders even when nothing changed. Memoize the value, or split state and setter into two contexts so components that only update don't re-render.
Context also isn't a data cache. It has no deduplication, no staleness and no refetching, so server data belongs in a query library. State used by one component or a small subtree should stay local or travel as props, and passing JSX as children often removes the prop drilling that made context tempting.
Likely follow-up: How would you stop components that only call the setter from re-rendering?
Server data is a copy of something that lives elsewhere: it goes stale, needs refetching, can fail and retry, should be deduplicated when several components ask for it, and must be invalidated after a mutation. UI state, like an open panel, the selected tab or an unsaved draft, lives only in the browser and has none of those concerns.
A query library like TanStack Query handles the server side. useQuery({ queryKey: ['orders', { status }], queryFn }) caches by key, dedupes concurrent requests and refetches stale data, and invalidateQueries({ queryKey: ['orders'] }) after a mutation keeps lists fresh. UI state stays in useState, a reducer or a small client store.
The anti-pattern is fetching in an effect and copying the response into Redux or several useState calls. Then you maintain a cache by hand: when to refetch, how to avoid races, and which copy is current.
Every input that changes the result, like filters, page or user, belongs in the query key, or different queries share one cache entry.
Likely follow-up: In an app using Server Components, how much of this still applies on the client?
I'd pick one specific slow interaction, like typing in the filter box or switching tabs, and reproduce it with realistic data on a production build, since development builds are much slower.
Then I'd record it with two tools. The React DevTools Profiler shows which components rendered in each commit, how long they took and, with 'Record why each component rendered' turned on, why. The Chrome Performance panel shows long tasks, layout and network, plus React's own tracks since React 19.2. Together they show which cost dominates: too many components re-rendering, one expensive component, layout thrashing or a slow request.
Common findings: a context value recreated every render so the whole tree re-renders, thousands of rows rendered at once, sorting or filtering inside render without useMemo, or a chart library being re-created on every update.
I'd fix the biggest cost first, then record the same interaction again and compare the numbers. Adding memo everywhere before measuring usually adds complexity without touching the slow part.
Likely follow-up: How would you tell whether a component re-rendered because of props, state or context?
The gain is clear: with a library like @tanstack/react-virtual or react-window, a 10,000-row list keeps maybe 30 rows in the DOM, so mounting, rendering and memory drop sharply.
The costs show up wherever code assumed every row exists:
aria-rowcount and aria-rowindex, or aria-setsize and aria-posinset.For a few hundred rows I'd rather use pagination, or CSS content-visibility: auto, which skips rendering offscreen content while keeping it in the DOM, so find in page still works.
content-visibilityLikely follow-up: How would you keep keyboard focus stable while the user arrows through a virtualized list?
The input must update immediately, so its state stays urgent, and the expensive part renders from a deferred copy. With useDeferredValue(query), React first re-renders with the old deferredQuery, so the keystroke appears at once, then renders the new results in the background, abandoning that work if another key arrives.
Two details make it work. Results must be wrapped in memo; otherwise it re-renders during the urgent pass anyway and nothing is gained. And comparing query !== deferredQuery gives a stale flag, so old results can dim instead of being replaced by a spinner. startTransition around a separate filter state does the same job when you control the setter.
Network requests need their own policy: debounce them, and ignore or abort out-of-order responses.
The limit is CPU. React yields to the browser between components, but one component that sorts 50,000 items synchronously still blocks the main thread for that long. Then the fix is less work: useMemo for the sort, virtualization, or a Web Worker.
function Search() {
const [query, setQuery] = useState('');
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<div style={{ opacity: isStale ? 0.6 : 1 }}>
<Results query={deferredQuery} />
</div>
</>
);
}
// memo lets Results skip the urgent render while deferredQuery is unchanged
const Results = memo(function Results({ query }) {
const rows = useMemo(() => filterProducts(query), [query]);
return <ProductList rows={rows} />;
});useDeferredValue or startTransition for resultsmemoquery !== deferredQueryLikely follow-up: When would you choose useTransition over useDeferredValue?
It assumes each render happens once and always reaches the screen; neither is true. A transition can be interrupted and restarted, a component can suspend and render again later, and Strict Mode renders twice in development. Rendering should only calculate JSX from props and state.
All three lines break that rule:
renderCount++ mutates a module variable, so the count is wrong in Strict Mode and after interrupted renders.analytics.track during render can fire twice, or fire for a render that was thrown away. It belongs in an effect, or in the event handler that caused the view.use(fetch(...)) creates a new promise on every render. The component suspends, renders again, makes another promise and suspends again. A promise passed to use must come from a cache, a framework loader, or a parent that created it once.Two related rules: read external mutable stores through useSyncExternalStore, so different components can't show different versions within one render, and don't read or write ref.current during render, except to initialize it lazily.
let renderCount = 0;
function ProductRow({ product }) {
renderCount++;
analytics.track('product_viewed', { id: product.id });
const price = use(fetch(`/api/price/${product.id}`).then((r) => r.json()));
return <li>{product.name}: {price.amount}</li>;
}use must be cacheduseSyncExternalStore for external storesLikely follow-up: Where would you move the analytics call, and how would you avoid double-counting views?
A waterfall happens when each component starts its own fetch while rendering. ProfilePage fetches the user and suspends; only when that resolves does Posts render and start its fetch, and then Comments. Three requests that could run together take three round trips.
The fix is starting independent requests before anything renders. In a framework that means the route loader or the Server Component: call getUser(id) and getPosts(id) without awaiting, then await both or pass the promises down. On the client in React 19, a parent can create the promises once, ideally through a cache, and children read them with use(promise). TanStack Query offers prefetchQuery and useSuspenseQueries for the same idea.
Only truly dependent requests stay sequential, such as needing the user's teamId before fetching the team.
Suspense boundaries decide what the user sees, not when requests start. Adding more Suspense boundaries lets the header appear while posts load, but doesn't parallelize anything. I'd compare the Network panel's waterfall before and after.
use()Likely follow-up: How would you prefetch a route's data when the user hovers its link?
This hits users who loaded the app before a deploy. Their old index.html references chunk files with old hashes, the deploy removed those files, and import() rejects with something like 'Failed to fetch dynamically imported module'. lazy throws that to the nearest error boundary; without one, the whole app unmounts and the user sees a blank page.
Prevention comes first: keep old hashed assets on the CDN for a while after deploying, and upload new assets before switching the HTML.
For the rest, the error boundary should say a new version is available and offer a reload button. A 'try again' that only resets the error boundary won't work, because lazy caches the rejected promise and throws the same error again.
Automatic reloading is fine with a guard: reload once, record that in sessionStorage, and show the message if it fails again, or a truly broken chunk causes an endless reload loop. Vite apps can catch this centrally through the vite:preloadError event. Save any draft before reloading.
lazy caches the rejection, so a reset alone failsLikely follow-up: How would you tell a stale-chunk error apart from the user simply being offline?
Never lose what they typed, and never pretend it was sent. When fetch rejects with a TypeError and navigator.onLine is false, I keep the form as it is and say plainly: 'You're offline. Your changes haven't been sent.' The draft is also saved to localStorage or IndexedDB, so a reload or a closed tab doesn't lose it.
Then offer a way forward: a Retry button, plus an automatic retry when the online event fires, confirmed by a request succeeding, since navigator.onLine only says a network interface exists.
If the product needs true offline submission, queue the request, but show it as 'Waiting to send', never 'Saved', until the server confirms. Each queued submission carries an idempotency key, so a retry after an unclear timeout doesn't create two orders.
This failure should look different from others. Offline means try again later, a 422 means fix these fields, and a 500 means something broke on our side. Lumping them into one 'Something went wrong' toast leaves the user guessing.
online eventLikely follow-up: How would you test this flow without actually disconnecting the network?
First, what it catches. Render it around a test component that throws during render and assert the fallback appears, such as screen.getByRole('alert') with a useful message. Error boundaries catch errors thrown while rendering, in lifecycle methods and in constructors below them, but not in event handlers, async code or the error boundary itself. React logs caught errors to the console, so I stub console.error in these tests to keep output readable.
Second, isolation. Siblings outside the error boundary still render and stay usable, which is the whole point of wrapping one widget rather than the page.
Third, reporting. The onError callback or componentDidCatch sends the error and componentStack to monitoring, and the test checks that no tokens or personal data go with it.
Fourth, recovery. With react-error-boundary, clicking 'Try again' calls resetErrorBoundary, and a change in resetKeys, such as a new userId, resets it automatically. If the error persists, the fallback comes back instead of looping.
resetErrorBoundary or resetKeysLikely follow-up: How would you show an error from a failed event handler using the same fallback UI?
A portal only changes where the DOM goes. Everything that makes it a dialog is still your job:
role="dialog", aria-modal="true" and aria-labelledby pointing at its title.inert on the app root, which React 19 accepts as a boolean prop, and lock body scroll.onClick handlers on its React ancestors. That surprises click-outside logic.The native <dialog> element opened with showModal() gives you the top layer, an inert background and Escape handling for free, and it's often the better base.
I'd test with the keyboard alone and with VoiceOver or NVDA, including opening one dialog from another.
aria-modal and a labeled titleinert background and scroll lock<dialog> with showModal()Likely follow-up: How would you restore focus if the button that opened the dialog was removed while it was open?
React renders an empty <div> and hands it to the library through a ref. React never renders children into that div, so the two never fight over the same DOM.
Creation goes in an effect that runs once: new ChartLib(containerRef.current) after commit, when the element exists. Its cleanup calls chart.destroy(), which removes the library's listeners, timers and canvas. In development, Strict Mode creates, destroys and recreates it on mount, so a leaky destroy() shows up as two charts.
Updates go in a second effect that depends on data and options and calls the library's update method on the existing instance. Recreating the chart on every change is slower and loses zoom or tooltip state. Because that effect compares object identity, the parent should memoize data and options, or they'll count as changed on every render.
For size changes, a ResizeObserver created in the first effect and disconnected in its cleanup handles it. useLayoutEffect is only needed when something must be measured before the browser paints.
function SalesChart({ data, options }) {
const containerRef = useRef(null);
const chartRef = useRef(null);
useEffect(() => {
const chart = new ChartLib(containerRef.current);
chartRef.current = chart;
return () => {
chart.destroy();
chartRef.current = null;
};
}, []);
useEffect(() => {
chartRef.current?.update({ data, options });
}, [data, options]);
return <div ref={containerRef} />;
}destroy() in cleanupdata and optionsLikely follow-up: How would you expose chart methods such as exportPng() to a parent component?
React 19's useOptimistic handles the display layer. Inside the form action, addOptimistic(...) shows the comment immediately, styled as pending. When the action finishes, React drops the optimistic layer and shows the real comments state. So on success, the server's comment must be in real state before the action ends, and on failure the pending comment disappears by itself.
The key is identity. I generate a clientId with crypto.randomUUID() before sending, which the server stores and returns with the real id. That matches the pending item to the saved one, and it dedupes the common race where a WebSocket broadcast of the same comment arrives before the HTTP response. The server can also treat clientId as an idempotency key, so a retry doesn't post twice.
Failure needs a decision, not a silent disappearance: keep the text with a 'Couldn't post. Retry?' message so nothing the user typed is lost. And since two comments can be in flight at once, every lookup goes by clientId, never by position.
const [optimistic, addOptimistic] = useOptimistic(
comments,
(list, pending) => [...list, pending],
);
async function postAction(formData) {
const text = formData.get('text');
const clientId = crypto.randomUUID();
addOptimistic({ id: clientId, clientId, text, pending: true });
try {
const saved = await api.postComment({ text, clientId });
startTransition(() => setComments((list) => [...list, saved]));
} catch {
setFailed({ clientId, text });
}
}
// <form action={postAction}> ... render `optimistic`, not `comments`useOptimistic shows the pending comment at onceLikely follow-up: What happens if you call the optimistic setter outside a transition or action?
Validate on the server, because anything the browser checked can be bypassed, and return expected failures as data instead of throwing. With useActionState(createAccount, initialState), the action receives (prevState, formData) and returns the next state, something like { values, errors: { email: 'That email is already registered.' } }. Throwing is for the unexpected, and those errors go to the nearest error boundary.
In the form, each error sits next to its field: aria-invalid on the input and aria-describedby pointing at the message, so screen readers read it when the field gets focus. isPending disables the button while the action runs.
Return the submitted values too. React 19 resets uncontrolled fields after a form action completes, even when it returned errors, so without defaultValue={state.values.email} the user's input vanishes.
Keep messages human and safe: map a duplicate-key database error to 'That email is already registered', and never return stack traces or SQL. Client-side checks like required and type="email" still help for instant feedback, but the server decides.
// actions.js
'use server';
export async function createAccount(prevState, formData) {
const values = { email: String(formData.get('email') ?? '') };
if (!values.email.includes('@')) {
return { values, errors: { email: 'Enter a valid email address.' } };
}
if (await emailExists(values.email)) {
return { values, errors: { email: 'That email is already registered.' } };
}
await saveAccount(values);
return { values: {}, errors: {}, done: true };
}
// SignupForm.jsx
'use client';
import { useActionState } from 'react';
import { createAccount } from './actions';
export function SignupForm() {
const [state, formAction, isPending] = useActionState(createAccount, { values: {}, errors: {} });
return (
<form action={formAction}>
<input name="email" defaultValue={state.values.email}
aria-invalid={Boolean(state.errors.email)} aria-describedby="email-error" />
<p id="email-error">{state.errors.email}</p>
<button disabled={isPending}>Create account</button>
</form>
);
}aria-invalid and aria-describedby on each fielddefaultValueLikely follow-up: After a failed submit, how would you move focus to an error summary?
Everything cached for the previous user has to go before the new user sees a screen. With TanStack Query, queryClient.cancelQueries() stops in-flight requests and queryClient.clear() empties the cache on sign-out or switch. Putting the user id in query keys, like ['orders', userId], adds a second layer: even a stray entry can't be served to the wrong account.
Late responses are the subtle bug. A slow request started for user A can resolve after B signs in and write A's data into B's screen. Cancelling helps; in hand-written fetch code, checking that the response's user matches the current one is the reliable guard.
Component state needs a reset too: drafts, selections, open panels. The simplest way is remounting the signed-in tree with key={user.id}, which resets every piece of state below it.
Don't forget persisted copies: a persisted query cache, drafts in localStorage, IndexedDB and service worker caches. None of this replaces server authorization, which still checks every request against the session.
cancelQueries() and clear() on an account changekey={user.id} resets component stateLikely follow-up: How would you handle a second tab when the user signs out in the first one?
Make the filters part of the list's identity. With TanStack Query's useInfiniteQuery, a key like ['products', { category, sort }] means a filter change starts a new list from the first page, and a page still loading for the old key can only land in the old cache entry. Its signal aborts requests nobody needs anymore.
Without the library: when filters change, reset the pages and the cursor, abort the in-flight request, and ignore any response whose filters don't match the current ones.
Cursor pagination beats offsets here. With ?offset=40, an item inserted at the top shifts everything down, so the next page repeats an item; a cursor doesn't. If duplicates are still possible, dedupe by id when appending.
The UI keeps two loading states apart. The first load shows a skeleton in place of the list, while isFetchingNextPage shows a small spinner at the bottom. A failed next page shows a retry button there and keeps the loaded pages. An IntersectionObserver on a sentinel element triggers the next fetch.
const products = useInfiniteQuery({
queryKey: ['products', { category, sort }],
queryFn: ({ pageParam, signal }) =>
fetchProducts({ category, sort, cursor: pageParam, signal }),
initialPageParam: null,
getNextPageParam: (lastPage) => lastPage.nextCursor ?? undefined,
});Likely follow-up: How would you restore the scroll position and loaded pages when the user comes back from a detail page?
First, know whether there's anything to lose. Compare the draft with the last saved version rather than setting a flag on the first keystroke, so undoing an edit clears the warning.
For in-app route changes, React Router's data routers provide useBlocker(isDirty). When the user clicks a link, blocker.state becomes 'blocked' and I show my own dialog: save, discard or stay. blocker.proceed() continues and blocker.reset() cancels. After a successful save the draft equals the saved version, so the next route change isn't blocked.
Refreshing, closing the tab or typing a new URL leaves React entirely, so those need a beforeunload listener that calls event.preventDefault(). The browser shows its own generic prompt, ignores custom text, and may skip it if the user never interacted with the page. I add that listener only while the form is dirty.
When losing the work would really hurt, like a long article, I'd also autosave the draft to localStorage and offer to restore it, because prompts can be dismissed or never shown.
useBlocker for in-app route changesbeforeunload for refresh and closing the tabLikely follow-up: Why shouldn't a beforeunload listener stay attached all the time?
A full page load gives screen reader users a lot for free: the new title is announced and reading starts at the top. A client-side route change does none of that. Focus stays on the link that was clicked, or drops to body if that link unmounted, and nothing is announced.
So on each route change I'd do three things. Update document.title; React 19 hoists a <title> rendered inside the page component into the head. Move focus to the new page's main heading, an h1 with tabIndex={-1} focused in an effect, or announce the change through a polite live region. And handle scroll: new pages start at the top, while back and forward restore the previous position, which React Router's <ScrollRestoration /> does.
Not every URL change is a page change. Updating a filter in the query string shouldn't pull focus away from the filter control in use.
Then test with a keyboard and VoiceOver or NVDA: follow a link, and check what's announced and where Tab goes next.
<title>Likely follow-up: What are the trade-offs between moving focus to the heading and announcing through a live region?
'Only some users' points to something that differs between the server and those users' browsers. React 19 logs a diff of the mismatch in the console, so I'd reproduce it and read that.
The usual suspects:
OrderTime formats with the server's time zone and locale, then with the user's during hydration. Chrome DevTools' Sensors panel can override time zone and locale to reproduce it.localStorage, window.innerWidth, cookies read only on the client, Math.random() or Date.now().<div> inside a <p>, which the browser restructures before React sees it.The fixes make the first client render match the server: pass the time zone and locale from the server and format with them, or render a neutral value first and switch to the client-only one in an effect. suppressHydrationWarning is fine on a single timestamp element, never as a blanket fix.
function OrderTime({ placedAt }) {
return <time>{new Date(placedAt).toLocaleString()}</time>;
}Likely follow-up: How would you render a relative time like '5 minutes ago' without a mismatch?
Per route, based on freshness, personalization, SEO and cost.
Static generation builds HTML once at build time. It's the cheapest and fastest to serve, from a CDN, and fits pages that are the same for everyone and change rarely: marketing pages, docs, blog posts. With revalidation, like Next.js's revalidate option, static pages refresh every few minutes without a rebuild.
Server rendering builds HTML on each request. It fits content that must be fresh or personalized but still needs fast first paint or crawlable HTML: search results, product pages with live stock, a signed-in home page. The costs are servers, time to first byte, and caching that must respect personalization.
Client rendering ships a shell and fetches data in the browser. It suits apps behind a login where SEO doesn't matter and interaction dominates, like an admin tool.
Most real apps mix them: a static landing page, server-rendered product pages and a client-rendered dashboard. I'd decide per route with TTFB, LCP, INP and hosting cost.
Likely follow-up: What changes in your caching once a server-rendered page is personalized?
'use client'?hardStart with everything on the server and add 'use client' only where the browser is needed: state, effects, event handlers or browser APIs. On a product page, the server component reads the product from the database and renders the title, description, images, specs and reviews, and none of that code ships to the browser. Interactive parts, like a quantity stepper, an image carousel or a variant picker, become small client components.
'use client' marks a module and everything it imports as client code, so putting it high up, on the page layout, drags the whole tree into the bundle. Server components can still be passed into a client component as children and stay on the server.
Props crossing to the client must be serializable, and they're visible in the page payload. Pass productId, price and stock, not the whole product row with cost price and supplier notes.
The add-to-cart Server Function re-checks price, stock and the user's session on the server, because the client can send anything.
'use client' pulls in everything the module importschildrenLikely follow-up: What can and can't be passed as props from a Server Component to a Client Component?
A Server Function call is a network request, a POST under the hood. That brings latency, from milliseconds to seconds on a bad connection, and new failure modes: offline, timeouts, 500s. The UI needs a pending state, through useTransition or useActionState, and an error state.
It's also a public endpoint. Anyone can call it with any arguments, so it must check authentication, authorization and input, even if its button is hidden.
I keep calculations local when they're cheap and need no secrets: a cart subtotal while quantities change, input formatting, filtering a list already on the page. A Server Function earns the round trip when it needs trusted data or causes effects: applying a discount code, final pricing with tax rules, writing to the database.
In Next.js the client sends Server Function calls one at a time, so one call per keystroke queues up and feels slow; debounce, or calculate locally and confirm once on submit. For reads, server components or cached GET routes fit better.
Likely follow-up: How would you show an instant subtotal while still guaranteeing the server's price at checkout?
The bug only appears when responses arrive out of order, so the test must control the order.
I'd mock the search function so each call returns a promise I resolve by hand, with a small deferred() helper. Enter 'rea', then 'react', which starts two requests. Resolve the second first, with results for 'react', then the first, with results for 'rea', each inside act().
Then assert that the 'react' results show, the 'rea' results never appear, and the loading indicator is gone. A buggy implementation passes when responses arrive in order and fails here.
Two more cases: unmounting while a request is pending causes no error or update, and with AbortController the first request's signal is aborted. Aborting alone isn't enough, since a response can resolve just as you abort, so the component should also ignore results for a query that's no longer current. With a debounce, fake timers keep the test fast.
function deferred() {
let resolve;
const promise = new Promise((r) => (resolve = r));
return { promise, resolve };
}
test('shows results for the latest query only', async () => {
const first = deferred();
const second = deferred();
searchApi.mockReturnValueOnce(first.promise).mockReturnValueOnce(second.promise);
render(<Search />);
const box = screen.getByRole('searchbox');
fireEvent.change(box, { target: { value: 'rea' } });
fireEvent.change(box, { target: { value: 'react' } });
await act(async () => second.resolve([{ id: 2, name: 'React docs' }]));
await act(async () => first.resolve([{ id: 1, name: 'Reading list' }]));
expect(screen.getByText('React docs')).toBeInTheDocument();
expect(screen.queryByText('Reading list')).not.toBeInTheDocument();
});Likely follow-up: How would you implement the component so it passes this test?
I'd swap the real source for a fake I can drive: subscribe(fn) that records listeners and returns an unsubscribe function, an emit(value) method, and a listener count.
Then I test through the Hook's result with renderHook or a tiny test component:
act() and assert result.current shows the new value.unmount() and assert the listener count is zero.With useSyncExternalStore, two bugs deserve tests. A subscribe created inline changes every render, so React resubscribes each time, visible as listener churn. And getSnapshot must return the same value until the source changes, or React re-renders in a loop.
I wouldn't assert on internal refs or state, which can change while behavior stays the same.
function createFakeSource() {
const listeners = new Set();
return {
subscribe(fn) {
listeners.add(fn);
return () => listeners.delete(fn);
},
emit(value) {
listeners.forEach((fn) => fn(value));
},
get listenerCount() {
return listeners.size;
},
};
}
test('updates on events and unsubscribes on unmount', () => {
const source = createFakeSource();
const { result, unmount } = renderHook(() => useLatestValue(source));
act(() => source.emit(42));
expect(result.current).toBe(42);
unmount();
expect(source.listenerCount).toBe(0);
});subscribe, emit and a listener countrenderHook or a test componentsubscribe and cached getSnapshotLikely follow-up: Why must getSnapshot return a cached value?
Make the component generic over the item type: ListProps<T> has items: readonly T[], plus getKey and renderItem callbacks that each take a T. TypeScript infers T from items, so with items={users} both callbacks know they receive a User, and u.email autocompletes and type-checks.
getKey is passed in on purpose. Not every record has an id; some use sku, some a composite key. Assuming a field would force any or a cast, and falling back to the array index breaks row state and animations when the list reorders.
Typing items as any would compile too, but then a renderItem written for products could receive users and nobody would know until runtime.
Two practical notes. In a .tsx file a generic arrow function needs <T,> so the parser doesn't read it as JSX. And in React 19, ref is an ordinary prop on function components, so a generic list that takes a ref no longer needs the forwardRef cast that used to lose the type parameter.
type ListProps<T> = {
items: readonly T[];
getKey: (item: T) => string | number;
renderItem: (item: T) => React.ReactNode;
empty?: React.ReactNode;
};
function List<T>({ items, getKey, renderItem, empty = null }: ListProps<T>) {
if (items.length === 0) return <>{empty}</>;
return (
<ul>
{items.map((item) => (
<li key={getKey(item)}>{renderItem(item)}</li>
))}
</ul>
);
}
// T is inferred as User, so u.email autocompletes and type-checks
<List items={users} getKey={(u) => u.id} renderItem={(u) => u.email} />;ListProps<T> with renderItem and getKeyT is inferred from itemsany: mismatched renderers fail to compileLikely follow-up: How would you constrain T so getKey can default to item.id?
Three independent pieces of state, isLoading, error and data, allow eight combinations, and several are nonsense: loading with an error, or an error next to fresh data. Each one turns into a rendering bug sooner or later.
A discriminated union allows only the valid ones: { status: 'idle' } | { status: 'loading' } | { status: 'success'; data: T } | { status: 'error'; error: Error }. Data exists only in the success case, so TypeScript won't let you read state.data until you've checked state.status === 'success'. A switch on status narrows each branch, and assigning the leftover value to never in default makes the compiler complain when someone adds a state and forgets to render it.
Real screens often need one more state: refreshing while still showing the old data. Model it on purpose, as { status: 'refreshing'; data: T } or a separate staleData field, rather than sneaking a boolean back in.
Libraries already work this way. TanStack Query's result type narrows data once you check isSuccess, for example.
type RequestState<T> =
| { status: 'idle' }
| { status: 'loading' }
| { status: 'success'; data: T }
| { status: 'error'; error: Error };
function UserPanel({ state }: { state: RequestState<User> }) {
switch (state.status) {
case 'idle':
return null;
case 'loading':
return <Spinner />;
case 'success':
return <Profile user={state.data} />;
case 'error':
return <ErrorMessage error={state.error} />;
default: {
const unhandled: never = state;
return unhandled;
}
}
}statusdata exists only in the success casenever check catches unhandled statesLikely follow-up: How would you write a reducer whose actions move between these states safely?
I'd start from the WAI-ARIA tabs pattern, or a library that implements it like Radix UI or React Aria, because the details are easy to get wrong.
Structure: a role="tablist" with an aria-label; tabs with role="tab", aria-selected and aria-controls pointing at their panels; panels with role="tabpanel" and aria-labelledby pointing back. useId() generates ids that match between server and client.
Keyboard: only the selected tab is in the Tab order (tabIndex={0}, others -1), a roving tabindex. Left and Right arrows move between tabs and wrap; Home and End jump to the ends. Activation is automatic, where focus selects the tab, or manual, with Enter or Space, which is better when panels load slowly.
Inactive panels get the hidden attribute unless their state must survive, and a panel with no focusable content gets tabIndex={0} so keyboard users can reach it.
Then test with a keyboard and a screen reader, which should announce something like 'Billing, tab, 2 of 4, selected'.
Likely follow-up: When would you choose manual activation over automatic?
Automated checks like jest-axe or @axe-core/playwright belong in CI, but they only find what a machine can detect: missing labels, contrast values, invalid ARIA. They can't tell whether focus goes somewhere sensible or whether an error message makes sense.
For each critical flow, like sign-up or checkout, I'd add manual passes:
I test the dynamic states, not just the first screen: validation errors, loading, empty results, toasts and dialogs. In unit tests, querying by role and name, such as getByRole('button', { name: 'Save' }), keeps markup honest too.
Where possible, sessions with disabled users or an audit by a specialist find what the team's own testing misses.
Likely follow-up: Which accessibility problems can axe never detect?
The UI uses permissions to be helpful; the server uses them to decide. I'd load the user's permissions from something like /api/me, such as ['invoice:read', 'invoice:approve'], and use a small can('invoice:approve') helper to hide or disable actions. Disabling with a reason, like 'Only finance managers can approve', often beats hiding.
Every protected read and write is still checked on the server, which answers 403 when the session lacks the permission. Anyone can call the API directly or flip a flag in DevTools.
Two mistakes to avoid. Don't send data the user may not see and then hide it in the UI: a salary column hidden with CSS, or filtered out after the fetch, is still in the network response. Filter on the server. And roles change mid-session: when an action returns 403, show a clear message and refetch permissions so the UI catches up.
Route guards that hide pages a user can't use are fine for UX, as long as nobody mistakes them for protection.
Likely follow-up: Should an unavailable action be hidden or shown disabled, and what decides it?
First decide whether raw HTML is allowed in the Markdown. Usually it isn't. react-markdown turns Markdown into React elements without dangerouslySetInnerHTML, doesn't render raw HTML by default, and its default urlTransform blocks dangerous schemes such as javascript:. If you need some HTML, add rehype-raw together with rehype-sanitize and an allowlist schema. With a string renderer like marked, run the output through DOMPurify before it goes anywhere near dangerouslySetInnerHTML.
Links and images need rules of their own: allow http, https and mailto, add rel="noopener noreferrer nofollow" to user links, and consider proxying images so a tracking pixel can't log every reader's IP address.
Never evaluate user content as code. MDX from untrusted users is JavaScript, not Markdown.
The preview must use the same pipeline and rules as the published page, ideally one shared module, or the server rendering both. Otherwise the preview looks safe while the published version runs different code. I'd test both with payloads like <img src=x onerror=alert(1)> and [click](javascript:alert(1)).
react-markdown renders elements, not HTML stringsrehype-sanitize or DOMPurifyLikely follow-up: Why isn't sanitizing only in the preview enough?
By feature first. Each area, like features/billing or features/orders, holds its own components, hooks, API calls and routes, so a billing change mostly touches one folder. Shared code lives in shared/ui for design-system pieces like Button and Dialog, and in shared/lib for small utilities. Top-level components/ and hooks/ folders stop scaling at a few hundred files, because related code gets scattered.
Dependency direction is the rule that keeps this healthy: features may import from shared, shared never imports from features, and features don't reach into each other's internals. An ESLint rule like import/no-restricted-paths enforces it.
Data access goes through each feature's query hooks, such as useInvoices(), rather than fetch calls sprinkled through components. Routes are lazy-loaded per feature, and tests sit beside the code.
I'd add structure when pain appears, not before. A utils.ts growing past a few hundred lines, or two features importing each other, is the signal to split. A monorepo only makes sense once several apps need the same packages.
Likely follow-up: How would you stop two features from importing each other's internals?
I'd write three or four real usages from actual screens before looking at the implementation, because an awkward API shows up immediately in calling code.
Then a checklist:
Select should accept value with onChange and also defaultValue, like native inputs, and warn when a consumer switches between the two.ref through, which is just a prop in React 19, and spread remaining props such as aria-*, data-* and className onto the root element so consumers aren't stuck.onChange, disabled, id, not isDisabled in one component and disabled in the next..dropdown > div:nth-child(2) and breaks on the next refactor.Every supported combination gets a Storybook story, which doubles as documentation and as interaction tests. Breaking changes go out as a major version with a changelog, plus a codemod when the change is mechanical.
ref and spread native propsLikely follow-up: How would you deprecate a prop without breaking existing consumers?
Bundlers like Vite put a content hash in each asset name, such as assets/index-4f2a9c.js, which allows a safe policy:
Cache-Control: public, max-age=31536000, immutable, safe because a changed file gets a new name.Cache-Control: no-cache, so browsers revalidate index.html and find new builds.One trap: an SPA fallback that serves index.html for unknown paths must not cover /assets/. Otherwise a missing chunk returns HTML with status 200, and the browser fails with a MIME-type error or Unexpected token '<' instead of a clean 404.
Rollback then means pointing the HTML back at the previous build, whose assets still exist. I'd test deep links, refreshing a nested route, and a tab left open across a deploy. A service worker needs its own update flow, such as prompting users to reload.
immutableno-cache/assets/Likely follow-up: What would you change if the app also has a service worker?
I'd move to React 18.3 first. It's identical to 18.2 but warns about every API React 19 removes, so running the app and tests on 18.3 produces a to-do list.
Then the official codemods: npx codemod@latest react/19/migration-recipe handles changes like ReactDOM.render to createRoot and moved act imports, and types-react-codemod fixes TypeScript breakages. Removed APIs to look for: ReactDOM.render and hydrate, findDOMNode, string refs, legacy context, propTypes (now silently ignored) and defaultProps on function components. In TypeScript, useRef needs an argument, and an arrow ref callback that implicitly returns a value now counts as a cleanup function.
Next, the ecosystem: the router, state library, testing library, component library and, if relevant, the framework version must all support 19.
Then behavior, not just compilation. Run interaction tests, check SSR and hydration warnings, and watch error reporting, since uncaught render errors now go to window.reportError and the new root options instead of being rethrown. Ship it gradually with monitoring and a quick revert.
ReactDOM.render, string refs and propTypesLikely follow-up: What changed in how React 19 reports errors thrown during rendering?
Every user-visible string goes through a library like react-intl, i18next or Lingui, as a complete sentence with placeholders. Concatenating 'Hello ' + name fragments breaks in languages with a different word order. Plurals use ICU syntax, such as {count, plural, one {# message} other {# messages}}, because many languages have more than two plural forms.
Formatting comes from the locale: Intl.NumberFormat for currency, Intl.DateTimeFormat for dates, Intl.RelativeTimeFormat for '3 days ago', and Intl.ListFormat for 'A, B and C'.
Layout has to flex. German text often runs about 30% longer than English, so avoid fixed widths. Arabic and Hebrew need dir="rtl" on the document and CSS logical properties like margin-inline-start. Forms shouldn't assume people have a first and a last name.
With SSR, the server picks the locale from the URL or a cookie, renders with it, and sends the same locale, time zone and messages to the client, or hydration will mismatch.
I'd test with pseudo-localization, which stretches and accents strings, plus a real right-to-left locale.
Intl APIs for numbers, dates and listsLikely follow-up: How does pseudo-localization help before real translations exist?
I'd compare them on what will actually hurt this team and this app.
clsx or cva, and the team has to accept long class strings.Then the practical checks: SSR setup, style ordering where two systems meet, how theming and dark mode work, and bundle cost.
I'd build one real feature, a form with validation states plus a dialog, in the top two candidates. Whichever wins, design tokens for color, spacing and type live in one place.
Likely follow-up: What problems appear when two styling systems coexist during a migration?
Behavior tests come first, written with Testing Library so they survive the rewrite. Then I convert one leaf component at a time; classes and function components mix freely, so there's no big-bang rewrite.
The trap is translating lifecycle methods one by one. A subscription split across componentDidMount, componentDidUpdate and componentWillUnmount becomes one useEffect with its cleanup and the right dependencies, while unrelated work that shared a componentDidMount becomes separate effects.
Some semantics change:
this.setState({ a }) merges into an object; a useState setter replaces the value, so split the state or spread the old value.this.props inside a timeout reads the latest props, while a closure in a function component sees the render it came from. Code that relied on the latest value needs a ref.this.timer become useRef.getDerivedStateFromProps usually becomes a value computed during render, or a reset with key.PureComponent becomes memo.Error boundaries stay classes, since React 19 still has no Hook for them.
useState setter replaces; setState mergedLikely follow-up: How would you share Hook-based logic with a class component you can't convert yet?
First, an inventory: every slice, which components read and write it, how often it changes, and what the middleware does, such as thunks or sagas, logging, persistence with redux-persist, and DevTools time travel. Each needs a replacement or a decision to drop it.
The big technical difference is subscriptions. useSelector(state => state.cart.count) re-renders a component only when that selected value changes. A context re-renders every consumer whenever its value changes, so moving a frequently updated store into one big context can turn a cheap update into a whole-tree re-render. Split contexts per slice, separate state from dispatch, or use a selector-based store like Zustand or useSyncExternalStore.
Persisted data needs migrating: users have the old shape in storage, say under persist:root, and the first load after release must convert it rather than crash or wipe settings.
To migrate safely, I'd add hooks like useCart() that wrap useSelector today, move components onto them, then swap the implementation behind the hook. I'd profile critical interactions before and after.
Likely follow-up: When is plain context genuinely enough to replace a global store?
Separate receiving data from rendering it. Hundreds of WebSocket messages a second, each put into React state, means hundreds of renders. I'd write messages into a store outside React, a Map from symbol to latest quote, and flush to the screen every 250 milliseconds or once per animation frame.
Each widget subscribes only to what it shows, through useSyncExternalStore with a selector or a library like Zustand, so a price change for one stock doesn't re-render the news panel. Rows use memo and id keys, and dense charts use canvas.
Memory must be bounded: a ring buffer of the last N points per series stops an all-day session from growing forever.
Connections drop, so plan recovery: reconnect with exponential backoff and jitter, then fetch a snapshot and apply only events with sequence numbers after it, which also reveals gaps. While disconnected, show 'Live updates paused since 10:42' so nobody acts on stale numbers. I'd measure render time and heap size over long runs at realistic rates.
Likely follow-up: How would you pause work while the dashboard tab is in the background?
I'd start with requirements, which decide the architecture: how many people edit one document at once, offline support, rich text or plain, comments and presence cursors, version history, and permission levels like view, comment and edit.
Then the core decision: how concurrent edits merge. Operational transformation, as in Google Docs, needs a central server to transform operations. CRDTs like Yjs or Automerge merge on any client, which suits offline use, at some memory and size cost.
On the client, an editor engine such as ProseMirror, Lexical or Tiptap holds the document state, not React. A keystroke shouldn't re-render the document through React; React renders the toolbar, sidebars and comments around it. Local edits apply instantly, sync over a WebSocket and persist to IndexedDB. Presence travels on a separate, short-lived channel.
Then failure cases: reconnecting and sending missed updates, permissions changing mid-session, and huge documents. I'd close with accessibility, such as keyboard shortcuts and careful announcement of others' edits, and the metrics I'd watch, like sync latency.
Likely follow-up: How would you show presence cursors without storing them in the document?
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.
Prefer multiple choice? All 26 React MCQs with answers →