React Fundamentals
A practical, example-driven React guide from fundamentals to React 19. Master hooks, the Virtual DOM, performance, and the gotchas interviewers actually test.
React is a JavaScript library for building user interfaces out of small, reusable pieces called components. If you strip away every buzzword, one idea sits at the center: you describe what the screen should look like for a given state, and React works out the DOM operations needed to get there. You never hand-create, update, or delete DOM nodes the way you would in vanilla JS or jQuery. You change state, and the UI follows. This guide walks the full arc from that mental model to React 19, with the gotchas interviewers actually probe.
How to read this guide. Each idea is introduced in plain language first, then backed by a small runnable-style example, and, where it matters for interviews, an "Interview answer" you can say almost word for word. Plain-English analogies are scattered throughout to make the abstract bits stick. You don't need to memorize anything; read for the why, and the what tends to follow.
The Core Mental Model: UI = f(state)
Imperative code lists the exact steps to mutate the DOM: find this node, set its text, toggle that class. Declarative code expresses the end result as a function of state. React is declarative, and the single most important sentence to internalize is:
Analogy. Imperative is turn-by-turn driving directions: "go 200m, turn left, then right at the lights." Declarative is just giving the address and letting the GPS figure out the route. With React you hand over the destination (the state), and React works out every turn (the DOM changes) for you.
Compare the two styles directly:
// Imperative (vanilla JS): you mutate the DOM yourself
const btn = document.getElementById('count');
btn.addEventListener('click', () => {
const n = Number(btn.dataset.n) + 1;
btn.dataset.n = n;
btn.textContent = `Clicked ${n} times`;
});
// Declarative (React): you describe the result, not the steps
function Counter() {
const [n, setN] = useState(0);
return <button onClick={() => setN(n + 1)}>Clicked {n} times</button>;
}Why React exists. Touching the real DOM is slow and error-prone. Keeping UI manually in sync with data spawns a whole class of "the screen and the data disagree" bugs. React's diffing layer batches and minimizes DOM writes, the UI = f(state) model removes the sync bugs, components give you reuse, and unidirectional data flow keeps large apps traceable.Interview answer: "What does it mean that React is declarative?" You describe the UI as a pure function of state rather than scripting DOM mutations. When state changes, React re-runs your component, computes a new description of the UI, diffs it against the previous one, and applies the minimal set of real DOM updates. The payoff is predictability: the same state always produces the same UI, which makes code easier to reason about and test.
Components: The Building Blocks
A component is a JavaScript function that returns React elements. Component names must start with a capital letter. Lowercase names are treated as DOM tags. Components compose into a tree, with one rendering others:
Analogy. Components are LEGO bricks. Each brick is small and self-contained, you snap them together to build bigger things, and the same brick can show up in a dozen different builds. AButtonbrick lives inside aToolbar, which lives inside aPage. Same idea, just nested.
function Avatar({ user }) {
return <img className="avatar" src={user.imageUrl} alt={user.name} />;
}
function Profile({ user }) {
return (
<section>
<Avatar user={user} />
<h2>{user.name}</h2>
</section>
);
}Modern React is written with function components plus Hooks. Class components still work and show up in legacy code and interviews, so you should be able to read them, but function components win on every axis that matters day to day.
| Concept | Class component | Function component |
|---|---|---|
| State | this.state / this.setState | useState / useReducer |
| Side effects | componentDidMount, etc. | useEffect |
| Props access | this.props | function arguments |
this binding | required (or arrow methods) | none, no this |
| Reusable logic | HOCs / render props | custom hooks |
Interview answer: "Why did the React team introduce Hooks?" Before Hooks, stateful logic could only live in class components, and reusing it required awkward patterns (higher-order components, render props) that led to "wrapper hell." Hooks let you extract stateful logic into reusable functions, use state and lifecycle inside plain functions, dodge the this confusion, and organize code by concern rather than by lifecycle method.
JSX, Deeply
JSX is a syntax extension that looks like HTML but compiles to JavaScript function calls. Browsers don't understand it; a compiler (Babel, SWC, or the TypeScript compiler) transforms it first. Knowing what it compiles to is a frequent interview probe:
// What you write:
const el = <h1 className="title">Hello, {name}</h1>;
// Modern automatic runtime compiles to:
import { jsx as _jsx } from "react/jsx-runtime";
const el = _jsx("h1", { className: "title", children: ["Hello, ", name] });
// Classic runtime (React 17 and earlier):
const el = React.createElement("h1", { className: "title" }, "Hello, ", name);The result is a plain JavaScript object, a React element, that describes what to render. It is not a DOM node. Elements are cheap, immutable descriptions.
JSX Rules That Trip People Up
- Return a single root. Wrap siblings in one parent or a Fragment
<>...</>. - Use
className, notclass;htmlFor, notfor. Attributes are camelCase (onClick,tabIndex). - Close every tag, even void elements:
<br />,<img />. - Curly braces embed expressions, not statements.
{cond ? a : b}works;{if(){}}does not. - Inline styles are objects with camelCased properties:
style={{ marginTop: 8 }}.
// Fragment avoids an extra wrapper DOM node
function Row() {
return (
<>
<td>Name</td>
<td>Email</td>
</>
);
}Interview answer: "Why must JSX be wrapped in a single parent?" Each JSX element compiles to a single function call returning one object, so a component's return value must be one element (or one Fragment). Returning two siblings would be like a function returning two values. Fragments group children without adding a node to the DOM.
Interview answer: "Element vs. component?" A component is a function or class, a reusable blueprint. A React element is the lightweight immutable object a component returns, describing one instance of UI at a moment in time (e.g. {type:'h1', props:{...}}). Components are called to produce elements; elements are created every render and React diffs them.
Gotcha.{0}renders as0, but{false},{null}, and{undefined}render nothing. This breaks{items.length && <List/>}when the array is empty: it prints a literal0on screen. Use{items.length > 0 && <List/>}instead.
Props, State & Data Flow
Props: Data Passed Down
Props are the inputs to a component, passed from parent to child like function arguments. They are read-only: a component must never mutate its own props. That immutability is what makes React's data flow predictable: a child can't reach up and change its parent's data; it can only request changes through callbacks passed down.
function Welcome({ name, greeting = "Hello" }) { // default value
return <h1>{greeting}, {name}!</h1>;
}
// children is a special prop = whatever is nested between the tags
function Card({ title, children }) {
return (
<div className="card">
<h3>{title}</h3>
<div className="card-body">{children}</div>
</div>
);
}
// Usage: <Card title="Stats"><p>Content here</p></Card>Interview answer: "Why are props read-only?" React's model is one-way data flow: data flows down. If a child could mutate its props, the parent's state and the child's view would silently diverge and you couldn't trust that re-rendering the parent reproduces the UI. To change data a parent owns, the child calls a function the parent passed down, i.e. "lifting state up."
Analogy. Props are like arguments you pass to a function: the function can read them but shouldn't reach back out and rewrite the caller's variables. A child wanting to change something asks the parent to do it, by calling a function the parent handed down. Here's that "ask the parent" flow in code:
function Parent() {
const [likes, setLikes] = useState(0);
// The parent owns the data AND the function that changes it.
return <LikeButton count={likes} onLike={() => setLikes(likes + 1)} />;
}
function LikeButton({ count, onLike }) {
// The child can't touch `count` directly; it just calls onLike to request a change.
return <button onClick={onLike}>❤️ {count}</button>;
}State: Memory That Triggers Re-renders
State is data a component owns and can change over time. Calling the setter tells React to re-render. The crucial difference from a plain variable: a normal variable resets every render and does not trigger re-rendering; state persists across renders and schedules a re-render when updated.
function Counter() {
const [count, setCount] = useState(0); // [value, setter], initial 0
// A plain `let n = 0` here would reset to 0 every render and
// updating it would NOT re-render the component.
return <button onClick={() => setCount(count + 1)}>{count}</button>;
}State is a snapshot. Within a single render, state is a fixed value. count does not change mid-render even after you call setCount; the new value is available only in the next render. This explains the classic puzzle:
Analogy. Think of each render as a photograph. The values in that photo are frozen at the moment the shutter clicked. CallingsetCountdoesn't edit the photo you're holding; it asks React to take a new photo. So inside one render,countis whatever it was when that render started, no matter how many setters you call.
function Broken() {
const [n, setN] = useState(0);
function handleClick() {
setN(n + 1); // n is 0 here -> schedules 1
setN(n + 1); // n is STILL 0 -> schedules 1
setN(n + 1); // still 0 -> schedules 1
// Result after click: n becomes 1, not 3.
}
function fixed() {
setN(prev => prev + 1); // 0 -> 1
setN(prev => prev + 1); // 1 -> 2
setN(prev => prev + 1); // 2 -> 3 (now n becomes 3)
}
return <button onClick={handleClick}>{n}</button>;
}Interview answer: "Why does calling setState three times with n+1 only add one?" State is a snapshot captured at render time. All three calls read the same n (0), so each schedules a re-render to 1, and React batches them into one re-render with the final value. To accumulate, pass an updater function; React applies each updater to the latest pending state, yielding 3.
Automatic batching (React 18). Before React 18, updates were only batched inside React event handlers, so setState calls inside a setTimeout, a promise .then, or a native event listener each triggered a separate re-render. React 18 made batching automatic everywhere, regardless of where the updates originate. When you genuinely need React to apply an update and touch the DOM synchronously (e.g. to measure layout immediately after), the escape hatch is flushSync:
import { flushSync } from "react-dom";
flushSync(() => setCount(c => c + 1)); // DOM is updated before the next line runs
const height = listRef.current.scrollHeight;Use flushSync sparingly; it opts out of batching and can hurt performance.
Two more essentials:
// Functional update: when next state depends on previous
setCount(prev => prev + 1);
// Lazy initial state: pass a FUNCTION so the expensive value
// is computed once, not on every render
const [data, setData] = useState(() => expensiveParse(localStorage.bigBlob));Tip. Update objects and arrays immutably. Neverstate.push(x)orobj.key = v. Create a new reference:setItems([...items, x]),setUser({ ...user, name }). React compares references withObject.is, so mutating in place can skip the render entirely.
Interview answer: "Props vs. state?" Props are passed in from the parent and are read-only inside the component. State is owned internally, is mutable via its setter, and persists across renders. Both trigger re-renders. Rule of thumb: if a value is controlled by a parent, it's a prop; if a component must remember and change something itself, it's state.
Lifting State Up
When two components need to share data, move that state to their closest common parent and pass it down, with callbacks to update it. This is the canonical answer to "how do siblings communicate?"
Analogy. Two siblings can't pass notes directly through a wall; they ask a parent in the next room to relay the message. In React, siblings don't talk to each other; the shared data lives in the parent, who passes it down to both.
function Parent() {
const [query, setQuery] = useState("");
return (
<>
<SearchBox value={query} onChange={setQuery} />
<Results query={query} /> {/* stays in sync automatically */}
</>
);
}
function SearchBox({ value, onChange }) {
return <input value={value} onChange={e => onChange(e.target.value)} />;
}Here's a second, very common version: two temperature inputs that must always agree. Because both read from and write to the same parent state, typing in either one instantly updates the other, with no duplicate state and no drift:
function TemperatureConverter() {
const [celsius, setCelsius] = useState(0);
const fahrenheit = celsius * 9 / 5 + 32;
return (
<>
<label>
°C <input
type="number"
value={celsius}
onChange={e => setCelsius(Number(e.target.value))}
/>
</label>
<label>
°F <input
type="number"
value={fahrenheit}
onChange={e => setCelsius((Number(e.target.value) - 32) * 5 / 9)}
/>
</label>
</>
);
}Notice there's only one piece of state (celsius). Fahrenheit is derived during render, never stored separately, which is exactly the "don't mirror derived data into state" lesson, applied early.
Events, Forms & Controlled Inputs
React gives you camelCased handler props and a single delegated listener at the root. You pass a function reference, not a call:
<button onClick={handleClick}>Go</button> // correct: pass reference
<button onClick={handleClick()}>Go</button> // WRONG: calls on render
<button onClick={() => handleClick(id)}>Go</button> // correct: pass an argInterview answer: "What is a SyntheticEvent?" It's React's lightweight cross-browser wrapper around the native event. It normalizes browser differences and gives a consistent API (e.preventDefault(), e.stopPropagation(), e.target). React uses event delegation, attaching listeners at the root for performance, and you can still reach the underlying event via e.nativeEvent.
Stale advice alert: event pooling. Older tutorials warn that SyntheticEvents are pooled and reused, so you must calle.persist()before accessing the event asynchronously. Event pooling was removed in React 17. You can hold onto an event and read it later withoute.persist(). If you see that advice, it's outdated.
Controlled vs. Uncontrolled Inputs
A controlled input has its value driven by React state, which is the single source of truth and every keystroke updates it. An uncontrolled input keeps its value in the DOM and you read it via a ref.
// Controlled: React is the source of truth
function Form() {
const [email, setEmail] = useState("");
return <input value={email} onChange={e => setEmail(e.target.value)} />;
}
// Uncontrolled: DOM holds the value; read with a ref
function Form2() {
const ref = useRef(null);
const submit = () => console.log(ref.current.value);
return <input ref={ref} defaultValue="" />; // defaultValue, not value
}| Aspect | Controlled | Uncontrolled |
|---|---|---|
| Source of truth | React state | the DOM node |
| Read value | from state, always current | via ref, on demand |
| Validation / format | easy, on each change | harder, manual |
| Set value | value + onChange | defaultValue, ref |
| Best for | most forms | file inputs, quick/simple, non-React integration |
Prefer controlled for real-time validation, conditional logic, and predictable state. Use uncontrolled for simple cases, file inputs (which must be uncontrolled), or when integrating with non-React code.
Here's why controlled feels so natural: because every keystroke flows through state, things like live validation, disabling the submit button, and showing a character count are just expressions of that state, with no manual DOM reading required:
function SignupField() {
const [email, setEmail] = useState("");
const isValid = /^[^@]+@[^@]+\.[^@]+$/.test(email);
return (
<div>
<input
value={email}
onChange={e => setEmail(e.target.value)}
placeholder="you@example.com"
/>
{email && !isValid && <p>Please enter a valid email.</p>}
<button disabled={!isValid}>Sign up</button>
</div>
);
}Lists, Keys & Conditional Rendering
function TodoList({ todos }) {
return (
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li> // key on the outermost element
))}
</ul>
);
}Why keys matter. Keys give each list item a stable identity so React's diffing can match elements between renders: knowing which items were added, removed, or reordered, and reusing DOM nodes and component state correctly. Keys must be stable, unique among siblings, and tied to the data's identity, typically a database id.
Analogy. Keys are name tags at a conference. If everyone wears a name tag, you can spot exactly who left, who's new, and who just changed seats. If instead you identify people purely by which chair they're sitting in (the array index), then the moment anyone swaps chairs you start greeting the wrong person, which is exactly the bug index keys cause.
Gotcha. Using the array index as a key is a common bug. If the list can reorder, insert, or delete in the middle, indices shift and React mismatches items, leading to wrong values in inputs, lost focus, and subtle rendering bugs. Index keys are only safe for static, append-only lists that never reorder.
Concretely, picture a to-do list where each row has a checkbox. With index keys, deleting the first item shifts every other item up by one index, so React thinks item 2 "became" item 1, and the checkbox states slide to the wrong rows:
// ❌ Delete the top todo and watch the checkmarks jump to the wrong rows
{todos.map((todo, index) => <TodoRow key={index} todo={todo} />)}
// ✅ Stable id keeps each row glued to its own data
{todos.map(todo => <TodoRow key={todo.id} todo={todo} />)}Conditional rendering patterns to keep in your pocket:
{isLoggedIn && <LogoutButton />} // render or nothing
{isLoading ? <Spinner /> : <Content data={d} />} // either/or
// Extract to a variable for complex branches
let body;
if (status === "loading") body = <Spinner />;
else if (status === "error") body = <Retry />;
else body = <List items={items} />;
return <section>{body}</section>;Virtual DOM, Reconciliation & Fiber
The virtual DOM is an in-memory tree of plain JavaScript objects (React elements) mirroring the real DOM. When state changes, React builds a new VDOM tree, diffs it against the previous one (reconciliation), computes the minimal set of changes, and applies only those to the real DOM. Diffing cheap JS objects and batching writes is fast; touching the real DOM is expensive.
Analogy. Imagine editing a printed document. The slow, expensive way is to reprint the whole document every time you change a word. Instead, you mark up a cheap draft copy, compare it to the last version to find just the words that changed, and only retype those. The virtual DOM is that cheap draft; the real DOM is the expensive printout.
// A React element is just an object describing UI:
{
type: "div",
props: {
className: "card",
children: [ { type: "h1", props: { children: "Hi" } } ]
}
}The Diffing Heuristics
A general tree-diff is . React makes it with two heuristics:
- Different element types produce different trees. If a node changes type (
<div>to<span>, orComponentAtoComponentB), React tears down the old subtree entirely and builds a new one; old state is destroyed. - Keys identify children across renders. Within a list, React matches elements by key to detect moves, insertions, and removals instead of recreating everything.
Gotcha. Because changing an element's type unmounts the subtree, conditionally swapping between an <input/> in one branch and a different component in another can wipe out internal state and focus. Keeping the same type and toggling props preserves state.State Is Tied to Position in the Tree
React keeps a component's state as long as the same component renders at the same position in the tree. Render a different component type at that position, or move the component somewhere else, and its state resets; React treats it as a brand-new instance. This is why two <Counter /> siblings each keep their own independent count even though they're identical code: they sit at different positions.
The deliberate flip side is forcing a reset with a different key. Changing a component's key tells React "this is a different instance," so it unmounts the old one and mounts a fresh one with clean state:
// When userId changes, the form fully resets (clears all internal state)
// instead of carrying the previous user's draft into the new user's view
<ProfileForm key={userId} user={user} />Interview answer: "When does React preserve state, and how do I force a reset?" State is preserved when the same component type renders at the same position across renders. It resets when the type at that position changes, when the component moves to a different position, or when you change its key. Changing key is the idiomatic way to deliberately remount and reset a component.
Fiber
Fiber (React 16) is the internal architecture that performs reconciliation. Each element gets a fiber node, a unit of work holding its type, props, state, and pointers to parent/child/sibling. Fiber makes rendering interruptible: React can split work into chunks, pause for high-priority updates (like typing), and resume later. This is the foundation for concurrent features.
Interview answer: "What problem did Fiber solve?" Before Fiber, reconciliation was synchronous and recursive: once React started rendering a large tree it couldn't stop, blocking the main thread and dropping frames. Fiber reimplemented reconciliation as a linked list of work units that can be paused, prioritized, aborted, and resumed, enabling time-slicing so urgent updates interrupt less urgent work.
Render Phase vs. Commit Phase
| Render phase | Commit phase | |
|---|---|---|
| What happens | call components, build/diff VDOM | apply changes to the real DOM |
| Purity | must be pure, no side effects | DOM exists; refs are set |
| Interruptible? | yes (can be paused/restarted) | no (synchronous) |
Because the render phase can be paused and even restarted, it must be a pure function of props and state: no mutations, subscriptions, or DOM writes during render. Side effects go in effects, which run during or after commit when the DOM is stable.
What Triggers a Re-render?
- Its own state changed (
setState/dispatch). - Its parent re-rendered. By default, children re-render too, even if props didn't change.
- A Context value it consumes changed.
- A forced update via a key change (remounts).
Gotcha. A common misconception: "a component re-renders only when its props change." Not true. By default a child re-renders whenever its parent does. To skip that when props are unchanged, wrap the child in React.memo.Interview answer: "Does a re-render always update the DOM?" No. A re-render means React calls the component and produces a new virtual tree. If the diff shows nothing changed in the output, React commits no DOM mutations. Performance work is usually about avoiding unnecessary render-phase work, not just DOM writes, hence the memoization tools.
Hooks, Part 1: Rules, State, Effects, Refs
Hooks are functions starting with use that let function components tap into React features. Two rules, and the reason behind them, matter most:
- Only call Hooks at the top level: never inside loops, conditions, or nested functions.
- Only call Hooks from React functions: components or custom hooks, not plain JS functions.
React tracks Hooks by call order, not by name. It keeps an ordered list of Hook slots per component. Call Hooks conditionally and the order shifts between renders, so React associates state with the wrong Hook and corrupts your component.
Analogy. Picture a coat check that hands out tickets in order: first coat gets ticket #1, second gets #2, and so on. React's Hooks work the same way: it remembers each Hook by the order it was called, not by name. If one render skips a Hook (because of an if), every ticket after it shifts by one, and you walk away with someone else's coat. Calling all your Hooks unconditionally, every time, keeps the ticket numbers stable.// WRONG: conditional Hook changes call order between renders
function Bad({ show }) {
if (show) {
const [x, setX] = useState(0); // sometimes called, sometimes not
}
}
// RIGHT: call unconditionally, branch on the value
function Good({ show }) {
const [x, setX] = useState(0);
return show ? <span>{x}</span> : null;
}useEffect: Synchronizing with the Outside World
useEffect runs side effects after the render is committed: data fetching, subscriptions, timers, manual DOM work, logging. Think "synchronize this component with an external system," not "lifecycle method."
Analogy. Rendering is React's job: drawing the UI from your state. An effect is for everything outside React's world that needs to stay in step with that UI: the document title, a chat server subscription, a setInterval, a browser API. You're telling React, "after you've painted this, go sync up the outside world too."The simplest possible effect keeps the browser tab title in sync with state:
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
document.title = `You clicked ${count} times`; // sync the tab title
}, [count]); // re-run only when count changes
return <button onClick={() => setCount(count + 1)}>Click</button>;
}useEffect(() => {
const id = setInterval(() => tick(), 1000);
return () => clearInterval(id); // cleanup: before next effect & on unmount
}, [/* dependencies */]);
// Dependency array semantics:
useEffect(fn); // runs after EVERY render
useEffect(fn, []); // runs ONCE after mount (cleanup on unmount)
useEffect(fn, [a, b]);// runs after mount + whenever a or b changesThe cleanup function tears down whatever the effect set up. React runs it before re-running the effect (deps changed) and once on unmount, preventing leaks and duplicate subscriptions.
Interview answer: "Map class lifecycles to useEffect." componentDidMount → useEffect(fn, []). componentDidUpdate → useEffect(fn, [deps]). componentWillUnmount → the returned cleanup function. One effect can express mount + update + unmount together, organized by concern. The real mental model is "synchronization," not a literal one-to-one map.
Common pitfalls worth memorizing:
- Missing dependencies cause stale values: closures capture old state/props. Include everything the effect reads.
- Object/array/function deps are compared by reference and change every render: wrap them in
useMemo/useCallbackor move them inside the effect. - Fetching without handling races: a fast re-fetch can resolve out of order.
- Putting derived data in an effect: if a value can be computed during render, compute it in render; don't mirror it into state.
// Race-safe data fetching
useEffect(() => {
let ignore = false;
(async () => {
const res = await fetch(`/api/user/${id}`);
const data = await res.json();
if (!ignore) setUser(data); // ignore stale responses
})();
return () => { ignore = true; };
}, [id]);Gotcha: "Why does my effect run twice on mount?" In development with React 18+ Strict Mode, React intentionally mounts, unmounts, and remounts components once to surface effects that aren't cleaned up. If your cleanup is correct, running twice is harmless. It does not happen in production. The fix is never to disable the check but to make setup and cleanup symmetric. (Strict Mode also double-invokes render functions and state updaters in development to surface impure logic, so an effect running twice is just the most visible symptom of a broader check.)
You Might Not Need an Effect
This is the single most common modern React mistake, and a frequent senior-level discriminator. Two rules cover most cases:
- For derived data, compute during render: don't sync with an effect. If a value can be calculated from existing props or state, just calculate it while rendering (memoize with
useMemoonly if it's expensive). Mirroring it into state via an effect adds a redundant render and a chance for the two to drift out of sync. - For logic that responds to a user action, put it in the event handler: not an effect that watches state. An effect that fires "after the count changed" loses the context of why it changed; the click handler has that context directly.
// ❌ Redundant: an effect mirroring derived state
const [fullName, setFullName] = useState("");
useEffect(() => { setFullName(first + " " + last); }, [first, last]);
// ✅ Just compute it during render
const fullName = first + " " + last;Gotcha: controlled to uncontrolled warning. "A component is changing an uncontrolled input to be controlled." This appears when an input'svaluestarts asundefined(so React treats it as uncontrolled) and later becomes a string. Always initialize the state sovalueis neverundefined: useuseState(""), notuseState().
useLayoutEffect vs. useEffect
Both run after render, but timing differs. useLayoutEffect fires synchronously after DOM mutations but before the browser paints; useEffect fires after paint, asynchronously. Reach for useLayoutEffect only when you must measure or mutate layout before the user sees it (e.g. positioning a tooltip) to avoid a visible flicker. Otherwise default to useEffect, which doesn't block painting.
useRef: Mutable Boxes & DOM Access
useRef returns a mutable object { current: ... } that persists across renders and does not trigger a re-render when changed. Two main uses: referencing a DOM node, and storing a mutable value that should survive renders without causing re-renders (timer ids, previous values, instance flags).
Analogy. A ref is a sticky note you keep on your monitor. You can scribble on it and read it anytime, and it stays put between renders, but changing it doesn't tell React to redraw anything. State is the opposite: writing to it does trigger a redraw. Use state for things the user should see change; use a ref for things you just need to remember.
// 1) DOM access
function TextInput() {
const inputRef = useRef(null);
const focus = () => inputRef.current.focus();
return (<><input ref={inputRef} /><button onClick={focus}>Focus</button></>);
}A second classic use is holding a value that must survive renders but shouldn't cause one, like the id returned by setInterval. Here a stopwatch stores its interval id in a ref so it can stop the timer later, while the visible seconds lives in state:
function Stopwatch() {
const [seconds, setSeconds] = useState(0);
const intervalRef = useRef(null); // remembered, but doesn't trigger renders
function start() {
if (intervalRef.current) return; // already running
intervalRef.current = setInterval(() => setSeconds(s => s + 1), 1000);
}
function stop() {
clearInterval(intervalRef.current);
intervalRef.current = null;
}
return (
<>
<p>{seconds}s</p>
<button onClick={start}>Start</button>
<button onClick={stop}>Stop</button>
</>
);
}Interview answer: "useRef vs. useState?" Both persist across renders, but updating state triggers a re-render and updating a ref does not. State is for data reflected in the UI; refs are for values you need to remember but that shouldn't cause rendering. Ref changes are synchronous and visible immediately on ref.current, whereas state updates are batched and applied on the next render.
Gotcha. Don't read or write ref.current during rendering (except to lazily initialize it). Rendering must be pure. Touch refs in event handlers and effects.useMemo & useCallback: Referential Stability
Both cache values between renders based on dependencies. useMemo caches a computed value; useCallback caches a function, and useCallback(fn, deps) is just useMemo(() => fn, deps).
// Skip an expensive recomputation unless inputs change
const sorted = useMemo(() => bigList.slice().sort(cmp), [bigList]);
// Keep a stable function identity so a memoized child doesn't re-render
const handleSelect = useCallback(id => setSelected(id), []);
return <ExpensiveChild onSelect={handleSelect} />; // child is React.memo'dInterview answer: "When does useCallback actually help?" It helps when the function is passed to a React.memo child (stable identity prevents the child from re-rendering) or used in another Hook's dependency array. It's pointless, even slightly wasteful, when the function is only used locally, because you pay for caching with no benefit. The React Compiler aims to make most manual useCallback/useMemo unnecessary.
Hooks, Part 2: Context, Reducers & React 19
useContext & the Context API
Context passes data through the tree without threading props through every level ("prop drilling"). Create a context, wrap part of the tree in a <Provider value={...}>, and any descendant reads it with useContext.
Analogy. Prop drilling is passing a message hand-to-hand down a long line of people, even though only the last person needs it. Context is a radio broadcast: the Provider transmits on a channel, and anyone in range can tune in directly with useContext, with no relaying through everyone in between.const ThemeContext = createContext("light");
function App() {
const [theme, setTheme] = useState("dark");
return (
<ThemeContext.Provider value={theme}>
<Toolbar /> {/* no theme prop needed below here */}
</ThemeContext.Provider>
);
}
function ThemedButton() {
const theme = useContext(ThemeContext); // reads nearest provider's value
return <button className={theme}>Click</button>;
}Interview answer: "What does Context solve, and its downside?" It eliminates prop drilling. The downside is performance: when a Provider's value changes, every consumer re-renders, and a new object/array passed as value changes identity every render. Mitigate by memoizing the value, splitting contexts by concern, or moving high-churn state into a dedicated store. Context is dependency injection, not a full state manager.
Gotcha.value={{ user, setUser }}creates a new object every render, forcing all consumers to re-render. Wrap it:const value = useMemo(() => ({ user, setUser }), [user]);
useReducer: Structured State Transitions
useReducer is an alternative to useState for complex or interdependent state. You centralize update logic in a reducer, a pure function (state, action) => newState, and trigger changes by dispatching actions. It's the conceptual basis of Redux.
Analogy. Think of a reducer as a vending machine's rulebook. You don't reach inside and rearrange the snacks yourself; you press a button (dispatch an action like "B4"), and the machine's fixed rules decide what comes out (the new state). All the "what happens when" logic lives in one predictable place, which makes it easy to test and reason about.function reducer(state, action) {
switch (action.type) {
case "increment": return { count: state.count + 1 };
case "reset": return { count: 0 };
default: throw new Error("Unknown action: " + action.type);
}
}
function Counter() {
const [state, dispatch] = useReducer(reducer, { count: 0 });
return (
<>
<span>{state.count}</span>
<button onClick={() => dispatch({ type: "increment" })}>+1</button>
<button onClick={() => dispatch({ type: "reset" })}>Reset</button>
</>
);
}Interview answer: "useState vs. useReducer?" Use useState for simple, independent values. Reach for useReducer when state has multiple fields, the next state depends intricately on the previous, transitions are driven by distinct "actions," or you want centralized, unit-testable update logic. Reducers pair well with Context to share state and dispatch across a subtree.
Other Built-in Hooks
useId: stable unique ids for accessibility attributes that match between server and client. Not for list keys.useImperativeHandle: customize what a parent's ref sees (exposefocus()instead of the raw node).useTransition: mark updates non-urgent so urgent ones (typing) stay responsive; gives anisPendingflag.useDeferredValue: render a lagging copy of a value so expensive UI updates don't block input.useSyncExternalStore: subscribe safely to external stores (Redux, Zustand) in a concurrent-safe way. The why: concurrent rendering can pause partway through a render, and if an external store changes during that pause, different parts of the UI could read different values of the same data, a glitch called tearing. This hook forces a consistent snapshot so every consumer sees the same value within one render. It's mostly used by library authors, not application code.
// useTransition keeps the input responsive while a heavy list re-renders
const [isPending, startTransition] = useTransition();
function onChange(e) {
setQuery(e.target.value); // urgent
startTransition(() => setResults(filter(e.target.value))); // non-urgent
}React 19: use(), Actions & Form Hooks
React 19 (stable, Dec 2024) added first-class support for async data and form mutations, cutting the boilerplate of manual loading/error/optimistic handling:
use(): read a Promise or Context during render; React suspends until it resolves. Unlike other Hooks,use()may be called conditionally.- Actions: async functions passed to a form's
actionprop; React manages pending state, errors, and sequencing. useActionState: track an action's state/result and pending status.useFormStatus: read the pending status of the nearest parent form from a child.useOptimistic: show an immediate optimistic update while the real mutation is in flight, auto-reverting on failure.
function NameForm({ currentName }) {
const [optimisticName, setOptimistic] = useOptimistic(currentName);
const [state, formAction, isPending] = useActionState(
async (prev, formData) => {
const name = formData.get("name");
setOptimistic(name); // show new name immediately
await updateNameOnServer(name); // real mutation
return name;
},
currentName
);
return (
<form action={formAction}>
<p>Name: {optimisticName}</p>
<input name="name" disabled={isPending} />
<SubmitButton />
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus(); // reads the parent <form> status
return <button disabled={pending}>Save</button>;
}React 19 also lets you pass ref as a normal prop (no more forwardRef in most cases), render <title>/<meta> directly inside components, and use ref-callback cleanup functions. React 19.2 added <Activity> (pre-render/hide subtrees while preserving state) and useEffectEvent (extract non-reactive logic from effects so it sees the latest props/state without being a dependency).
Custom Hooks: Reusing Logic
A custom hook is a function whose name starts with use and that calls other Hooks. It's the idiomatic way to share stateful logic, but two components sharing one hook do not share state; each gets its own.
Analogy. A custom hook is a recipe, not a finished dish. Sharing the recipe lets two cooks each make their own batch; they don't share the same bowl of soup. Likewise, two components using useToggle each get their own independent on/off value.The smallest useful custom hook just bundles a common state pattern. useToggle wraps a boolean and a flip function:
function useToggle(initial = false) {
const [on, setOn] = useState(initial);
const toggle = useCallback(() => setOn(o => !o), []);
return [on, toggle];
}
// Usage: const [isOpen, toggleOpen] = useToggle();A slightly richer one wraps an effect too. useWindowSize subscribes to the browser's resize event and cleans up after itself, so every component that calls it gets live dimensions without repeating the boilerplate:
function useWindowSize() {
const [size, setSize] = useState({
width: window.innerWidth,
height: window.innerHeight,
});
useEffect(() => {
const onResize = () =>
setSize({ width: window.innerWidth, height: window.innerHeight });
window.addEventListener("resize", onResize);
return () => window.removeEventListener("resize", onResize); // cleanup
}, []);
return size;
}And here's the useDebounce hook interviewers love to ask for: it delays updating a value until the user stops changing it (great for search boxes that shouldn't fire a request on every keystroke):
function useDebounce(value, delay = 300) {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id); // reset timer on each change
}, [value, delay]);
return debounced;
}Performance Optimization
Most performance problems are unnecessary render-phase work. Profile first with the React DevTools Profiler; premature memoization adds complexity and can even slow things down. The toolbox:
React.memo: skip re-rendering when props are shallow-equal to last time. Best for pure, expensive children that render often with the same props.useMemo/useCallback: stabilize values and functions so memoized children and effect deps don't see new references.- Code splitting with
React.lazy+Suspense: load components on demand to shrink the initial bundle. - List virtualization (
react-window,react-virtuoso): render only visible rows. - Move state down / lift content up: keep frequently-changing state local; pass static subtrees as
children. - Stable keys: let React reuse DOM and state instead of recreating.
- React Compiler: auto-memoizes at build time, reducing the need for manual memo hooks.
const Row = React.memo(function Row({ item, onPick }) {
return <li onClick={() => onPick(item.id)}>{item.name}</li>;
});
function List({ items }) {
const onPick = useCallback(id => console.log(id), []); // stable identity
return <ul>{items.map(i => <Row key={i.id} item={i} onPick={onPick} />)}</ul>;
}
// Code splitting
const Settings = React.lazy(() => import("./Settings"));
<Suspense fallback={<Spinner />}><Settings /></Suspense>Interview answer: "How does React.memo work?" It memoizes a component's output, re-rendering only when props change by shallow comparison. Use it for pure, frequently-rendering, relatively expensive components whose parent re-renders often with unchanged props. It backfires when props change every render anyway (new inline objects/functions), so it's usually paired with useMemo/useCallback. Measure first. React.memo also accepts an optional second argument, areEqual(prevProps, nextProps), a custom comparator returning true to skip the re-render, useful when the default shallow check isn't quite right (note the return value is inverted relative to shouldComponentUpdate).
Interview answer: "What is the React Compiler?" A build-time optimizing compiler (introduced experimentally at React Conf 2024) that automatically memoizes components and values, applying the equivalent of React.memo, useMemo, and useCallback by analyzing your code. The goal is to remove most manual memoization while staying fast. It relies on components following the Rules of React (purity, no mutation during render).
Component Patterns
Modern React leans on composition and hooks; older patterns still appear in libraries and legacy code.
- Composition with
children: the most fundamental pattern: pass UI into a component via thechildrenprop or named slots. Avoids inheritance, keeps components flexible. - Higher-Order Components (HOC): a function that takes a component and returns an enhanced one. The classic pre-hooks way to share cross-cutting logic. Downsides: wrapper nesting and prop collisions.
- Render Props: a component takes a function as a prop and calls it with data, letting the caller decide how to render. Largely superseded by hooks.
- Compound Components: related components share implicit state via Context to act as one unit (e.g.
<Tabs><Tab/><TabPanel/></Tabs>). Great for component libraries.
function withLoading(Component) {
return function Wrapped({ isLoading, ...rest }) {
if (isLoading) return <Spinner />;
return <Component {...rest} />;
};
}
const UserListWithLoading = withLoading(UserList);| Pattern | Shares | Modern status |
|---|---|---|
| Custom hooks | stateful logic | preferred default |
Composition (children) | UI structure | core, always relevant |
| HOC | cross-cutting behavior | legacy; hooks usually better |
| Render props | data via a function | legacy; hooks usually better |
| Compound components | implicit shared state | great for component libraries |
Advanced Topics
Error Boundaries
An error boundary catches JavaScript errors in its child tree during rendering, lifecycle, and constructors, then shows a fallback instead of crashing the whole app. They must be class components (getDerivedStateFromError / componentDidCatch); there is no hook equivalent yet, which is a common "why a class?" question. They do not catch errors in event handlers, async code, or SSR.
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(error, info) { logError(error, info); }
render() {
return this.state.hasError ? <Fallback /> : this.props.children;
}
}Suspense
<Suspense> declaratively shows a fallback while its children are "not ready": code-split chunks loading via React.lazy, or data being read with use(). It moves loading states out of imperative if (loading) checks into the tree.
<Suspense fallback={<PageSkeleton />}>
<Profile /> {/* may suspend on lazy load or data fetch */}
<Feed />
</Suspense>Portals
createPortal renders children into a DOM node outside the parent's DOM hierarchy (e.g. a modal at document.body) while keeping them in the React tree, so context and event bubbling still work. Ideal for modals, tooltips, and popovers that must escape parent overflow or z-index.
function Modal({ children }) {
return createPortal(
<div className="overlay">{children}</div>,
document.getElementById("modal-root")
);
}Server Components & Rendering Strategies
CSR ships JS that builds the page in the browser. SSR renders HTML on the server per request, then hydrates. SSG pre-renders at build time. React Server Components (RSC), stable in React 19, render on the server, send a serialized result (not JavaScript) to the client, and never ship their own JS, shrinking the bundle and letting them fetch data directly. They can't use state or effects; interactive parts are 'use client' components, orchestrated by frameworks like Next.js.
Interview answer: "RSC vs. SSR?" Traditional SSR runs your client components on the server to produce an HTML string, then ships the same component JS to the client to hydrate. RSC render on the server and send a serialized description of the UI; their code never reaches the browser. They access server resources directly and cut the client bundle, but can't hold state, run effects, or use browser APIs. RSC and SSR are complementary, not the same thing.
State Management, Routing & Testing
Not every app needs Redux. Start local; escalate only when sharing becomes painful. The interview-winning answer is matching the tool to the scope of the state.
| Tool | Best for | Notes |
|---|---|---|
useState / useReducer | local component state | always the starting point |
| Lifting state up | a few sibling components | no library needed |
| Context | low-frequency global data | theme, auth, locale; not high-churn data |
| Redux Toolkit | large apps, complex shared state | predictable, devtools, middleware |
| Zustand / Jotai | lightweight global state | less boilerplate than Redux |
| React Query / SWR | server state (caching, fetching) | handles cache, refetch, dedupe |
Interview answer: "Redux vs. Context?" Context is a transport mechanism with no built-in update optimization: every consumer re-renders when the value changes, so it's best for stable, low-frequency values. Redux (or Zustand/Jotai) is a real state container with structured updates, selective subscriptions, middleware, and time-travel devtools, worth it for large apps with complex, frequently-updated shared state. And distinguish server state (React Query/SWR) from client/UI state (Redux/Zustand/Context).
For routing, single-page apps map URLs to components without full reloads. The react-router essentials: a route table, navigation via <Link> / useNavigate, URL params via useParams, and nested routes with <Outlet>.
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/users/:id" element={<User />} /> {/* useParams() -> id */}
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>For testing, the community standard is Jest/Vitest plus React Testing Library. Its philosophy: test behavior the way a user experiences it, query by role/text, fire events, assert on visible output, rather than implementation details like internal state. This keeps tests resilient to refactors.
test("increments on click", async () => {
render(<Counter />);
const btn = screen.getByRole("button", { name: /count/i });
await userEvent.click(btn);
expect(btn).toHaveTextContent("1");
});React 19 / 19.2: What's New
- Actions: async functions for form
actionprops; automatic pending, error, and sequencing handling. - New hooks:
useActionState,useFormStatus,useOptimistic, anduse()for reading promises/context in render. refas a prop: function components acceptrefdirectly;forwardRefmostly unnecessary. Ref callbacks can return a cleanup function.- Document metadata: render
<title>,<meta>,<link>in components; React hoists them to<head>. - Stable Server Components & Actions for full-stack architectures.
- React Compiler: automatic memoization, reducing manual
useMemo/useCallback. - React 19.2:
<Activity>(hide/pre-render subtrees while keeping state),useEffectEvent(non-reactive effect logic), Performance Tracks in DevTools, and Partial Pre-rendering.
Rapid-Fire Interview Q&A
Is React a framework or a library? A library focused on the view layer. It doesn't prescribe routing, data fetching, or build tooling; you compose those from the ecosystem. Next.js builds a full framework around React.
Is JSX required? No. It's syntactic sugar over React.createElement / the jsx runtime, you could call those directly, but it's near-universal because it's far more readable. A compiler transforms it before the browser sees it.What is the children prop? A special prop holding whatever is nested between a component's tags. It enables composition, wrapping arbitrary content without knowing what it is.What is prop drilling and how do you avoid it? Passing props through many intermediate components that don't use them just to reach a deep child. Avoid with composition, Context, or a state library.
What is reconciliation? Diffing the new virtual DOM against the previous one to compute the minimal set of real DOM updates, using type and key heuristics for performance.
How do you fetch data in modern React? Prefer a data library (React Query/SWR) or a framework's data layer over rawuseEffectfetches. WithuseEffect, handle cleanup and race conditions. React 19'suse()+ Suspense and Server Components also fetch declaratively.
What is hydration? Attaching React's event handlers and state to server-rendered HTML on the client so static markup becomes interactive, without recreating the DOM. Server/client output mismatches cause hydration errors.
What does "React is unidirectional" mean? Data flows one way: down via props. Children communicate up only by calling callbacks passed as props, making state changes traceable.
Common Coding Challenges
Debounce hook:
function useDebounce(value, delay = 300) {
const [v, setV] = useState(value);
useEffect(() => {
const t = setTimeout(() => setV(value), delay);
return () => clearTimeout(t);
}, [value, delay]);
return v;
}Fetch with loading/error state:
function useFetch(url) {
const [state, setState] = useState({ data: null, loading: true, error: null });
useEffect(() => {
let active = true;
setState({ data: null, loading: true, error: null });
fetch(url)
.then(r => { if (!r.ok) throw new Error(r.status); return r.json(); })
.then(data => active && setState({ data, loading: false, error: null }))
.catch(error => active && setState({ data: null, loading: false, error }));
return () => { active = false; }; // ignore stale responses
}, [url]);
return state;
}Previous-value hook:
function usePrevious(value) {
const ref = useRef();
useEffect(() => { ref.current = value; }, [value]);
return ref.current; // returns the value from the previous render
}Controlled accordion:
function Accordion({ items }) {
const [openId, setOpenId] = useState(null);
return items.map(item => (
<div key={item.id}>
<button onClick={() => setOpenId(openId === item.id ? null : item.id)}>
{item.title}
</button>
{openId === item.id && <p>{item.body}</p>}
</div>
));
}Close-on-outside-click hook (used by every dropdown and modal):
function useOnClickOutside(ref, handler) {
useEffect(() => {
function onClick(e) {
// ignore clicks inside the referenced element
if (!ref.current || ref.current.contains(e.target)) return;
handler();
}
document.addEventListener("mousedown", onClick);
return () => document.removeEventListener("mousedown", onClick);
}, [ref, handler]);
}
// Usage:
// const menuRef = useRef(null);
// useOnClickOutside(menuRef, () => setOpen(false));Star rating (a controlled component combining state, lists, and conditional styling):
function StarRating({ count = 5, value, onChange }) {
const [hover, setHover] = useState(0);
return (
<div>
{Array.from({ length: count }, (_, i) => i + 1).map(star => (
<span
key={star}
onClick={() => onChange(star)}
onMouseEnter={() => setHover(star)}
onMouseLeave={() => setHover(0)}
style={{ cursor: "pointer" }}
>
{star <= (hover || value) ? "★" : "☆"}
</span>
))}
</div>
);
}Tricky Gotchas Interviewers Love
Stale closures. A callback or effect captures the state/props from the render it was created in. AsetIntervalset up once with[]deps that readscountwill always see the initial value. Fix with the functional updater, including the value in deps, a ref, oruseEffectEvent.
Mutating state directly. items.push(x); setItems(items) often skips the re-render because the array reference didn't change. Always create a new reference.Setting state in render. Calling a setter unconditionally during render causes an infinite render loop. Set state in event handlers or effects, or compute derived values directly during render.
Index keys + reordering. Causes inputs to keep the wrong values and lose focus. Use stable ids.
The&&with numbers bug.{count && <X/>}renders0whencountis0. Convert to a boolean:{count > 0 && <X/>}.
Forgetting effect cleanup. Subscriptions, timers, and listeners not cleaned up leak and can fire after unmount. Always return a cleanup when you set something up.
Related
UPSC Physics Paper 1 - 2025
UPSC Physics Paper 1 Solutions