React Interview Questions for 2–4 Years of Experience: 10 Realistic Technical Round Questions
Mid-level front-end technical interviews focus less on rote syntax memorization and more on real-world engineering judgment. For a React developer with 2 to 4 years of experience, interviewers expect you to clearly explain why a particular design pattern or optimization is chosen, how React operates under the hood, and how to prevent architectural bottlenecks.
In a typical 45-to-60-minute technical interview, the panel assesses component lifecycle mechanics, state boundaries, memoization trade-offs, asynchronous data fetching, and error resilience. Candidates who stand out don't just provide theoretical answers—they articulate trade-offs, identify edge cases, and explain how their decisions impact user experience and team maintainability.
This guide simulates a realistic technical interview round for mid-level React developers, covering 10 high-frequency questions spanning architecture, performance, and best practices.
| # | Question | Level |
|---|---|---|
| 1 | How does React determine when a component re-renders, and what happens during reconciliation? | Junior |
| 2 | When should you choose between local state, lifting state up, and global state management? | Junior |
| 3 | What is the difference between controlled and uncontrolled components in modern React? | Junior |
| 4 | Why is the key prop essential in lists, and why is using the array index an anti-pattern? | Junior |
| 5 | When does React.memo actually prevent re-renders, and why must it be paired with useCallback? | Mid |
| 6 | What are the architectural trade-offs between the React Context API and external stores like Zustand? | Mid |
| 7 | How does server state (TanStack Query) differ from client state, and why should they be separated? | Mid |
| 8 | How do you design a custom hook that encapsulates complex state and avoids memory leaks? | Mid |
| 9 | How do Error Boundaries work, what errors can they NOT catch, and how do you recover from failures? | Senior |
| 10 | How do React 18/19 Concurrent Rendering and transitions (useTransition) improve UI responsiveness? | Senior |
Q1. How does React determine when a component re-renders, and what happens during reconciliation?
Level: Junior
What the interviewer is checking: The interviewer wants to verify that you understand the fundamental triggers for re-renders and the distinction between the render phase and the commit phase.
Short answer (say this first): A component re-renders when its state updates, when its parent component re-renders, or when it consumes a changing React Context. During the render phase, React executes the component function to generate a new Virtual DOM tree. During reconciliation, React diffs this new tree against the previous one using its heuristic algorithm and applies the minimal set of real DOM updates during the synchronous commit phase.
Detailed explanation:
- Re-render Triggers: In functional components, re-renders occur on state dispatches, parent re-renders, or context updates. Prop changes only trigger a re-render because the parent re-rendered, unless memoization is applied.
- Render vs Commit: The render phase is pure and side-effect-free; React can pause or abort it in Concurrent Mode. The commit phase applies DOM mutations and invokes layout effects.
- Diffing Heuristic: Elements with different types produce completely new subtrees; elements with identical types and keys are updated in place.
Example:
import React, { useState } from 'react';
export function Parent() {
const [count, setCount] = useState(0);
return (
<div>
<button onClick={() => setCount(count + 1)}>Increment: {count}</button>
<Child label="Static Prop" />
</div>
);
}
function Child({ label }: { label: string }) {
return <p>{label}</p>; // Re-renders whenever Parent renders
}
Common mistakes:
- Believing that passing identical props prevents a child from re-rendering by default.
Likely follow-up questions:
- Does calling
setStatewith the identical primitive value trigger a render? — No, React usesObject.isand bails out if the state value has not changed.
Q2. When should you choose between local state, lifting state up, and global state management?
Level: Junior
What the interviewer is checking: The interviewer is assessing your state architecture instincts and whether you default to global state unnecessarily.
Short answer (say this first): Keep state as local as possible to where it is consumed. Use local component state for UI concerns that affect only a single component, such as toggles or form inputs. Lift state up to the nearest common ancestor when sibling components must synchronize. Reserve global state strictly for data needed across widely separated subtrees, such as authentication sessions, user permissions, or shopping carts.
Detailed explanation:
- Local State (
useState): Isolates re-renders to the individual component, optimizing performance and simplifying testing. - Lifting State Up: Coordinates siblings cleanly via props and callback handlers without introducing external store dependencies.
- Global State: Necessary when prop drilling exceeds several levels or distinct route branches require immediate synchronous access to the same state.
Example:
export function SearchFeature() {
const [query, setQuery] = useState('');
return (
<div>
<SearchInput value={query} onChange={setQuery} />
<ResultsList query={query} />
</div>
);
}
Common mistakes:
- Putting ephemeral local state (like input values or modal toggles) into a global Redux or Zustand store.
Likely follow-up questions:
- How does component composition help avoid prop drilling? — Passing elements via
childrenslots allows the top-level parent to supply state directly without intermediate components needing prop wiring.
Q3. What is the difference between controlled and uncontrolled components in modern React?
Level: Junior
What the interviewer is checking: The interviewer wants to verify that you understand form management patterns, React state vs DOM state, and performance considerations.
Short answer (say this first):
A controlled component has its value driven entirely by React state; React is the single source of truth, and every keystroke updates state via an onChange handler. An uncontrolled component maintains its value internally in the DOM; React reads it on demand using a useRef or native FormData on form submission. Controlled components are preferred for real-time validation, while uncontrolled components offer better performance and simpler code for large forms.
Detailed explanation:
- Controlled Components:
value={state}+onChange. Enables real-time character masking, dynamic button enabling, and instant feedback. - Uncontrolled Components:
defaultValue+ref. Prevents re-rendering the component on every keystroke, keeping form input close to native HTML. - Modern Form Actions: Native
<form>handling paired withFormDataheavily favors uncontrolled inputs for clean data submission.
Example:
export function LoginForm({ onSubmit }: { onSubmit: (data: any) => void }) {
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const data = Object.fromEntries(new FormData(e.currentTarget));
onSubmit(data);
};
return (
<form onSubmit={handleSubmit}>
<input name="email" type="email" required defaultValue="" />
<button type="submit">Log In</button>
</form>
);
}
Common mistakes:
- Switching an input between uncontrolled and controlled during its lifecycle by passing
value={undefined}initially and a string later.
Likely follow-up questions:
- Why do libraries like React Hook Form prefer uncontrolled inputs? — They subscribe to inputs via refs without triggering React re-renders on keystrokes, maximizing performance in large forms.
Q4. Why is the key prop essential in lists, and why is using the array index an anti-pattern?
Level: Junior
What the interviewer is checking: The interviewer is confirming your understanding of component identity, DOM node recycling, and state persistence bugs during list mutations.
Short answer (say this first):
The key prop provides a stable identity for React elements across re-renders, enabling reconciliation to detect if an item was added, removed, or reordered. Using the array index as a key is an anti-pattern when lists are reordered, sorted, or filtered. Because item indices shift during mutations, React can mistakenly preserve the internal state of child components (like input focus or local state) on the wrong DOM nodes.
Detailed explanation:
- Identity Matching: React matches previous Virtual DOM nodes with new ones using
keyto decide whether to update, mount, or unmount. - The Index Problem: If you prepend an item to an index-keyed list, the new item becomes index
0, and all existing items shift indices, causing React to treat every item as modified and attaching local state to the wrong elements. - When Index is Safe: Only when the list is purely static—never filtered, sorted, or mutated.
Example:
export function TodoList({ items }: { items: { id: string; text: string }[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.text}</li>
))}
</ul>
);
}
Common mistakes:
- Generating random keys on the fly (
key={Math.random()}), which forces React to unmount and remount DOM subtrees on every render.
Likely follow-up questions:
- Can
keybe used on single components outside lists? — Yes, changing thekeyon a standalone component (e.g.<UserProfile key={userId} />) forces React to unmount and reset its local state.
Q5. When does React.memo actually prevent re-renders, and why must it be paired with useCallback?
Level: Mid
What the interviewer is checking: The interviewer wants to assess whether you understand shallow reference equality in React memoization and how inline function/object definitions break memoized components.
Short answer (say this first):
React.memo is a higher-order component that skips re-rendering if its incoming props are shallowly equal to its previous props (Object.is). It only works if all passed props maintain stable references across parent renders. If a parent passes an inline function or object without wrapping them in useCallback or useMemo, a new memory reference is created on every parent render, causing React.memo's shallow check to fail and re-rendering anyway.
Detailed explanation:
- Shallow Comparison:
React.memocomparesprevProps[key]withnextProps[key]. Primitive values compare easily; object and function references fail unless memoized. - Callback Invalidation: Functions declared in a parent component get fresh memory addresses on every render pass unless stabilized with
useCallback. - Cost of Memoization: Shallow comparisons and cache allocations carry overhead; applying
React.memoto cheap components is often slower than letting them render.
Example:
import React, { useState, useCallback } from 'react';
const Chart = React.memo(function Chart({ onRefresh }: { onRefresh: () => void }) {
return <button onClick={onRefresh}>Refresh Chart</button>;
});
export function Dashboard() {
const [filter, setFilter] = useState('');
const handleRefresh = useCallback(() => console.log('Refreshing'), []);
return (
<div>
<input value={filter} onChange={(e) => setFilter(e.target.value)} />
<Chart onRefresh={handleRefresh} />
</div>
);
}
Common mistakes:
- Wrapping a component in
React.memowhile passing inline object literals likestyle={{ color: 'blue' }}or inline callbacksonClick={() => ...}.
Likely follow-up questions:
- How does the React Compiler impact this pattern? — The React Compiler automatically infers and memoizes values and functions at build time, reducing the need for manual
useMemoanduseCallbackannotations.
Q6. What are the architectural trade-offs between the React Context API and external stores like Zustand?
Level: Mid
What the interviewer is checking: The interviewer is probing your knowledge of state propagation architecture, render scalability, and selector-based subscriptions.
Short answer (say this first): React Context is designed for dependency injection and low-frequency global updates like theme, localization, or authentication data. When a Context value changes, every component consuming that Context re-renders, even if it only uses properties that did not change. External state managers like Zustand live outside the React tree and support selector-based subscriptions, re-rendering only the specific components whose selected slice of state actually changed.
Detailed explanation:
- Context Limitation: Context lacks granular property-level subscriptions; updating any field in the context object invalidates all consumers.
- Context Splitting: To optimize Context, you must split monolithic contexts into many smaller providers, increasing wrapper nesting.
- Zustand Architecture: Uses
useSyncExternalStoreunder the hood. Components subscribe to specific slices (useStore(s => s.cartCount)), ensuring zero wasted renders when unrelated state updates.
Example:
import { create } from 'zustand';
interface StoreState {
cartCount: number;
user: string;
increment: () => void;
}
export const useStore = create<StoreState>((set) => ({
cartCount: 0,
user: 'Alex',
increment: () => set((s) => ({ cartCount: s.cartCount + 1 })),
}));
export function CartBadge() {
// Only re-renders when cartCount changes; completely ignores user changes
const cartCount = useStore((s) => s.cartCount);
return <span>Items: {cartCount}</span>;
}
Common mistakes:
- Using Context API for high-velocity state changes (like mouse coordinates or text inputs), which causes widespread render lag.
Likely follow-up questions:
- What native hook enables external state stores to integrate with React safely? —
useSyncExternalStore, which prevents visual tearing during concurrent rendering.
Q7. How does server state (TanStack Query) differ from client state, and why should they be separated?
Level: Mid
What the interviewer is checking: The interviewer wants to verify that you understand data ownership, caching, deduplication, and why putting remote API responses into Redux is an outdated pattern.
Short answer (say this first): Client state represents local data owned completely by the browser, such as modal visibility, form inputs, or selected tabs. Server state represents remote data owned by the backend that is only temporarily cached on the client, such as product lists or user records. Server state is asynchronous, can be mutated out of band by other users, and requires dedicated caching, background refetching, and deduplication—capabilities provided by TanStack Query rather than manual client stores.
Detailed explanation:
- Separation of Concerns: Storing API payloads in Redux or local state requires manual management of loading booleans, error strings, and cache invalidation.
- Server State Features: TanStack Query caches data by query key, deduplicates identical simultaneous requests, refetches on window focus, and provides declarative optimistic updates.
- Lean Client State: Separating server state leaves client stores tiny and focused strictly on true UI state.
Example:
import { useQuery } from '@tanstack/react-query';
export function ProductList() {
const { data, isLoading, error } = useQuery({
queryKey: ['products'],
queryFn: () => fetch('/api/products').then((r) => r.json()),
staleTime: 1000 * 60 * 5, // Fresh for 5 minutes
});
if (isLoading) return <div>Loading products...</div>;
if (error) return <div>Failed to load products</div>;
return <ul>{data.map((p: any) => <li key={p.id}>{p.name}</li>)}</ul>;
}
Common mistakes:
- Copying TanStack Query data into local
useStateviauseEffect, which re-introduces stale data bugs.
Likely follow-up questions:
- What is the difference between
staleTimeandgcTimein TanStack Query? —staleTimedefines when cached data is considered outdated for refetching;gcTimedefines how long unused cache entries remain in memory before disposal.
Q8. How do you design a custom hook that encapsulates complex state and avoids memory leaks?
Level: Mid
What the interviewer is checking: The interviewer is evaluating your custom hook composition skills, clean API contract design, and cleanup discipline.
Short answer (say this first):
A robust custom hook extracts stateful logic and exposes a clean, minimal interface of values and actions. To prevent memory leaks, any external subscriptions, timers, or DOM event listeners established inside the hook must return a cleanup function in their useEffect. Furthermore, action callbacks returned by the hook should be stabilized with useCallback so consumers can safely include them in dependency arrays without triggering re-render cascades.
Detailed explanation:
- Interface Contract: Return a stable tuple
[value, actions]or an object{ data, isLoading, error }. - Cleanup Discipline: Always remove event listeners, abort active network controllers, and clear intervals when dependencies change or on unmount.
- Referential Stability: Wrap exported functions in
useCallbackto maintain stable identity for memoized consumers.
Example:
import { useState, useEffect, useCallback } from 'react';
export function useWindowSize() {
const [size, setSize] = useState({ width: 0, height: 0 });
const handleResize = useCallback(() => {
setSize({ width: window.innerWidth, height: window.innerHeight });
}, []);
useEffect(() => {
handleResize(); // Initial measure
window.addEventListener('resize', handleResize);
return () => window.removeEventListener('resize', handleResize);
}, [handleResize]);
return size;
}
Common mistakes:
- Creating a new function instance on every render inside a custom hook and returning it without
useCallback.
Likely follow-up questions:
- Can custom hooks return JSX? — Yes, but it is generally an anti-pattern; hooks should encapsulate behavior and data, while components should encapsulate presentation.
Q9. How do Error Boundaries work, what errors can they NOT catch, and how do you recover from failures?
Level: Senior
What the interviewer is checking: The interviewer wants to assess whether you understand production stability, graceful degradation, and the limitations of React's error-handling architecture.
Short answer (say this first):
Error Boundaries are class components that implement static getDerivedStateFromError() to render fallback UI and componentDidCatch() to log error telemetry. They catch JavaScript errors thrown during rendering, in lifecycle methods, and in constructors across their child component tree. Crucially, they do NOT catch errors in asynchronous code (like setTimeout or fetch), event handlers, or errors thrown during server-side rendering.
Detailed explanation:
- Why Class Components: React does not yet provide a native functional hook equivalent for Error Boundaries.
- Uncaught Errors: Errors inside
onClickhappen outside the render phase; use standardtry...catchfor event handlers and async code. - Reset Mechanism: Production Error Boundaries provide a retry action (e.g. resetting state when a button is clicked or when navigation keys change).
Example:
import React, { Component, ErrorInfo, ReactNode } from 'react';
interface Props { fallback: ReactNode; children: ReactNode; }
interface State { hasError: boolean; }
export class ErrorBoundary extends Component<Props, State> {
state: State = { hasError: false };
static getDerivedStateFromError(): State {
return { hasError: true };
}
componentDidCatch(error: Error, info: ErrorInfo) {
console.error('Logged to telemetry:', error, info);
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
Common mistakes:
- Expecting an Error Boundary to catch network failures thrown inside an async
fetchcall without explicitly setting state to re-throw during render.
Likely follow-up questions:
- How can an async callback trigger an Error Boundary? — Set state with an updater function that throws during the next render:
setError(() => { throw new Error(); }).
Q10. How do React 18/19 Concurrent Rendering and transitions (useTransition) improve UI responsiveness?
Level: Senior
What the interviewer is checking: The interviewer is probing your mastery of modern React internals, non-blocking rendering, interruptible updates, and prioritizing user inputs over background updates.
Short answer (say this first):
Prior to React 18, rendering was synchronous and blocking; once a render started, it monopolized the main thread until the entire Virtual DOM tree finished updating, causing UI jank. Concurrent Rendering allows React to pause, abandon, or run component updates in the background. With useTransition, you can mark heavy updates as non-blocking transitions, ensuring urgent user interactions (like typing in an input) remain immediately responsive while background lists render concurrently.
Detailed explanation:
- Urgent vs Transition Updates: Urgent updates reflect direct physical interactions (typing, clicking) and need instant visual feedback. Transitions represent view changes (filtering large lists, switching tabs) where slight latency is acceptable.
- Interruptible Work: If a user types another character while React is rendering a transition, React abandons the in-progress background render and handles the new keystroke immediately.
- The
isPendingFlag:useTransitionreturns[isPending, startTransition], providing state to render subtle loading feedback without unmounting the current view.
Example:
import React, { useState, useTransition } from 'react';
export function FilterList({ items }: { items: string[] }) {
const [query, setQuery] = useState('');
const [filtered, setFiltered] = useState(items);
const [isPending, startTransition] = useTransition();
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const val = e.target.value;
setQuery(val); // Urgent update
startTransition(() => {
// Non-blocking background transition
setFiltered(items.filter((i) => i.toLowerCase().includes(val.toLowerCase())));
});
};
return (
<div>
<input value={query} onChange={handleChange} placeholder="Filter items..." />
<div style={{ opacity: isPending ? 0.6 : 1 }}>
{filtered.map((item) => <div key={item}>{item}</div>)}
</div>
</div>
);
}
Common mistakes:
- Wrapping controlled text input updates directly in
startTransition, which creates typing lag and desynchronizes the input field.
Likely follow-up questions:
- How does
useDeferredValuediffer fromuseTransition? —useTransitionwraps the function triggering the state update, whereasuseDeferredValuewraps an existing value prop or state when you do not control the triggering setter.
Preparation Checklist
- Practice explaining reconciliation and Virtual DOM diffing in under 2 minutes out loud.
- Diagram when to choose local state, lifted state, Context API, or external stores (Zustand).
- Understand why
React.memorequires stable function references (useCallback) to avoid failing shallow checks. - Build a small sample demonstrating
useTransitionvs synchronous state updates with a heavy list. - Audit your projects to ensure server data (TanStack Query) is not redundantly stored in client stores.
- Write a custom hook that attaches an event listener and verify its cleanup function executes on unmount.
Conclusion
Technical interviews for mid-level React roles test your ability to look past simple APIs and evaluate execution trade-offs. By demonstrating a solid grasp of reconciliation mechanics, state boundaries, fine-grained subscriptions, server state caching, and concurrent rendering with transitions, you will convey the depth and engineering maturity expected of a senior front-end engineer.