Full-Stack Questions
React Interview Questions
Component design, hooks, rendering cost, forms, data fetching, React 19, and code-review style questions.
On this page
- Words used in this lesson
- Component design and composition
- Controlled and uncontrolled: choosing and avoiding the traps
- Designing a custom hook
- Context pitfalls
- Render performance: what really matters
- Lists and keys in code review
- Forms: React Hook Form and Actions
- Error boundaries, portals and refs
- Suspense and data fetching in React 19
- What changed in React 19, 19.2 and 19.3
- Testing, structure, state tools and accessibility in short
- "What happens when…" walk-throughs
- A code-review table
- Common bugs
- Interview questions
- Key takeaways
- What's next
In one line: at 4+ years, React interviews stop asking "what is a hook?" and start asking "what is wrong with this code, and what would you do?" The answers come from a few habits: compose small components instead of adding props, keep one source of truth for each value, make hooks clean up after themselves, know what really causes a re-render, and know what React 19 changed so that you do not repeat outdated advice.
This lesson does not repeat the basics. For how rendering works, see Frontend Depth, Lesson 00. For hooks and stale closures, see Lesson 01. For Server Components, Suspense and hydration, see Lesson 02. For where state should live, see Lesson 03. Here we go for new questions: design choices, code-review snippets, "what happens when…" walk-throughs and the bugs that appear in real projects. I ran the code in this lesson with React 19.3, React Testing Library and Vitest, so the numbers and warnings are real output, not guesses.
Words used in this lesson
- Composition is building a bigger component by putting smaller components inside it, instead of giving one big component many settings (props). Think of Lego bricks versus one moulded toy.
- A controlled component is a form field whose value is held in React state, and React writes that value into the field on every render. An uncontrolled component is a field whose value is held by the browser, and React reads it only when needed (on submit, or through a ref).
- A custom hook is a function whose name starts with
useand that calls other hooks, so that you can share logic (not state) between components. - Context is a React feature that lets a parent give a value to every component below it without passing props at each level.
- Memoization (also "memoizing") is remembering the result of a calculation so that the same inputs do not repeat the work.
useMemoremembers a value.useCallbackremembers a function.memoremembers a component's output and skips a render when the props are the same. - An error boundary is a class component that catches errors thrown while React renders its children, and shows a fallback screen instead of a blank page.
- A portal is a way to render a child into a different place in the page (for example
document.body) while it stays in the same place in the React tree. - A ref is a box (
{ current: ... }) that React keeps between renders and that does not cause a render when you change it. It is often used to hold a DOM element. - An Action (React 19) is an async function that you pass to a form's
actionprop or run inside a transition. React tracks its pending state and its errors for you. - The React Compiler is a build-time tool that adds memoization to your components automatically.
Component design and composition
The most common design mistake is the prop explosion: a component that gets a new prop for every new request.
// Hard to use and hard to change: each new need adds one more prop<Card title="Pay" showIcon icon="card" showFooter footerText="Secure" footerAlign="right" />The fix is to let the parent decide what goes inside. React gives you two simple tools.
Tool 1: children as a slot. A component renders {children} and does not care what they are. This also helps performance. A child that is created by the parent is not re-created when the wrapper's own state changes:
export function Counter({ children }: { children: ReactNode }) { const [n, setN] = useState(0); return (<><button onClick={() => setN(n + 1)}>{n}</button>{children}</>);}// <Counter><ExpensiveChart /></Counter>In my test, I clicked the button twice. The children element rendered once, because the parent that owns the element did not change. A normal child component inside Counter would render three times.
Tool 2: compound components. A group of components that share hidden state through context and are used together, such as <Tabs>, <Tabs.Tab> and <Tabs.Panel>:
const TabsContext = createContext<TabsValue | null>(null);function useTabs() { const value = useContext(TabsContext); if (!value) throw new Error("Tabs parts must be used inside <Tabs>"); return value;}export function Tabs({ defaultTab, children }: { defaultTab: string; children: ReactNode }) { const [active, setActive] = useState(defaultTab); const value = useMemo(() => ({ active, setActive }), [active]); return <TabsContext value={value}>{children}</TabsContext>;}Tabs.Tab = function Tab({ id, children }: { id: string; children: ReactNode }) { const { active, setActive } = useTabs(); return <button role="tab" aria-selected={active === id} onClick={() => setActive(id)}>{children}</button>;};The user writes the layout. The component keeps the rules (which tab is open, the right aria-selected). Two details are worth saying in an interview: the hook throws a clear error when used outside its parent, and the context value is memoized (see the context section below).
Difference in one line: children as a slot is for "put anything here"; compound components are for "these parts must work together".
In React 19 you write <TabsContext value={...}> as the provider. The older <TabsContext.Provider> still works but is the legacy form.
When should you not compose? When there are only two or three real variants of one thing (a Button with variant="primary"), a prop is simpler. Use composition when a prop would change structure (what is inside), not just style.
Controlled and uncontrolled: choosing and avoiding the traps
| Controlled | Uncontrolled | |
|---|---|---|
| Where the value lives | React state | The DOM (the browser) |
| How you read it | From state at any time | On submit (FormData) or from a ref |
| Re-render per keystroke | Yes | No |
| Good for | Live validation, formatting as you type, fields that depend on other fields | Simple forms, file inputs, big forms with many fields |
| Prop used | value + onChange | defaultValue (or none) |
Three traps come up in code reviews. I triggered each one in a test.
Trap 1: value without onChange. React warns: "You provided a value prop to a form field without an onChange handler. This will render a read-only field." If you want a starting value only, use defaultValue.
Trap 2: switching from uncontrolled to controlled. If state starts as undefined and later becomes a string, React warns: "A component is changing an uncontrolled input to be controlled." Start with "", not undefined.
Trap 3: defaultValue is read only once. If the parent passes a different defaultValue later, the input does not change. When you edit a different record (a different user id), give the editor a key:
<UserEditor key={user.id} user={user} /> // new key = new component = new defaultsIn my test the field kept the old typed text after the id changed. With key={id} it showed user-2. This is the standard way to "reset a form". It is better than an effect that copies props into state (see Lesson 01).
Designing a custom hook
A hook is part of your team's API. In a review, check that: the name says what it does; effects clean up (timers, listeners, requests); races are handled (an old response must not overwrite a newer one); the return shape is simple (an object for more than two values, a tuple for two); surprises are visible (a hook that writes to localStorage says so); and it works on the server (no window during render). Remember that every call to a custom hook gets its own state: two components that call useOnlineStatus() share a source of truth (the browser), not a variable.
Here is a small, correct hook for a classic problem, a search that fires on each keystroke:
export function useDebouncedValue<T>(value: T, delayMs: number): T { const [debounced, setDebounced] = useState(value); useEffect(() => { const id = setTimeout(() => setDebounced(value), delayMs); return () => clearTimeout(id); // each new value cancels the old timer }, [value, delayMs]); return debounced;}In my test, five keystrokes 100 ms apart with a 300 ms delay produced one value: "react".
And a fetching hook that cancels the old request when the URL changes. It also derives loading during render instead of calling setState at the start of the effect (the ESLint rule react-hooks/set-state-in-effect flags that pattern):
export function useSearch<T>(url: string | null): SearchState<T> { const [result, setResult] = useState<Result<T> | null>(null); // Result = { url, data?, error? } useEffect(() => { if (!url) return; const controller = new AbortController(); fetch(url, { signal: controller.signal }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json() as Promise<T>; }) .then((data) => setResult({ url, data })) .catch((error: unknown) => { if (error instanceof DOMException && error.name === "AbortError") return; // not a real error setResult({ url, error: error as Error }); }); return () => controller.abort(); }, [url]); if (!url) return { status: "idle" }; if (!result || result.url !== url) return { status: "loading" }; // derived, not stored if (result.error) return { status: "error", error: result.error }; return { status: "success", data: result.data as T };}I tested the race. Request A (slow) is sent, then request AB (fast). Without any protection, the slow answer arrives last and the screen shows "result for a (slow)". With a cleanup flag (let ignore = false; ... return () => { ignore = true }) the screen correctly shows "result for ab". AbortController goes one step further because it also stops the network work.
For browser-owned data (online status, window size, media queries), use useSyncExternalStore. It is a hook that reads a value from outside React and re-renders when it changes, without tearing (two parts of the screen showing different values). Its third argument gives the value to use on the server.
export function useOnlineStatus() { return useSyncExternalStore(subscribe, () => navigator.onLine, () => true);}For server data in a real app, most teams use a library such as TanStack Query instead of writing useSearch by hand. It handles caching, retries and de-duplication. See Lesson 13 for libraries, and Frontend Depth, Lesson 03 for server state.
Context pitfalls
Context is simple, and that is why it is misused. Remember three facts.
- Every consumer re-renders when the context value changes. "Changes" means a different object by reference (
Object.is). memodoes not stop a context update. A memoized component skips renders caused by its parent, but not renders caused by a context it reads.- A new object in
value={{ ... }}is a new value on every render of the provider.
I measured this. A provider re-rendered because of an unrelated state change. Two memoized consumers read two contexts: one with value={{ n: 1 }} written inline, and one with the value in useMemo. After two provider re-renders, the inline one had rendered 3 times; the useMemo one once.
The fixes, from simple to heavy:
- Memoize the value (
useMemo), as in theTabsexample above. - Split contexts by how often they change. Keep the rarely changing actions apart from the often changing data:
const CartStateContext = createContext<string[]>([]);const CartActionsContext = createContext<{ add: (id: string) => void }>({ add: () => {} });export function CartProvider({ children }: { children: ReactNode }) { const [items, setItems] = useState<string[]>([]); const actions = useMemo(() => ({ add: (id: string) => setItems((x) => [...x, id]) }), []); return ( <CartActionsContext value={actions}> <CartStateContext value={items}>{children}</CartStateContext> </CartActionsContext> );}A button that only calls add reads CartActionsContext, so it does not re-render when items change.
- Use a store with selectors (Zustand, Redux Toolkit, Jotai). A selector lets a component subscribe to one slice. Context cannot do that. See Lesson 03 for the decision.
Context is not a state manager. It only carries a value down. Where the value comes from (useState, useReducer, a store) is a separate choice.
Render performance: what really matters
Before you reach for memo, answer one question: is there a measured problem? Open the React DevTools Profiler, record the slow interaction and find the component that takes long. Most apps do not need manual memoization. When they do, the cause is usually one of these:
| Symptom | Real cause | Fix |
|---|---|---|
memo child still re-renders | A prop is a new object, array or function each render | Move the value outside the component, or useMemo / useCallback, or let the Compiler do it |
| Whole page re-renders on typing | State is too high in the tree | Move state down into the small component that uses it |
| Slow list | Thousands of DOM nodes | Virtualize (render only the rows on screen) |
| Slow typing in a field | Heavy render on each keystroke | useDeferredValue or startTransition for the heavy part |
| Wasted render after context change | Inline provider value | Memoize or split the context |
I confirmed the first row. A memo child received style={{ color: "red" }} written inline. It rendered on every parent render (3 renders for 2 clicks), so memo did nothing. The fix is a stable value:
const Row = memo(function Row({ style, onPick }: { style: { color: string }; onPick: (id: number) => void }) { return <li style={style} onClick={() => onPick(1)}>row</li>;});export function List() { const [n, setN] = useState(0); const style = useMemo(() => ({ color: "red" }), []); // same object every render const onPick = useCallback((id: number) => setN(id), []); // same function every render return (<ul><Row style={style} onPick={onPick} />{n}</ul>);}The React Compiler
The React Compiler is a build-time tool that reads your components and adds the memoization for you. Version 1.0 is stable. It is opt-in: it is a separate package (babel-plugin-react-compiler), not part of React itself. It works with React 17, 18 and 19, and it is designed to work best with 19. In Next.js 16, you turn it on with reactCompiler: true in next.config.ts (it is not on by default, and builds get slower because the Compiler uses Babel).
I compiled this component:
export function ProductList({ products, onSelect, filter }) { const visible = products.filter((p) => p.name.includes(filter)); const handle = (p) => onSelect(p.id); return <ul>{visible.map((p) => <li key={p.id} onClick={() => handle(p)}>{p.name}</li>)}</ul>;}The output starts with const $ = _c(12); — a cache with 12 slots — and wraps the filter result, the inner functions and the JSX in if ($[0] !== filter || ...) checks. In plain words: the same inputs return the same output without redoing the work, and the inline arrow functions are remembered too. This is more precise than most hand-written memoization.
What the official guidance says, and what you should say:
- New code: rely on the Compiler. Use
useMemoanduseCallbackonly where you need exact control, for example a value that is a dependency of an effect. - Existing code: leave your current
useMemoanduseCallbackin place. Removing them can change what the Compiler produces. - The Compiler assumes your code follows the Rules of React: components and hooks must be pure (the same inputs give the same output) and must not change props or state directly.
The Compiler can freeze an impure value. I compiled function Clock() { const now = Date.now(); return <p>{now}</p>; }. The output computes Date.now() once and stores it in the cache, so the time never updates. The ESLint plugin eslint-plugin-react-hooks (version 7, recommended config) caught it as react-hooks/purity: "Cannot call impure function during render". It also reported react-hooks/set-state-in-effect for an effect that only calls setTotal(...) to copy a computed value. Run this plugin before you turn the Compiler on.
Lists and keys in code review
Lesson 00 explains how keys work. In reviews, look for these three:
key={index}on a list that can be reordered, filtered or deleted from. I typed "AAA" in the first of three uncontrolled inputs, then deleted the first row. With index keys, the text "AAA" stayed in the first remaining input (which now belonged to rowb). Withkey={item.id}it moved away with its row.key={Math.random()}orkey={crypto.randomUUID()}in render. A new key on every render means React destroys and rebuilds the element each time. My test showed the typed text lost after one re-render.- A key on the wrong element. The key must be on the outermost element returned inside
.map, not on an inner<li>.
A key is also a reset button: change the key to throw away a component's state on purpose (the UserEditor example above).
Forms: React Hook Form and Actions
You have three common choices.
| Choice | How it works | Pick it when |
|---|---|---|
| Plain state (controlled fields) | useState per field | Tiny forms, one or two fields |
| React Hook Form (RHF) | Fields are uncontrolled; RHF reads them through refs and register | Many fields, validation rules, performance matters |
React 19 Actions (<form action> + useActionState) | The form passes FormData to an async function | Mutations, Server Actions, progressive enhancement |
React Hook Form is a popular library that keeps field values in the DOM and re-renders only when you ask it to. I typed five characters into a field and counted renders: RHF's register rendered the component 2 times; a useState controlled input rendered 6 times. With zodResolver and Zod 4, validation errors came out as "Enter a valid email" and "Must be 18+", and the submitted value was already typed (age: 30 as a number, because I used z.coerce.number()).
A common RHF trap is watch(). Calling watch("name") in the form component re-renders the whole form on each keystroke (5 renders in my test). useWatch in a small child component keeps the parent at 2 renders.
React 19 Actions change how you write submit handlers. useActionState takes an async function and gives you three things: the latest state, an action to give to <form action>, and a pending flag.
type FormState = { message: string; values: { name: string } };async function saveProfile(_prev: FormState, formData: FormData): Promise<FormState> { const name = String(formData.get("name") ?? "").trim(); if (!name) return { message: "Name is required", values: { name } }; await new Promise((r) => setTimeout(r, 20)); // pretend to call the server return { message: `Saved ${name}`, values: { name } };}function SubmitButton() { const { pending } = useFormStatus(); // must be INSIDE the <form>, in a child return <button disabled={pending}>{pending ? "Saving..." : "Save"}</button>;}export function ProfileForm() { const [state, action] = useActionState(saveProfile, { message: "", values: { name: "" } }); return ( <form action={action}> <input name="name" aria-label="Name" defaultValue={state.values.name} /> <SubmitButton /> <p role="status">{state.message}</p> </form> );}Two gotchas, both tested:
React resets uncontrolled fields after an Action finishes. In my first version (no defaultValue), the input became empty after the action, even when validation failed. Return the submitted values from the action and feed them back with defaultValue, as above.
useFormStatus only sees a parent <form>. When I called it in the same component that renders the <form>, pending was never true. In a child component (SubmitButton) it was. Many blog posts and even popular interview lists show it in the wrong place.
useOptimistic shows the expected result at once, and goes back if the work fails:
export function LikeButton({ likes, send }: { likes: number; send: () => Promise<number> }) { const [real, setReal] = useState(likes); const [shown, addOptimistic] = useOptimistic(real, (current, delta: number) => current + delta); const [, startTransition] = useTransition(); return ( <button onClick={() => startTransition(async () => { addOptimistic(1); setReal(await send()); })}> {shown} </button> );}Right after the click, the button showed the new count. In a separate run where I never updated the real state, the number went back to the old value when the transition ended. That automatic rollback is the point.
Error boundaries, portals and refs
Error boundaries
An error boundary is a class component with static getDerivedStateFromError (to show the fallback) and, optionally, componentDidCatch (to log). There is still no hook version. Most teams use the small react-error-boundary package, which the React docs mention.
export class ErrorBoundary extends Component<BoundaryProps, { error: Error | null }> { state = { error: null as Error | null }; static getDerivedStateFromError(error: Error) { return { error }; } componentDidCatch(error: Error, info: ErrorInfo) { this.props.onError?.(error, info); } render() { return this.state.error ? this.props.fallback(this.state.error) : this.props.children; }}What it catches: errors thrown while rendering, in lifecycle methods and in constructors of the components below it. What it does not catch:
- Errors in event handlers. I threw inside
onClick. The boundary did not show its fallback; the error reachedwindowas a normal uncaught error. Usetry/catchin the handler. - Errors in async code (promises,
setTimeout) that is not part of rendering. - Server-side rendering errors (handled by the framework).
- Errors in the boundary itself.
Good practice: put boundaries around risky sections (a widget, a route) as well as one at the top, so that one broken widget does not blank the whole page. React 19 added onCaughtError and onUncaughtError options to createRoot, one place to send errors to your logging tool.
Portals
createPortal(child, document.body) renders child into another DOM node. Use it for modals, tooltips and dropdown menus that must escape overflow: hidden or a stacking context.
export function Modal({ open, children }: { open: boolean; children: ReactNode }) { if (!open) return null; return createPortal(<div role="dialog" aria-modal="true">{children}</div>, document.body);}The surprise: React events bubble through the React tree, not the DOM tree. In my test, a click inside the portal fired an onClick on a React parent, although the portal's DOM parent was document.body. That is useful (a modal inside a form can still use the form's handlers) and a classic bug source (a click in the modal closes a parent that listens for outside clicks). Portals do not fix accessibility: you still need focus management and aria-modal, see Frontend Depth, Lesson 06.
Refs in React 19
Two changes matter. First, ref is now a normal prop on function components, so forwardRef is not needed (it still works and is expected to be deprecated later). Second, ref callbacks can return a cleanup function.
export function TextField({ ref, ...rest }: { ref?: Ref<HTMLInputElement> } & React.ComponentProps<"input">) { return <input ref={ref} {...rest} />;}I checked that useImperativeHandle(ref, ...) also works without forwardRef, and that a ref callback's returned function ran when the element was removed (attach > cleanup). Avoid useImperativeHandle unless you must expose commands such as focus() or play(). Props are clearer than commands.
Suspense and data fetching in React 19
Fetching inside useEffect has known problems: a waterfall (a child starts fetching only after the parent has rendered), races, and no cache. The newer tools are:
- A framework or router that loads data before rendering (Next.js Server Components, React Router loaders).
- A data library (TanStack Query) that caches and de-duplicates.
use(promise): a React 19 API that reads a promise during render and suspends (shows the nearest<Suspense>fallback) until it settles.
The rule that people miss: the promise must be the same object on every render. Creating it inside the component makes a new promise each time:
// WRONG: a new promise on every render. React suspends, retries, creates a new promise, suspends...function Bad() { const v = use(fetch("/api/user").then((r) => r.json())); /* ... */ }// RIGHT: a cached promise (or one passed from a parent / created by a framework)const userCache = new Map<string, Promise<{ name: string }>>();function getUser(id: string) { if (!userCache.has(id)) userCache.set(id, fetch(`/api/users/${id}`).then((r) => r.json())); return userCache.get(id)!;}export function UserName({ id }: { id: string }) { const user = use(getUser(id)); return <b>{user.name}</b>;}In my test, the "wrong" component was attempted dozens of times in 300 ms and never showed content. The cached version rendered after 3 attempts. Unlike other hooks, use can be called inside if blocks and loops. A rejected promise throws to the nearest error boundary, so pair every <Suspense> with an error boundary.
What changed in React 19, 19.2 and 19.3
| Feature | Version | What it does |
|---|---|---|
use | 19.0 | Reads a promise or a context during render |
Actions, useActionState, useFormStatus, useOptimistic | 19.0 | Async form handling, pending and optimistic UI |
ref as a prop; ref callback cleanup | 19.0 | No more forwardRef for new code |
<Context value> as provider | 19.0 | Shorter than <Context.Provider> |
Document metadata tags (<title>, <meta>) in components | 19.0 | React hoists them to <head> |
propTypes and defaultProps on function components removed | 19.0 | Use TypeScript and default parameters |
<Activity> | 19.2 | Hide UI but keep its state (effects are cleaned up while hidden) |
useEffectEvent | 19.2 | A function inside an effect that always sees the latest props and state, but is not a dependency |
<ViewTransition>, Fragment refs | 19.3 | Animate changes with the browser's View Transition API |
useEffectEvent fixes an old pain. In a test I started a setInterval in an effect with [] as dependencies. The plain version kept reading the first value of the state (0) while the real state was 2. The version that reads state through useEffectEvent printed 2.
Testing, structure, state tools and accessibility in short
- Testing: query by role (
getByRole), type withuser-event, mock the network, not your own modules. See Frontend Depth, Lesson 05. - Folders: group by feature (
features/checkout/holds its components, hooks and tests). Group by file type only in small apps. - State tools: URL for filters, a data library for server data,
useStatefor local state, a small store (Zustand) for shared client state. See Lesson 03. - Accessibility: native elements first, then ARIA. Every field has a label, and errors are announced with
role="status". See Lesson 06.
"What happens when…" walk-throughs
When a parent re-renders, what happens to a child? React calls the child function again, unless the child is wrapped in memo and its props are equal by reference, or the Compiler has already memoized it. It then compares the new output with the old one and changes the DOM only where it differs. Children passed as {children} from above are the same elements, so they are not called again.
When a user clicks "Like" with useOptimistic? (1) The click handler starts a transition. (2) addOptimistic(1) makes React render the screen with the optimistic number. (3) The async work runs. (4) When it finishes, setReal(...) stores the real number. (5) When the transition ends, React drops the optimistic layer and shows the real value. If the work threw, step 4 never happens, so the screen goes back to the old number.
A code-review table
| You see | Why it is a problem | What you suggest |
|---|---|---|
useEffect(() => setTotal(sum(items)), [items]) | Extra render with a stale value; derived data in state | const total = sum(items) (the Compiler or useMemo if it is slow) |
useEffect(..., []) that reads props | Stale values | Add the dependency, or useEffectEvent |
fetch in an effect without cleanup | Race; setState after unmount | AbortController or an ignore flag, or a data library |
<Ctx value={{ a, b }}> | New object on every render | useMemo or split contexts |
key={i} on a list that changes | Wrong state moves between rows | Stable id |
items.sort() or state.push() | Mutates state; no render, or a wrong one | Copy first: [...items].sort() or toSorted() |
{cond && <A/>} where cond can be 0 | Renders the number 0 | cond > 0 && ... or a ternary |
onClick={handle(id)} | Calls the function during render | onClick={() => handle(id)} |
| Secret or token read in a Client Component | It ships to the browser | Read it on the server |
Common bugs
- StrictMode runs effects twice in development. I logged
setup > cleanup > setup. This is on purpose, to show missing cleanup. In production it runs once. - An
asyncfunction passed straight touseEffect. It returns a promise, not a cleanup function. Define an inner async function and call it. - An event-handler error expected to reach the boundary. It does not.
Interview questions
Controlled or uncontrolled: how do you choose?
(must know) I ask where the value needs to be known. If other parts of the screen react while the user types (live validation, formatting, a preview, a button that enables), I make it controlled: state holds the value, and value plus onChange connect it. If I only need the value on submit, I use an uncontrolled field and read FormData. It is simpler and does not render on every key. File inputs are always uncontrolled. I never mix the two: value without onChange gives a read-only field, and starting at undefined then becoming a string triggers the "uncontrolled to controlled" warning. For big forms I use React Hook Form, which is uncontrolled underneath. In my test it rendered 2 times for 5 keystrokes, where a controlled field rendered 6 times.
A Modal component has 14 props and a new one every sprint. What do you do?
(senior signal) I change the shape instead of adding props. Props that change structure (header content, footer buttons, an icon) become slots: children plus sub-components (<Modal.Header>, <Modal.Footer>). The component keeps the behaviour: focus trap, Escape to close, aria-modal, scroll lock. The caller owns the content. I keep simple style props like size="lg". To migrate safely, I add the new API, keep the old props for one release with a deprecation note, then remove them. The risk of compound components is that parts must be used together, so the hook throws a clear error outside the parent.
Review this hook: it fetches in an effect and stores the result in state. What do you check?
(senior signal) Five things. (1) Race: can an old response win? I want an AbortController or an ignore flag in the cleanup. (2) Errors: fetch does not throw on 404, so check res.ok, show an error state, and ignore AbortError. (3) State shape: one object with status beats separate loading and error flags that can disagree. (4) Dependencies: list every value the effect reads. (5) Should it exist? For real server data I prefer TanStack Query or framework data loading, because caching, retries and de-duplication are hard to write well. In my test, the unprotected version let the slow first response replace the correct second one.
Why do all consumers re-render when the context changes, and how do you fix it?
(must know) React compares the context value by reference. If the provider passes a new object, every component that calls useContext for it re-renders, and memo does not help because the update does not come from the parent. I measured: with an inline value={{ n: 1 }}, a consumer rendered 3 times after 2 parent updates, and once with a memoized value. Fixes: memoize the value; split the context into "state" and "actions" so that components that only call actions do not re-render on data changes; or move to a store with selectors when many components need different slices. If the value changes very often (mouse position, typing), context is the wrong tool.
A memo component still re-renders every time. Why?
(trap) memo compares each prop with Object.is. If the parent creates a new object, array or function in render, that prop is "different" every time. I reproduced it with style={{ color: "red" }} and got one render per parent render. The fix is a stable prop: a constant outside the component, useMemo or useCallback, or the React Compiler. Other causes: the component reads a context that changed, or its children are created fresh by the parent. And if the component renders fast, memo may cost more than it saves, so I check the Profiler first.
With the React Compiler, do we still need useMemo and useCallback? What can go wrong when we turn it on?
(senior signal) Mostly not in new code. The Compiler (1.0, opt-in, works with React 17 to 19) adds finer memoization than we write by hand. The docs say to rely on it for new code and keep useMemo and useCallback as an escape hatch, for example for a value used as an effect dependency. In old code, leave the existing calls, because removing them can change the compiled result. Lists that say "React 19 removed the need for memoization" are wrong: the Compiler is a separate tool. The risk is impure code. My example: const now = Date.now() in render. After compiling, the value was cached and the time never updated. Also watch for mutating props, writing refs during render, and setting state in an effect to derive data. eslint-plugin-react-hooks v7 reports these (react-hooks/purity, react-hooks/set-state-in-effect). My rollout: fix lint errors, enable one folder, compare behaviour and the Profiler, then widen.
Why is key={index} wrong, and what is a good use of key besides lists?
(must know) A key is how React matches elements between renders. With index keys, after a delete or reorder, position 0 holds a different item but keeps the old key, so React reuses the old component and its state. I typed in the first of three inputs, deleted that row, and the text stayed in the first remaining input. Use a stable id from the data. A random key is worse: the element is rebuilt on every render. A good second use: a key resets a component. <Editor key={user.id} /> discards all state and refs when the user changes, which is cleaner than an effect that copies props into state.
React Hook Form, controlled state or React 19 Actions: which do you pick?
(senior signal) Two fields with no rules: plain state. Many fields and rules: React Hook Form with a Zod resolver, because fields stay uncontrolled (fewer renders) and one schema can be shared with the server. Mutations that go to a server, especially Next.js Server Actions: <form action> with useActionState, which gives pending state, errors and progressive enhancement (the form can work before JavaScript loads). They combine: RHF for client checks, then call the action. Whatever I choose, the server must validate again, because browser checks can be bypassed.
After my form action returned a validation error, all the inputs were empty. Why?
(trap) React 19 resets uncontrolled fields when a form Action finishes. It is intended for success, but it surprises people on errors. I reproduced it. The fix is to return the submitted values from the action and set defaultValue={state.values.name}, so the "reset" goes back to what the user typed. Or use controlled fields. I also return field-level messages and link them with aria-describedby.
useFormStatus always says pending: false. What is wrong?
(trap) It is called in the same component that renders the <form>. The hook reads the status of a parent <form>, so it must live in a child, such as a SubmitButton. I tested both: in the form's own component it was never true; in the child it was true during submit. If I need pending state in the form component itself, useActionState returns it as the third value.
What do error boundaries not catch, and where do you place them?
(must know) They catch errors thrown while rendering, in lifecycle methods and in constructors below them. They do not catch errors in event handlers, in async code such as promises and timers, in server rendering, or inside the boundary itself. I proved the handler case: no fallback appeared and the error reached window. For handlers I use try/catch and set an error state. I put one boundary near the root and more around independent risky areas (a chart, a route, a third-party widget), so one failure does not blank the page. I log with componentDidCatch, or with the React 19 root options onCaughtError and onUncaughtError. A rejected promise given to use also goes to the nearest boundary.
Why use a portal, and what is the event surprise?
(senior signal) A portal renders a child into another DOM node, usually document.body, so a modal, tooltip or menu is not clipped by overflow: hidden or trapped in a stacking context. The surprise: events bubble through the React tree. In my test a click in the portal reached a React parent's onClick, although the DOM parent was body. So an "outside click" check on a parent can fire by mistake. A portal gives no accessibility for free: I still add role="dialog", aria-modal, a focus trap, focus return and Escape handling.
What changed with refs in React 19?
(senior signal) ref is a normal prop on function components, so I write function Input({ ref, ...rest }). forwardRef is no longer needed and is planned for deprecation. Ref callbacks may return a cleanup function. I tested both, and that useImperativeHandle works with a ref prop. A popular interview list still says useImperativeHandle requires forwardRef; that is outdated. I use imperative handles rarely, only for commands like focus().
Explain use(promise). Why does my component keep showing the fallback?
(trap) use reads a promise during render and suspends until it settles. If I create the promise inside the component, every render makes a new one. React suspends, throws away the attempt, retries, creates another promise, and suspends again, so the content never appears. I saw dozens of attempts in 300 ms. The promise must be stable: created by a parent or framework (a Server Component can pass it as a prop), or kept in a cache keyed by the input. I wrap it in <Suspense> for loading and in an error boundary for failure. use can also read context, and unlike other hooks it may be called inside conditions.
Scenario: search results sometimes show the answer for an older query. Find and fix it.
(must know) It is a race condition: requests finish in a different order than they started, and the old one calls setState last. I reproduce it with throttled network. Fixes, in order of preference: a data library that keys results by query (TanStack Query); or an AbortController aborted in the effect cleanup; or an ignore flag set in the cleanup. I add a debounce so fewer requests start. In my test the unprotected version showed "result for a (slow)" and the protected one showed "result for ab". A test for it controls the order in which promises resolve.
Scenario: my effect runs twice in development. Is that a bug?
(trap) No. In development, StrictMode mounts, unmounts and mounts again to check that effects clean up. I logged setup > cleanup > setup. If the second run causes a problem (two connections, two analytics events), the effect is missing cleanup or is not safe to repeat. I do not remove StrictMode and I do not add a "run once" ref flag. I fix the cleanup. Production runs the effect once.
Code review: what is wrong here?
(senior signal)
function Cart({ items }: { items: Item[] }) { const [total, setTotal] = useState(0); useEffect(() => { setTotal(items.reduce((s, i) => s + i.price, 0)); }, [items]); items.sort((a, b) => a.price - b.price); return (<ul>{items.map((i, idx) => <li key={idx} onClick={remove(i.id)}>{i.name}</li>)}</ul>);}Four problems. (1) total is derived data kept in state, which costs an extra render with a stale value; compute it during render (the ESLint rule set-state-in-effect flags it). (2) items.sort changes the prop in place; copy first with [...items].sort(...) or toSorted. (3) key={idx} moves state between rows when the list changes; use i.id. (4) onClick={remove(i.id)} calls remove during render and passes its result; it should be () => remove(i.id). Then I would ask for a test that removes the first item and checks that the right row disappears.
How do you structure a React project and pick state tools for a team of ten?
(senior signal) Feature folders: features/orders/ holds its components, hooks, API calls, types and tests, and exports a small public surface from index.ts. Shared UI goes in components/ui, helpers in lib/. A rule like "a feature does not import another feature's internals" can be enforced with ESLint. For state: URL for filters and paging, a data library for server data, local state by default, and a small store only for client state that many distant components share. I add Redux Toolkit when the team needs strict conventions and tooling. I write these choices in a short decision record so new people know why.
Key takeaways
- Compose, do not configure. Slots (
children) and compound components beat a growing list of props, and thechildrenslot also avoids re-renders. - Controlled fields are for values the screen reacts to; uncontrolled fields are for submit-time values. Use
keyto reset,defaultValuefor initial values only. - Hooks must clean up and handle races. Prefer a data library or framework loading for server data.
- Context re-renders every consumer on a new value.
memodoes not stop it. Memoize the value, split contexts, or use a store. - The React Compiler is opt-in, not part of React 19. Run the ESLint rules first because it can freeze impure values.
- React 19: Actions reset uncontrolled fields;
useFormStatuslives in a child;useneeds a stable promise;refis a prop;useEffectEventfixes stale reads in effects. - Error boundaries catch render errors only. Handle event-handler and async errors yourself.
- In review, look for: derived state in effects, mutation, index and random keys, inline objects to memoized children, missing cleanup.
What's next
Lesson 05: Next.js Interview Questions. React is the engine; Next.js adds routing, caching and the server. We cover Server and Client Components, the Next.js 16 caching model, Server Actions security and the new proxy file.
Published by Jainik · Last updated · How these lessons are made
Keep learning
Related lessons
- “The API Is Fast but the UI Is Slow”: How to Solve It · Scenario Debugging
- Frontend and React Scenario Bank · Scenario Debugging
- Unnecessary Re-renders: Finding and Fixing Them · Scenario Debugging
Part of the study guide
- TypeScript, React and Next.js Guide for Developers
- Full-Stack Developer Interview Guide: Questions and Answers
Related series