React Interview Questions: Senior Level (4+ Years)
30 advanced React interview questions with in-depth answers for senior developers ā Fiber architecture, concurrent rendering, RSC, design systems, performance profiling, and system design.
Senior-Level Signals
Senior interviewers aren't checking if you know the API. They're evaluating:
- System thinking ā Can you design a scalable component architecture?
- Tradeoff reasoning ā "What would you pick and why, given these constraints?"
- Depth under pressure ā Can you explain how React actually works, not just what the docs say?
- Production experience ā Have you debugged real performance problems, not just toy examples?
Every answer should demonstrate these signals.
React Internals & Architecture
Q1: Explain React Fiber ā what problem it solved and how it works.
The problem: The old stack reconciler was synchronous and recursive. Once reconciliation started, it ran to completion ā blocking the main thread for large trees and causing jank (dropped frames, unresponsive inputs).
The solution: Fiber breaks reconciliation into a linked list of "units of work" (one fiber per component). React can process a unit of work, check if there's higher-priority work (like a user input), pause, and resume later. This is called time-slicing.
How it works:
Fiber tree structure (each fiber is a JS object):
{
type: MyComponent,
key: null,
stateNode: DOM node or class instance,
return: parentFiber, ā parent
child: childFiber, ā first child
sibling: nextFiber, ā next sibling
effectTag: UPDATE | PLACEMENT | DELETION,
memoizedState: hook linked list,
pendingProps: new props,
memoizedProps: old props,
}Two phases:
- Render phase (interruptible): React traverses the fiber tree, calls component functions, builds the work-in-progress tree. Can be paused and resumed.
- Commit phase (synchronous): React applies all DOM mutations at once. Cannot be interrupted ā partial DOM updates would leave the UI in an inconsistent state.
Practical implications: Concurrent rendering, useTransition, useDeferredValue, Suspense for data fetching, and React Server Components are all built on Fiber's interruptibility.
Q2: What is the React reconciliation algorithm and its time complexity?
React's diffing algorithm is O(n) ā linear in the number of elements. It achieves this through two heuristics:
-
Different types ā full replace: If
<div>becomes<span>, React unmounts the entire subtree and mounts a new one. It doesn't try to diff them. -
Keys for list optimization: Without keys, React matches by position ā O(n) comparisons but requires re-rendering all items on insertion. With stable keys, React matches items correctly and only re-renders what changed.
Tradeoff: Generic tree diffing is O(n³). React's heuristics reduce this to O(n) at the cost of some accuracy ā hence the requirement to use stable keys and avoid changing element types in the same position.
Q3: Explain the difference between useEffect and useLayoutEffect and when to use each.
Both accept the same signature. The difference is when they fire relative to the browser paint:
Render ā Commit DOM mutations ā useLayoutEffect ā Browser Paint ā useEffectuseLayoutEffect: Fires synchronously after DOM mutations, before the browser paints. The user never sees the intermediate state. Use it when:
- Measuring DOM (offsetWidth, getBoundingClientRect) to position something
- Preventing a visual flash (tooltip positioning, scroll restoration)
useEffect: Fires asynchronously after the browser paints. Doesn't block painting. Use it for everything else ā data fetching, subscriptions, logging.
Warning: useLayoutEffect in Server Components throws an error (no DOM during SSR). Use useEffect for SSR-safe code, or conditionally run useLayoutEffect on the client.
Q4: How does React's batching work in React 18 vs React 17?
React 17: Only batched updates inside React event handlers. Updates in setTimeout, Promise.then, or native event listeners were NOT batched ā each triggered a separate re-render.
React 18: Automatic batching everywhere. All state updates in a single event loop tick are batched into one re-render, regardless of origin (setTimeout, Promise, native events).
// React 17: THREE renders
setTimeout(() => {
setA(1); // render 1
setB(2); // render 2
setC(3); // render 3
}, 100);
// React 18: ONE render (automatic batching)
setTimeout(() => {
setA(1);
setB(2);
setC(3); // single render
}, 100);
// Opt out when needed
import { flushSync } from "react-dom";
flushSync(() => setA(1)); // Render immediately
setB(2); // Second renderQ5: What are React Server Components (RSC) and how do they differ from SSR?
Traditional SSR:
- Full component tree renders on server ā HTML
- Sends HTML to browser (fast initial paint)
- Browser downloads JS bundle
- Full component tree re-renders on client (hydration)
- App is interactive
Problem: you ship JavaScript for every component, including ones that only render static content.
React Server Components (RSC):
- Server components run only on the server, never on the client
- Their JavaScript never ships to the browser
- They can directly access databases, file systems, secrets
- Client components handle interactivity
- React serializes the server-rendered tree and streams it to the client
// page.tsx ā Server Component (no "use client")
// Can: async/await, access DB, use secrets, import server-only packages
// Cannot: useState, useEffect, event handlers, browser APIs
export default async function BlogPage() {
const posts = await db.posts.findMany(); // Direct DB access ā no API layer!
return <PostList posts={posts} />;
}
// PostList.tsx ā can be Server or Client
// If it needs state/effects ā "use client"
// If it's just rendering ā Server Component (smaller bundle)Key benefit: Zero-bundle components. A markdown renderer that imports a 100KB library: with RSC, that 100KB stays on the server.
Concurrent React
Q6: What is the difference between useTransition and useDeferredValue?
Both mark work as non-urgent, allowing React to prioritize urgent updates (like typing in an input). The difference is ownership:
useTransition: You own the state update. Wrap it in startTransition.
const [isPending, startTransition] = useTransition();
const handleSearch = (value) => {
setInputValue(value); // Urgent ā updates input immediately
startTransition(() => setResults(heavy(value))); // Deferred ā can be interrupted
};useDeferredValue: You don't own the state update ā it comes from a prop or parent.
function SearchResults({ query }) { // query comes from parent
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => filter(items, deferredQuery), [deferredQuery]);
const isStale = query !== deferredQuery;
return <div style={{ opacity: isStale ? 0.6 : 1 }}>{results}</div>;
}Q7: How does Suspense for data fetching work in React 18?
Suspense works by "suspending" ā when a component throws a Promise (or a library like React Query detects the query isn't cached), React catches it, shows the nearest <Suspense fallback={...}>, and retries the component when the Promise resolves.
function Profile({ userId }) {
// useQuery with suspense: true ā throws a promise if data isn't cached
const { data: user } = useQuery({ queryKey: ['user', userId], suspense: true });
return <div>{user.name}</div>; // Always has data here
}
function ProfilePage() {
return (
<ErrorBoundary fallback={<ProfileError />}>
<Suspense fallback={<ProfileSkeleton />}>
<Profile userId="123" />
</Suspense>
</ErrorBoundary>
);
}Advantage over traditional loading states: You don't need if (loading) return <Skeleton> in every component. Suspense boundaries handle it declaratively. Compose multiple Suspense boundaries for granular loading states.
Design Patterns & Architecture
Q8: Design a scalable component architecture for a large React application.
Layer the architecture:
Pages (route-level)
āāā Features (domain: auth, products, checkout)
āāā Feature components
āāā Feature hooks
āāā Feature API layer
āāā Feature store slice
āāā Shared components (design system)
āāā Primitives (Button, Input, Badge)
āāā Composites (DataTable, Modal, Dropdown)
āāā Utilities (formatters, validators, cn())Key principles:
- Feature colocation: Everything a feature needs lives with the feature. No shared-util sprawl.
- API layer abstraction: Components never call
fetchdirectly. They use hooks that call typed API functions. - Single import rule: External code imports from a feature's
index.tsbarrel, not from internal files. - Context boundaries: Each domain has its own Context. No global mega-context.
- Design system boundary: UI primitives have no business logic. Features own business logic.
Q9: What is the "children as composition" pattern and how does it solve performance problems?
// Problem: <Wrapper> has fast-changing state but ExpensiveChild is in its subtree
function Wrapper() {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// ExpensiveChild re-renders on every mouse move!
return (
<div onMouseMove={(e) => setMousePos({ x: e.clientX, y: e.clientY })}>
<ExpensiveChild />
</div>
);
}
// Solution: lift ExpensiveChild OUT of the state owner
function App() {
return (
<Wrapper>
<ExpensiveChild /> {/* children reference is stable ā doesn't re-render */}
</Wrapper>
);
}
function Wrapper({ children }) {
const [mousePos, setMousePos] = useState({ x: 0, y: 0 });
// children is a prop ā its reference doesn't change when mousePos changes
return (
<div onMouseMove={(e) => setMousePos({ x: e.clientX, y: e.clientY })}>
{children}
</div>
);
}This is often better than React.memo ā it doesn't require the child to know about optimization, and it works naturally without custom comparators.
Q10: When is a discriminated union better than boolean flags for component state?
Boolean flags allow impossible combinations:
// ā Can have loading=true AND error simultaneously
{ loading: boolean; data: User | null; error: string | null }A discriminated union makes impossible states unrepresentable:
// ā
Only valid combinations exist
type State =
| { status: "idle" }
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: string };
// TypeScript narrows in switch ā no defensive checks needed
switch (state.status) {
case "success": return <Profile user={state.data} />; // data is guaranteed here
case "error": return <Alert message={state.error} />; // error is guaranteed here
}In interviews: always mention that this pattern eliminates a class of bugs rather than just being style preference.
Performance Profiling
Q11: Walk me through how you would diagnose and fix a React performance problem in production.
Step 1: Measure, don't guess.
// Add React Profiler around the suspect area
<Profiler id="DataTable" onRender={(id, phase, actualDuration) => {
if (actualDuration > 16) analytics.track("slow_render", { id, duration: actualDuration });
}}>
<DataTable />
</Profiler>Step 2: Open React DevTools Profiler. Record interaction. Look for:
- Wide bars = slow renders
- Yellow bars = components that rendered but didn't need to
- Check "why did this render?" panel
Step 3: Common causes and fixes: | Symptom | Cause | Fix | |---|---|---| | All children re-render on parent state change | No memoization | React.memo + useCallback | | Memoization not working | Unstable object/function props | useMemo for objects, useCallback for functions | | Long single render | Expensive computation | useMemo | | Scroll jank with long lists | Too many DOM nodes | Virtualization | | Context causing global re-renders | One large context | Split contexts by domain |
Step 4: Measure Web Vitals (real user impact):
import { onLCP, onINP } from "web-vitals";
onLCP(({ value, rating }) => analytics.track("LCP", { value, rating }));
onINP(({ value, rating }) => analytics.track("INP", { value, rating }));Q12: What is the "prop stability" problem and how does it manifest?
Prop stability: when a prop's value is the same semantically but different by reference, breaking React.memo.
// Every render creates a new object and function ā memo is useless
function Parent({ id }) {
return (
<MemoChild
config={{ color: "blue" }} // New object every render
onClick={() => handleClick(id)} // New function every render
/>
);
}
// Solutions:
const CONFIG = { color: "blue" }; // Stable: module-level constant
function Parent({ id }) {
const config = useMemo(() => ({ color: "blue" }), []); // Or memoized
const handleClick = useCallback(() => onDelete(id), [id, onDelete]);
return <MemoChild config={config} onClick={handleClick} />;
}In performance interviews, proactively mention prop stability as a prerequisite for React.memo working correctly.
System Design Questions
Q13: Design the state management architecture for a complex SaaS dashboard.
Separate state by type:
Server state (API data) ā React Query / RTK Query
- Users, projects, tasks
- Auto-cached, auto-refetched
- Optimistic updates on mutations
UI state (no server sync) ā Zustand / Context
- Modal open/closed
- Active tab
- Sidebar collapsed
Auth state ā Context + localStorage persistence
- Current user
- Token
- Role/permissions
Form state ā React Hook Form (local, per-form)
- Field values
- Validation errors
- Dirty/submitted state
URL state ā React Router search params
- Active filters
- Current page
- Sort column/direction (shareable via URL)Why this split matters: Each type of state has different update frequency, persistence requirements, and sharing patterns. One global store tries to be everything and ends up being nothing well.
Q14: How would you implement a design system that scales across 10 teams?
Layer 1: Primitives ā no business logic, full style control
// Button, Input, Badge, Modal, Tooltip
// Extend HTML props, forward refs, Tailwind-based
// Published as an internal npm packageLayer 2: Composites ā combinations of primitives
// DataTable, FormField, SearchableSelect, DateRangePicker
// Include accessibility (ARIA, keyboard nav) by defaultLayer 3: Domain components ā business context
// UserAvatar, StatusBadge, PrioritySelector
// Import from layer 1/2, add domain semanticsGovernance:
- Changelog + semantic versioning
- Visual regression tests (Chromatic/Storybook)
- Accessibility CI gate (axe-core)
- Usage analytics per component
- Monthly design system office hours
Common failure mode: Trying to make every component fully customizable via props. Instead: allow 80% of uses out of the box, provide escape hatches (className, as-prop, compound patterns) for the other 20%.
What Separates Senior from Mid-Level
In 5+ years of React interviews, these are the separating questions:
-
"How does React actually know when to re-render?" ā Senior answer covers Fiber, referential equality, bailout optimization, not just "state changes."
-
"Design the component architecture for..." ā Seniors think in layers: primitives, composites, domain components, features. They talk about constraints before solutions.
-
"Your useEffect is causing an infinite loop. Debug it." ā Seniors immediately ask about the dependency array and whether state is being set inside the effect.
-
"When wouldn't you use React for this?" ā Seniors know React's limits. Heavy computation ā Web Worker. Simple static site ā Next.js static or Astro. Real-time heavy UI ā consider SolidJS.
-
"How do you know this optimization is necessary?" ā Seniors profile first. They never add
useMemospeculatively.
Enjoyed this article?
Explore the Frontend Engineering learning path for more.
Found this helpful?
Leave a comment
Have a question, correction, or just found this helpful? Leave a note below.