React is built on one simple formula: UI = f(state). Your component is a pure function — given the same state and props, it always returns the same UI. React figures out the minimum DOM updates needed to get there.
You manually find DOM nodes and tell the browser how to change them. Error-prone — any code path can touch any element.
.textContent = count + 1;
You describe what the UI should look like. React handles the DOM mutations. Your code only cares about state.
return <p>{count}</p>;
}
Think of React components as blueprints, not buildings. When state changes, React creates a new blueprint and compares it to the previous one (diffing). Only the changed rooms get rebuilt in the actual DOM — React never tears down the whole house.
JSX is not HTML, not a template language, and not part of the JavaScript spec. It's a syntax extension that Babel (or the TypeScript compiler) transforms into plain JavaScript function calls before your browser ever sees it.
// ── What you WRITE ── const el = ( <button className="btn" onClick={handleClick}> <span>Click me</span> </button> ); // ── What Babel COMPILES it to (React 17+ new transform) ── import { jsx as _jsx } from 'react/jsx-runtime'; const el = _jsx('button', { className: 'btn', onClick: handleClick, children: _jsx('span', { children: 'Click me' }) }); // ── Old transform (React <17, still works) ── const el = React.createElement( 'button', { className: 'btn', onClick: handleClick }, React.createElement('span', null, 'Click me') );
The result is a plain JavaScript object — a React element. It describes what to render, but it's not the DOM node itself.
{
$$typeof: Symbol(react.element), // XSS protection marker
type: 'button', // string for DOM, function for component
key: null,
ref: null,
props: { className: 'btn', children: ... }
}
import React from 'react' in every JSX file — because Babel emitted React.createElement(). The new JSX transform auto-imports from react/jsx-runtime, so you no longer need that import just for JSX.
If asked "why do you need to import React?", explain the old vs new transform. If the interviewer mentions a project still requiring it, you know it's using React <17 or the old Babel config.
| Rule | Wrong | Correct | Why |
|---|---|---|---|
| Single root | <h1/><p/> | <><h1/><p/></> | JSX compiles to one function call — one return value |
| Self-close tags | <img><br> | <img /> <br /> | JSX is XML-strict — all tags must close |
| className | class="btn" | className="btn" | class is a reserved JS keyword |
| htmlFor | for="email" | htmlFor="email" | for is a reserved JS keyword |
| camelCase attrs | onclick tabindex | onClick tabIndex | JSX maps to JS object properties |
| Expressions in {} | <p>Hello name</p> | <p>Hello {name}</p> | Only {} creates a JS expression slot |
// ✅ Valid inside { } <p>{name}</p> // variable <p>{2 + 2}</p> // expression <p>{items.length}</p> // property access <p>{isLoggedIn ? 'Hi' : 'Login'}</p> // ternary <p>{formatDate(date)}</p> // function call // ❌ NOT valid inside { } — these are statements, not expressions <p>{if (x) ...}</p> // if statement ❌ <p>{for (...) ...}</p> // for loop ❌ // Comments inside JSX <div> {/* This is a JSX comment */} <p>Content</p> </div>
{false}, {null}, {undefined}, and {0}* are valid JSX but render nothing. Watch out for {count && <Comp />} when count is 0 — it renders the literal 0, not nothing. Use {count > 0 && <Comp />} or a ternary instead.
A plain JS object describing what should appear on screen. Immutable snapshot — like a frame in a movie.
- Created by
React.createElement()or JSX - Has
type,props,key,ref - Cheap to create — no DOM involved
typeis a string:"div","button"
A function (or class) that returns elements. It's the blueprint; elements are the blueprint's output.
- Accepts
props, returns JSX/elements - Can hold state, run effects
- Name must start with capital letter
typein the element is the function itself
// Lowercase → React treats it as a DOM string tag <button>click</button> // compiles to: React.createElement('button', ...) // Uppercase → React treats it as a component reference <Button>click</Button> // compiles to: React.createElement(Button, ...) // React calls Button() to get the elements it returns // ⚠ This is why this BREAKS: function myButton() { return <button />; } const el = <myButton />; // treated as unknown DOM tag, not a component! // ✅ Fix: capital letter, OR assign to capital-letter variable const MyButton = myButton; const el = <MyButton />; // ✅ now recognized as component
<MyCard /> asks React to "cook" MyCard and get back a description of the DOM nodes it wants. React then decides whether to create, update, or reuse real DOM nodes.
| Feature | Class Component | Functional Component |
|---|---|---|
| State | this.state / this.setState | useState hook |
| Lifecycle | componentDidMount, componentDidUpdate, componentWillUnmount | useEffect (covers all three) |
| Side effects | Scattered across lifecycle methods | Collocated in useEffect |
| Context | static contextType or <Context.Consumer> | useContext hook |
| Code sharing | HOCs / render props (verbose) | Custom hooks (clean) |
this binding | Must bind event handlers or use arrow methods | No this — no binding bugs |
| Closures | Always reads from this — sees latest value | Each render closes over its own props/state snapshot |
| Error boundaries | Only class components can be error boundaries | Cannot be error boundaries (yet) |
// CLASS: always reads the LATEST value via this handleClick() { setTimeout(() => { alert(this.state.count); // shows current count at time of alert }, 3000); } // FUNCTIONAL: closes over the value at render time function Counter() { const [count, setCount] = useState(0); const handleClick = () => { setTimeout(() => { alert(count); // shows count at the time button was clicked, not 3s later }, 3000); }; }
getSnapshotBeforeUpdate. The React team has said they have no plans to remove class components, but all new React features (concurrent mode, hooks) are functional-only.
Don't just say "functional is better." Explain why: logic collocation with custom hooks, no this binding complexity, and the closure-per-render mental model that makes concurrent React safe.
Props (short for properties) are the mechanism for passing data from parent to child. They are read-only — a component must never modify its own props.
// ── Passing props ── <UserCard name="Yogesh" age={28} isAdmin={true} onClick={handleClick} /> // ── Receiving props (object destructure) ── function UserCard({ name, age, isAdmin, onClick }) { return ( <div onClick={onClick}> <h2>{name}</h2> {isAdmin && <span>Admin</span>} </div> ); } // ── Default props via destructuring ── function Button({ label = 'Submit', variant = 'primary' }) { ... } // ── Spread props (use sparingly) ── const props = { type: 'button', disabled: false }; <button {...props}>Go</button>
Anything placed between a component's opening and closing tags becomes props.children. It can be a string, an element, an array of elements, or even a function (render prop pattern).
// Parent uses children <Card> <h3>Title</h3> <p>Body text</p> </Card> // Card component renders whatever is passed function Card({ children }) { return <div className="card">{children}</div>; } // children can also be a function (render prop) <DataProvider> {(data) => <Table rows={data} />} </DataProvider>
Data flows down (parent → child via props) and events flow up (child → parent via callback props). Think of it like a waterfall — water only falls down. To tell a parent about a change, the child calls a callback the parent passed as a prop. This makes data flow predictable and debuggable.
The React docs explicitly recommend composition over inheritance. In 5+ years of building React at Meta, they found no use case where inheritance solved something composition couldn't solve better.
// ── Pattern 1: children (slot) ── function Dialog({ title, children }) { return ( <div className="dialog"> <h1>{title}</h1> {children} </div> ); } <Dialog title="Welcome"> <p>Any content here</p> </Dialog> // ── Pattern 2: named slots (multiple prop slots) ── function Layout({ sidebar, main, footer }) { return ( <div className="layout"> <aside>{sidebar}</aside> <main>{main}</main> <footer>{footer}</footer> </div> ); } <Layout sidebar={<Nav />} main={<Feed />} footer={<Footer />} /> // ── Pattern 3: specialization (compose general into specific) ── function WelcomeDialog() { return ( <Dialog title="Welcome!"> <p>Thanks for visiting.</p> </Dialog> ); }
// ── 1. if/else (outside JSX) — best for complex conditions ── function Page({ isLoading, data }) { if (isLoading) return <Spinner />; if (!data) return <Empty />; return <DataView data={data} />; } // ── 2. Ternary — best for 2-branch inline choices ── return ( <div> {isLoggedIn ? <UserMenu /> : <LoginBtn />} </div> ); // ── 3. Short-circuit && — best for "show or nothing" ── return ( <div> {hasError && <ErrorBanner msg={error} />} {items.length > 0 && <List items={items} />} // ✅ guard with > 0 </div> ); // ── 4. Return null — unmount / hide completely ── function Tooltip({ visible, text }) { if (!visible) return null; // component is called but renders nothing return <div className="tooltip">{text}</div>; }
| Pattern | Use when | Avoid when |
|---|---|---|
| if/else | Multiple branches, early returns, complex logic | Simple inline render |
| Ternary | Exactly 2 options inline in JSX | Nested ternaries — kills readability |
| && | Show-or-nothing, simple boolean | Left side is 0 or empty string |
| return null | Component must not render anything | Hiding via CSS is better (avoids remount cost) |
return null fully unmounts the component — all state is destroyed. Using style={{ display: isVisible ? 'block' : 'none' }} keeps the component mounted. Choose based on whether you want state preserved.
When React re-renders a list, it needs to match the new list of elements against the previous one. Without keys, React uses array position — which breaks when items are inserted, deleted, or reordered. Keys give React a stable identity for each item.
Imagine 30 students sit in numbered seats. If you add a new student at the front and all seats shift, React (without keys) thinks every student changed. With keys (student ID cards), React knows exactly which students are new, moved, or removed — and only updates those seats.
// ✅ GOOD: stable, unique, from your data {users.map(user => ( <UserRow key={user.id} user={user} /> ))} // ⚠ ACCEPTABLE: index only if list is static, never reordered, never filtered {staticTabs.map((tab, i) => ( <Tab key={i} label={tab.label} /> ))} // ❌ BAD: random keys — new key every render = unnecessary remounts {items.map(item => ( <Item key={Math.random()} item={item} /> ))} // ❌ BAD: index on a mutable list // Add item to front → every item's key shifts → all remount, input state lost {todos.map((todo, i) => ( <TodoItem key={i} /> // ❌ if todos can be prepended/deleted ))}
// Initial list: [{id:1, name:'Alice'}, {id:2, name:'Bob'}] // Keys: key=0 → Alice, key=1 → Bob // User types in Alice's input (local state inside TodoItem) // We prepend {id:3, name:'Charlie'} // New keys: key=0 → Charlie, key=1 → Alice, key=2 → Bob // React reuses the key=0 DOM node — input now shows Alice's typed text // but the label says "Charlie" 💥
key is a special React attribute — it's not passed as a prop to the component. If you need the id inside the component, pass it as a separate prop: <Item key={item.id} id={item.id} />.
The Virtual DOM is a lightweight JavaScript object tree that mirrors the structure of the real DOM. When state changes, React creates a new VDOM tree and compares it to the previous one — this comparison is called diffing. Only the actual differences get applied to the real DOM.
Imagine a word processor that keeps a copy of the "before" document and a "new" document. Instead of retyping the whole thing, it uses track-changes to find only the edits and applies them. React does the same — produce a new description, diff it, patch the DOM surgically.
<div> becomes a <span>, React tears down the entire subtree and builds fresh. This is why conditionally swapping component types is expensive.key to match children across renders. Without keys it falls back to position — which breaks on reorder/insert.React 16 replaced the old recursive reconciler with Fiber. Key benefits:
- Incremental rendering: work can be split into chunks and spread across multiple frames
- Prioritization: urgent updates (user input) can interrupt low-priority updates (data fetching renders)
- Concurrent mode: enables React 18 features like
startTransition, Suspense with data, and streaming SSR
Mention Fiber to show depth. Interviewers love hearing the distinction between the old stack reconciler (synchronous, all-or-nothing) and Fiber (incremental, prioritised). Connect it to why concurrent React features are possible.
JSX requires a single root. Wrapping everything in a <div> adds unnecessary DOM nodes that can break CSS layouts (flex, grid, table). Fragment groups children without adding a DOM node.
// Full syntax — supports key prop (needed in lists) {items.map(item => ( <React.Fragment key={item.id}> <dt>{item.term}</dt> <dd>{item.def}</dd> </React.Fragment> ))} // Shorthand syntax — no key support, but cleaner function Columns() { return ( <> <td>Name</td> <td>Age</td> </> ); }
A Portal renders a component's output into a different DOM node than where the component lives in the React tree. Crucial for modals, tooltips, and dropdowns that need to visually escape overflow:hidden or z-index stacking contexts.
import { createPortal } from 'react-dom'; function Modal({ children }) { return createPortal( <div className="modal-overlay">{children}</div>, document.getElementById('modal-root') // DOM node outside React root ); } // Portal still behaves like a normal React child: // - events bubble through React tree (not DOM tree) // - context works as normal // - unmounts when parent unmounts
<React.StrictMode> is a development-only tool. It double-invokes render functions, state initialisers, and reducers to surface side effects hidden in them. It also warns about deprecated APIs.
// Wrap your entire app (or part of it) createRoot(document.getElementById('root')).render( <React.StrictMode> <App /> </React.StrictMode> ); // What StrictMode does in development: // ✅ Renders components twice — surfaces impure renders // ✅ Runs effects twice (mount → unmount → remount) — surfaces missing cleanup // ✅ Warns about deprecated findDOMNode, legacy context API, string refs // ✅ No effect in production builds
Each render is a snapshot in time. When you call setState, React doesn't mutate the current state — it schedules a new render with new state. The current render's variables are frozen for that render's lifetime.
Every render is like taking a photograph. The photo captures exactly what the scene looked like at that moment — you can't change it. State is the scene. Calling setState rearranges the scene and takes a new photo. Event handlers inside a render always see the values from their photo, not a later one.
const [count, setCount] = useState(0); // ↑ current value ↑ setter function ↑ initial value (only used on first render) // The setter does NOT mutate count — it schedules a new render function handleClick() { setCount(5); console.log(count); // still 0 — this render's snapshot is frozen } // ✅ To use the new value, use the updater function form setCount(prev => prev + 1); // always based on latest queued state // ── Why updater form matters ── function increment3Times() { setCount(count + 1); // ❌ all 3 calls read the same stale count setCount(count + 1); // ❌ result: count + 1, not count + 3 setCount(count + 1); // ❌ setCount(c => c + 1); // ✅ updater queued: prev → prev+1 setCount(c => c + 1); // ✅ chains on previous result setCount(c => c + 1); // ✅ result: count + 3 }
React maintains a list of state slots per component instance (fiber node). Hooks are called in the same order every render, so slot 0 always corresponds to the first useState, slot 1 to the second, etc. This is why the Rules of Hooks exist — calling hooks conditionally would shift the slots.
setState with the same value as current state (by Object.is comparison), React bails out — no re-render, no children re-render. This is why mutating state directly doesn't work: obj.x = 1; setState(obj) — same reference, React skips the render.
Batching is React grouping multiple setState calls from the same event handler into a single re-render. Without it, three setStates would trigger three separate renders — expensive and potentially showing intermediate states.
// ── React 17 and earlier ── // Batched ONLY inside React event handlers: handleClick() { setCount(c => c + 1); // } batched → 1 render setFlag(true); // } } // NOT batched — setTimeout, Promises, native event listeners in React 17: setTimeout(() => { setCount(c => c + 1); // render 1 setFlag(true); // render 2 ← two separate renders! }, 1000); // ── React 18 — Automatic Batching everywhere ── setTimeout(() => { setCount(c => c + 1); // } batched → 1 render ✅ setFlag(true); // } }, 1000); // Opt out of batching in React 18 (rare): import { flushSync } from 'react-dom'; flushSync(() => setCount(c => c + 1)); // forces render immediately flushSync(() => setFlag(true)); // then another render
React 18 automatic batching is a common question. Key point: it's enabled by default when using createRoot (not legacy ReactDOM.render). Mention flushSync as the escape hatch when you need synchronous DOM updates (e.g., before reading a layout measurement).
React uses reference equality (Object.is) to detect state changes. If you mutate an object and pass the same reference, React sees no change and skips the render. You must always create a new object/array.
// ── Objects ── const [user, setUser] = useState({ name: 'Yogesh', age: 28 }); // ❌ Mutating — React won't re-render user.age = 29; setUser(user); // same reference! // ✅ Spread to create new object setUser({ ...user, age: 29 }); // ── Arrays ── const [items, setItems] = useState(['a', 'b', 'c']); // ❌ Mutating methods — avoid in state updates items.push('d'); // mutates! items.splice(1,1); // mutates! // ✅ Non-mutating equivalents setItems([...items, 'd']); // add setItems(items.filter(i => i !== 'b')); // remove setItems(items.map(i => i === 'b' ? 'B' : i)); // update setItems([...items].sort()); // sort (spread first!) // ── Nested objects — spread each level ── const [profile, setProfile] = useState({ name: 'Yogesh', address: { city: 'Mumbai', pin: '400001' } }); setProfile({ ...profile, address: { ...profile.address, city: 'Pune' } });
produce(state, draft => { draft.a.b.c = 1 }).
// ── Form 1: No dependency array — runs after EVERY render ── useEffect(() => { console.log('Runs after every render'); }); // Use: rare. Logging, measurements that must run on every update. // ── Form 2: Empty array [] — runs once on mount only ── useEffect(() => { fetchData(); const sub = subscribe(); return () => sub.unsubscribe(); // cleanup on unmount }, []); // Use: initial data fetch, setting up subscriptions, one-time setup. // ── Form 3: Dependency array — runs when deps change ── useEffect(() => { document.title = `Count: ${count}`; }, [count]); // only re-runs when count changes // Use: syncing state to an external system, reacting to prop/state changes.
componentDidMount. Use only when you need to read/write DOM layout before the user sees the paint (measuring element sizes, avoiding flicker). Overuse blocks painting.
// ⚠ Object/array deps created inline re-trigger every render! useEffect(() => { fetchUser(options); }, [{ id: userId }]); // ❌ new object every render → infinite loop risk // ✅ Use primitive values, or useMemo/useCallback to stabilise useEffect(() => { fetchUser({ id: userId }); }, [userId]); // ✅ primitive dep
The cleanup function returned from useEffect runs in two situations:
- Before the next effect fires — when deps change, React cleans up the previous effect, then runs the new one
- When the component unmounts
Think of it as: setup → (deps change) → cleanup → setup → (unmount) → cleanup
// ── 1. AbortController — cancel in-flight fetch ── useEffect(() => { const controller = new AbortController(); fetch(`/api/user/${userId}`, { signal: controller.signal }) .then(r => r.json()) .then(setUser) .catch(e => { if (e.name !== 'AbortError') setError(e); }); return () => controller.abort(); // cancel if userId changes or unmounts }, [userId]); // ── 2. Event listener ── useEffect(() => { const handleResize = () => setWidth(window.innerWidth); window.addEventListener('resize', handleResize); return () => window.removeEventListener('resize', handleResize); }, []); // ── 3. setInterval ── useEffect(() => { const id = setInterval(() => setTime(Date.now()), 1000); return () => clearInterval(id); }, []); // ── 4. WebSocket / subscription ── useEffect(() => { const ws = new WebSocket(`ws://chat/${roomId}`); ws.onmessage = e => setMessages(m => [...m, e.data]); return () => ws.close(); }, [roomId]);
// ❌ setState inside effect with no deps (or state in deps) useEffect(() => { setData(fetchSomething()); // setData → re-render → effect runs → setData → ∞ }); // no dep array! // ❌ Object dep created inside render const options = { page: 1 }; // new reference every render useEffect(() => { fetch(options); }, [options]); // always "changed" → loop // ✅ Fix: stabilise or use primitives const page = 1; useEffect(() => { fetch({ page }); }, [page]); // primitive dep
// ❌ count is frozen at 0 in this closure useEffect(() => { const id = setInterval(() => { setCount(count + 1); // always reads count=0, increments to 1 forever }, 1000); return () => clearInterval(id); }, []); // empty deps — effect never re-runs, count never updates // ✅ Fix 1: use updater function (no need to read count) setCount(c => c + 1); // always gets latest value from React // ✅ Fix 2: add count to deps (re-creates interval on each change) useEffect(() => { const id = setInterval(() => setCount(count + 1), 1000); return () => clearInterval(id); }, [count]);
// ❌ Suppressing the ESLint warning hides real bugs useEffect(() => { doSomething(value); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); // value is stale — this is a bug, not a fix // ✅ Proper fix options: // 1. Add missing dep }, [value]); // 2. Move the dep inside the effect (so it's not a dependency) useEffect(() => { const latest = getLatestValue(); // read inside effect, not from closure doSomething(latest); }, []); // 3. Use useRef to store a mutable value without triggering re-runs const valueRef = useRef(value); useEffect(() => { valueRef.current = value; }); // keep ref in sync useEffect(() => { doSomething(valueRef.current); // reads latest without being a dep }, []);
| Phase | Method | When it runs | Common use |
|---|---|---|---|
| Mounting | constructor | Before first render | Initialise state, bind methods |
render | Produces virtual DOM | Return JSX — must be pure | |
componentDidMount | After first real DOM paint | Fetch data, subscribe, set up timers | |
| Updating | shouldComponentUpdate | Before re-render | Performance — return false to skip |
render | New virtual DOM | — | |
getSnapshotBeforeUpdate | Before DOM is updated | Capture scroll position before change | |
componentDidUpdate | After DOM updated | Respond to prop/state changes | |
| Unmounting | componentWillUnmount | Before component removed | Cancel timers, unsubscribe, abort fetches |
// ⚠ Always guard with a condition — or you'll get an infinite loop componentDidUpdate(prevProps) { if (this.props.userId !== prevProps.userId) { this.fetchUser(this.props.userId); } } // Without the guard: setState → componentDidUpdate → setState → ∞
| Class method | Hook equivalent |
|---|---|
constructor (initial state) | useState(initialValue) |
componentDidMount | useEffect(() => { ... }, []) |
componentDidUpdate on specific dep | useEffect(() => { ... }, [dep]) |
componentWillUnmount | useEffect(() => { return () => cleanup() }, []) |
shouldComponentUpdate | React.memo + useMemo / useCallback |
getDerivedStateFromProps | Calculate during render (derived state pattern) |
getSnapshotBeforeUpdate | No direct equivalent — use useLayoutEffect with a ref |
// ── Class component ── componentDidMount() { this.fetch(this.props.id); } componentDidUpdate(prev) { if (prev.id !== this.props.id) this.fetch(this.props.id); } componentWillUnmount() { this.controller.abort(); } // ── Functional component — same logic, less code ── useEffect(() => { const controller = new AbortController(); fetch(`/api/${id}`, { signal: controller.signal }) .then(r => r.json()) .then(setData); return () => controller.abort(); }, [id]); // runs on mount AND whenever id changes — covers both CDM and CDU
// ❌ Expensive computation runs on EVERY render (result is discarded after mount) const [state, setState] = useState(parseHeavyJSON(rawData)); // called every render! const [items, setItems] = useState(JSON.parse(localStorage.getItem('cart') || '[]')); // ✅ Pass a FUNCTION — React calls it only on first render const [state, setState] = useState(() => parseHeavyJSON(rawData)); const [items, setItems] = useState(() => { const saved = localStorage.getItem('cart'); return saved ? JSON.parse(saved) : []; }); // Same pattern for useReducer const [state, dispatch] = useReducer(reducer, undefined, initFromStorage); // initFromStorage() called once; result becomes initial state
// ❌ Anti-pattern: syncing prop → state via useEffect function SearchBox({ query }) { const [value, setValue] = useState(query); useEffect(() => { setValue(query); // "sync" query to value }, [query]); // Problem: one render with stale value, then another with correct value → flash // Also: loses user's in-progress typing whenever parent re-renders }
// ✅ If value is always derived from query — no state needed at all function SearchBox({ query }) { const upperQuery = query.toUpperCase(); // computed during render — always in sync return <p>{upperQuery}</p>; } // ✅ Expensive derived value — useMemo (not useEffect) function FilteredList({ items, filter }) { const filtered = useMemo( () => items.filter(i => i.includes(filter)), [items, filter] ); return <List items={filtered} />; } // ✅ When you DO need editable state seeded from a prop: // Use the lazy initializer — initialise once, user can then edit independently function EditableField({ initialValue }) { const [value, setValue] = useState(initialValue); // seed once, then own it return <input value={value} onChange={e => setValue(e.target.value)} />; }
This is one of the most common React mistakes at all levels. Saying "I derive it during render instead of syncing it in useEffect" signals that you understand React's rendering model deeply. Bonus: mention the React docs article "You Might Not Need an Effect."
In plain HTML, the DOM owns form state — the <input> element tracks what the user typed. React flips this: your component's state owns the value, and the input just displays it. Every keystroke fires an event → you update state → React re-renders → input shows new value. You are always in control.
Value driven by React state. You always know what's in the field. Good for: validation as you type, disabling submit, dependent fields.
onChange={e => setVal(e.target.value)} />
DOM owns the value. You read it only when needed (on submit). Good for: file inputs, third-party widgets, performance-sensitive large forms.
<input ref={ref} />
// read: ref.current.value
React wraps every native browser event in a SyntheticEvent — a cross-browser normalisation layer. The wrapper exposes the same interface as the native event (target, currentTarget, preventDefault(), stopPropagation()), but behaves consistently across all browsers.
function Form() { const handleSubmit = (e) => { e.preventDefault(); // stops browser page reload console.log(e.type); // "submit" console.log(e.target); // the <form> DOM node console.log(e.nativeEvent); // the real browser event }; const handleChange = (e) => { console.log(e.target.value); // typed value console.log(e.target.name); // input name attribute console.log(e.target.checked); // for checkboxes }; return ( <form onSubmit={handleSubmit}> <input name="email" onChange={handleChange} /> </form> ); }
| Aspect | Native DOM | React SyntheticEvent |
|---|---|---|
| Naming | onclick (lowercase) | onClick (camelCase) |
| Handler value | String: "handleClick()" | Function reference: {handleClick} |
| Default prevention | return false works | Must call e.preventDefault() |
| Consistency | Browser-specific quirks | Normalised — same in all browsers |
| Pooling (React ≤16) | N/A | Event was reused — e.persist() was needed. Removed in React 17 |
e.target.value inside a setTimeout would give null. You needed e.persist(). React 17 removed pooling entirely — you can safely read event properties asynchronously.
React does not attach event listeners to individual DOM nodes. Instead, it attaches one listener per event type at the root container (document in React ≤16, the root div in React 17+). When an event fires anywhere in the tree, it bubbles up to that root listener, and React figures out which handlers to call.
Instead of every room in a building having its own phone line, there's one receptionist at the front desk. All calls go to the receptionist first, who then routes them to the right room. React is the receptionist — one listener, routes events to the right component.
document broke when multiple React versions ran on the same page (micro-frontends). Moving delegation to the root container means each React tree manages its own events independently.
// Bubbling — inner click fires child handler, then parent handler <div onClick={() => console.log('parent')}> <button onClick={() => console.log('child')}>Click</button> </div> // Logs: "child" → "parent" // Stop bubbling — prevent parent from firing <button onClick={(e) => { e.stopPropagation(); handleDelete(); }}>Delete</button> // Capture phase — fires top-down, before bubbling <div onClickCapture={() => console.log('captured first')}> <button onClick={() => console.log('bubble')}>Click</button> </div> // Logs: "captured first" → "bubble" // Common pattern: close dropdown when clicking outside useEffect(() => { const close = (e) => { if (!ref.current.contains(e.target)) setOpen(false); }; document.addEventListener('mousedown', close); return () => document.removeEventListener('mousedown', close); }, []);
// ── Pattern 1: Inline arrow function (simplest) ── <button onClick={() => deleteUser(user.id)}>Delete</button> // ⚠ Creates a new function reference every render — can break React.memo // ── Pattern 2: data attribute (avoids new function per item) ── <button data-id={user.id} onClick={handleDelete} // same reference every render >Delete</button> const handleDelete = (e) => { const id = e.currentTarget.dataset.id; deleteUser(id); }; // ── Pattern 3: useCallback for memoised handler ── const handleDelete = useCallback((id) => { deleteUser(id); }, []); // stable reference {users.map(u => ( <UserRow key={u.id} user={u} onDelete={handleDelete} // stable — UserRow wrapped in React.memo won't re-render /> ))} // ── Pattern 4: curried handler factory ── const handleDelete = (id) => (e) => { e.stopPropagation(); deleteUser(id); }; <button onClick={handleDelete(user.id)}>Delete</button>
() => fn(arg) creates a new function on every render. This only causes real performance issues when the child is wrapped in React.memo — otherwise React would re-render it anyway. Don't prematurely optimise; only reach for useCallback when you've profiled a real problem.
function LoginForm() { const [email, setEmail] = useState(''); const [password, setPassword] = useState(''); return ( <form onSubmit={handleSubmit}> <input type="email" value={email} // React controls the value onChange={e => setEmail(e.target.value)} /> // ✅ Powered by: validate on every keystroke // ✅ Powered by: disable submit until valid <button disabled={!email || !password}>Login</button> </form> ); } // ⚠ Gotcha: value without onChange = read-only input (React warns) <input value="fixed" /> // read-only — user can't type <input defaultValue="initial" /> // uncontrolled with initial value
function UploadForm() { const fileRef = useRef(null); const nameRef = useRef(null); const handleSubmit = (e) => { e.preventDefault(); const name = nameRef.current.value; // read on submit const file = fileRef.current.files[0]; // file input MUST be uncontrolled upload({ name, file }); }; return ( <form onSubmit={handleSubmit}> <input ref={nameRef} defaultValue="" /> <input type="file" ref={fileRef} /> // file inputs CANNOT be controlled <button>Upload</button> </form> ); }
| Need | Use |
|---|---|
| Validate on every keystroke | Controlled |
| Disable submit button conditionally | Controlled |
| Dynamic/dependent fields | Controlled |
| File upload | Uncontrolled (always) |
| Simple submit-only form with no validation | Uncontrolled (simpler) |
| Integrating a third-party rich text editor | Uncontrolled + ref |
| Large form with 50+ fields (performance) | Uncontrolled or React Hook Form |
function AllInputs() { const [form, setForm] = useState({ name: '', agree: false, gender: '', country: 'in', bio: '', skills: [], // multiple checkboxes }); // Universal handler — reads name + type to handle all inputs const handleChange = (e) => { const { name, value, type, checked } = e.target; setForm(prev => ({ ...prev, [name]: type === 'checkbox' ? checked : value })); }; // Multi-checkbox handler (array of selected values) const handleSkill = (e) => { const { value, checked } = e.target; setForm(prev => ({ ...prev, skills: checked ? [...prev.skills, value] : prev.skills.filter(s => s !== value) })); }; return ( <form> {/* Text input */} <input type="text" name="name" value={form.name} onChange={handleChange} /> {/* Single checkbox — reads checked, not value */} <input type="checkbox" name="agree" checked={form.agree} onChange={handleChange} /> {/* Radio buttons — each has same name, different value */} <input type="radio" name="gender" value="male" checked={form.gender === 'male'} onChange={handleChange} /> <input type="radio" name="gender" value="female" checked={form.gender === 'female'} onChange={handleChange} /> {/* Select — uses value prop, not selected attribute */} <select name="country" value={form.country} onChange={handleChange}> <option value="in">India</option> <option value="us">USA</option> </select> {/* Textarea — uses value prop, NOT children */} <textarea name="bio" value={form.bio} onChange={handleChange} /> {/* Multi-checkbox (array state) */} {['React', 'Node', 'CSS'].map(skill => ( <label key={skill}> <input type="checkbox" value={skill} checked={form.skills.includes(skill)} onChange={handleSkill} /> {skill} </label> ))} </form> ); }
- Checkbox: use
checked+ reade.target.checked, notvalue - Textarea: in HTML you set content as children; in React use the
valueprop - Select: in HTML you add
selectedto an option; in React putvalueon the<select>itself
function SignupForm() { const [fields, setFields] = useState({ email: '', password: '' }); const [errors, setErrors] = useState({}); const [touched, setTouched] = useState({}); // ── Validate function (pure) ── const validate = ({ email, password }) => { const e = {}; if (!email) e.email = 'Required'; else if (!email.includes('@')) e.email = 'Invalid email'; if (!password) e.password = 'Required'; else if (password.length < 8) e.password = 'Min 8 chars'; return e; }; // ── Strategy 1: Real-time (onChange) ── const handleChange = (e) => { const next = { ...fields, [e.target.name]: e.target.value }; setFields(next); setErrors(validate(next)); // validate every keystroke }; // ── Strategy 2: On-blur (show error only after user leaves the field) ── const handleBlur = (e) => { setTouched(t => ({ ...t, [e.target.name]: true })); setErrors(validate(fields)); }; // ── Strategy 3: On-submit only ── const handleSubmit = (e) => { e.preventDefault(); const errs = validate(fields); if (Object.keys(errs).length) { setErrors(errs); setTouched({ email: true, password: true }); // show all errors return; } submitToApi(fields); }; return ( <form onSubmit={handleSubmit}> <input name="email" value={fields.email} onChange={handleChange} onBlur={handleBlur} /> {/* Show error only after field was touched */} {touched.email && errors.email && <p className="error">{errors.email}</p>} <button disabled={Object.keys(validate(fields)).length > 0}> Submit </button> </form> ); }
The touched pattern is the professional approach — errors appear only after the user has interacted with a field, not before they've even tried. Always mention the onBlur + touched combination. For production apps, mention React Hook Form or Zod for schema-based validation.
When a user types "React interview", that's 16 keystrokes. Without debouncing, you'd fire 16 API requests. Debouncing waits until the user stops typing for N milliseconds before running the expensive operation.
A debounced elevator doesn't close its doors the moment someone steps in. It waits a few seconds to see if anyone else is coming. Only after no new arrivals for a set time does it close and move. Each new arrival resets the timer.
// ── Approach 1: useEffect + setTimeout (no library) ── function SearchBox() { const [input, setInput] = useState(''); // updates on every keystroke const [query, setQuery] = useState(''); // debounced value sent to API useEffect(() => { const timer = setTimeout(() => setQuery(input), 400); return () => clearTimeout(timer); // cancel if input changes before 400ms }, [input]); useEffect(() => { if (query) searchAPI(query); // only fires when user stops typing }, [query]); return <input value={input} onChange={e => setInput(e.target.value)} />; } // ── Approach 2: Custom useDebounce hook (reusable) ── function useDebounce(value, delay = 400) { const [debounced, setDebounced] = useState(value); useEffect(() => { const timer = setTimeout(() => setDebounced(value), delay); return () => clearTimeout(timer); }, [value, delay]); return debounced; } // Usage: const debouncedSearch = useDebounce(input, 400); useEffect(() => { if (debouncedSearch) searchAPI(debouncedSearch); }, [debouncedSearch]); // ── Approach 3: useRef to hold stable debounced function ── const timerRef = useRef(); const handleChange = (e) => { clearTimeout(timerRef.current); timerRef.current = setTimeout(() => searchAPI(e.target.value), 400); };
_.debounce / _.throttle.
const INITIAL = { name: '', email: '', role: 'user', active: true }; function UserForm({ onSave }) { const [form, setForm] = useState(INITIAL); const [loading, setLoading] = useState(false); const [error, setError] = useState(null); // Universal change handler using computed property name const handleChange = (e) => { const { name, value, type, checked } = e.target; setForm(f => ({ ...f, [name]: type === 'checkbox' ? checked : value })); }; // Programmatic field update (useful for dependent fields) const setField = (key, val) => setForm(f => ({ ...f, [key]: val })); // Reset to initial state const handleReset = () => setForm(INITIAL); // Submit with async + error handling const handleSubmit = async (e) => { e.preventDefault(); setLoading(true); setError(null); try { await onSave(form); handleReset(); // clear form on success } catch (err) { setError(err.message); } finally { setLoading(false); } }; return ( <form onSubmit={handleSubmit}> <input name="name" value={form.name} onChange={handleChange} /> <input name="email" value={form.email} onChange={handleChange} /> {error && <p className="error">{error}</p>} <button type="submit" disabled={loading}> {loading ? 'Saving...' : 'Save'} </button> <button type="button" onClick={handleReset}>Reset</button> </form> ); }
useRef returns { current: value }. Unlike state, changing .current does not trigger a re-render. It has two main uses: holding a reference to a DOM node, and holding a mutable value that should persist across renders without causing re-renders.
function AutoFocusInput() { const inputRef = useRef(null); useEffect(() => { inputRef.current.focus(); // DOM method — runs after mount }, []); const handleClear = () => { inputRef.current.value = ''; // direct DOM mutation (uncontrolled) inputRef.current.focus(); }; return <input ref={inputRef} placeholder="type here" />; } // useRef for mutable value (no re-render on change) function Stopwatch() { const [time, setTime] = useState(0); const intervalRef = useRef(null); // holds timer id — not state const start = () => { intervalRef.current = setInterval( () => setTime(t => t + 1), 1000 ); }; const stop = () => clearInterval(intervalRef.current); }
By default, ref is not passed as a prop — React intercepts it. forwardRef lets a component forward the ref it receives to one of its internal DOM nodes, enabling the parent to directly control a child's input.
// ── Child: forward the ref to the actual <input> ── const FancyInput = forwardRef(({ label, ...props }, ref) => ( <div> <label>{label}</label> <input ref={ref} {...props} /> // ref points to this DOM node </div> )); // ── Parent: gets direct access to the input DOM node ── function Parent() { const ref = useRef(null); return ( <> <FancyInput label="Name" ref={ref} /> <button onClick={() => ref.current.focus()}>Focus</button> </> ); } // ── useImperativeHandle — expose a custom API instead of the raw DOM node ── const FancyInput = forwardRef((props, ref) => { const inputRef = useRef(); useImperativeHandle(ref, () => ({ focus: () => inputRef.current.focus(), clear: () => { inputRef.current.value = ''; } })); return <input ref={inputRef} {...props} />; }); // Parent calls: ref.current.focus() or ref.current.clear()
Every keystroke in a controlled input calls setState → re-renders the entire form component. On a form with 50 fields, this means 50 re-renders per keystroke across all fields — most of them doing nothing useful.
React Hook Form solves this by using uncontrolled inputs + refs internally. It only triggers re-renders when the form-level validation state changes, not on every keystroke.
import { useForm } from 'react-hook-form'; function RegisterForm() { const { register, // connects input to RHF handleSubmit, // wraps your submit, provides validated data formState: { errors, isSubmitting }, reset, // reset to default values watch, // watch a field's value reactively setValue, // programmatically set a field } = useForm({ defaultValues: { name: '', email: '' } }); const onSubmit = async (data) => { await saveUser(data); // data is already validated reset(); }; return ( <form onSubmit={handleSubmit(onSubmit)}> <input {...register('name', { required: 'Name is required', minLength: { value: 2, message: 'Min 2 chars' } })} /> {errors.name && <p>{errors.name.message}</p>} <input {...register('email', { required: 'Email required', pattern: { value: /^\S+@\S+\.\S+$/, message: 'Invalid email' } })} /> {errors.email && <p>{errors.email.message}</p>} <button disabled={isSubmitting}> {isSubmitting ? 'Saving...' : 'Register'} </button> </form> ); } // With Zod schema validation (production pattern) import { zodResolver } from '@hookform/resolvers/zod'; import { z } from 'zod'; const schema = z.object({ name: z.string().min(2), email: z.string().email(), }); const { register, handleSubmit } = useForm({ resolver: zodResolver(schema) });
| Scenario | Use |
|---|---|
| Simple form, 2–5 fields | Native controlled (useState) |
| Real-time dependent fields (field A affects field B) | Controlled + watch() |
| Large form, many fields, performance matters | React Hook Form |
| Schema-driven validation, TypeScript types from schema | RHF + Zod/Yup |
| Dynamic field arrays (add/remove rows) | RHF useFieldArray |
Show you know the trade-off: "For simple forms I use controlled components because the code is straightforward and explicit. For forms with many fields or complex validation, I reach for React Hook Form because it's uncontrolled under the hood — zero re-renders per keystroke — and pairs cleanly with Zod for type-safe validation."
React stores hooks in an ordered linked list attached to each component's fiber node. The first call to useState reads slot 0, the second reads slot 1, and so on. React relies entirely on call order to match each hook call to its stored state — this is why hooks cannot be called conditionally.
Imagine a filing cabinet with numbered drawers. React opens drawer 1 for your first hook, drawer 2 for the second. If you skip drawer 2 sometimes (conditional hook), React puts the wrong data in every subsequent drawer. The cabinet only works if you always open drawers in the same order.
Only call hooks at the top level. Never inside loops, conditions, or nested functions. Always in the same order every render.
Only call hooks from React functions. Either function components or other custom hooks — never from regular JS functions, class methods, or event handlers.
// ❌ Conditional hook — breaks the slot order function BadComponent({ isAdmin }) { const [name, setName] = useState(''); // slot 0 ✅ if (isAdmin) { const [role, setRole] = useState(''); // slot 1 — but ONLY sometimes! } const [age, setAge] = useState(0); // slot 1 or 2 depending on isAdmin 💥 } // When isAdmin flips, "age" reads from the wrong slot → corrupted state // ✅ Condition goes INSIDE the hook, not around it function GoodComponent({ isAdmin }) { const [name, setName] = useState(''); // always slot 0 const [role, setRole] = useState(''); // always slot 1 const [age, setAge] = useState(0); // always slot 2 // Condition is inside useEffect, not around the hook useEffect(() => { if (isAdmin) fetchAdminRole().then(setRole); }, [isAdmin]); } // ❌ Hook inside event handler const handleClick = () => { const [x, setX] = useState(0); // ❌ not called during render };
eslint-plugin-react-hooks — it enforces both rules automatically and catches violations before runtime. The exhaustive-deps rule also flags missing useEffect dependencies. This plugin ships with Create React App and Vite's React template.
useRef(initial) returns { current: initial }. The object itself is stable — same reference every render. Mutating .current is not a state update: it doesn't schedule a re-render, it doesn't trigger effects, React doesn't know it changed.
function VideoPlayer({ src }) { const videoRef = useRef(null); const play = () => videoRef.current.play(); const pause = () => videoRef.current.pause(); return ( <> <video ref={videoRef} src={src} /> <button onClick={play}>Play</button> <button onClick={pause}>Pause</button> </> ); // videoRef.current is null until after first render (before mount) // Always null-check: videoRef.current?.play() }
// Track how many times a component rendered (for debugging) function RenderCounter() { const renderCount = useRef(0); renderCount.current++; // no re-render triggered return <p>Rendered {renderCount.current} times</p>; } // Store previous value of a prop/state function usePrevious(value) { const ref = useRef(); useEffect(() => { ref.current = value; // runs after render — stores this render's value for next }); return ref.current; // returns value from PREVIOUS render } // Store a callback without stale closure (useEvent pattern) function useStableCallback(fn) { const ref = useRef(fn); useEffect(() => { ref.current = fn; }); // always latest fn return useCallback((...args) => ref.current(...args), []); // stable identity }
| Need | Use |
|---|---|
| Value that triggers re-render when changed | useState |
| Value that persists across renders but never triggers re-render | useRef |
| Access a DOM node after render | useRef |
| Track interval/timeout IDs | useRef |
| Store previous prop/state value | useRef |
useMemo caches the return value of a function between renders. It only recalculates when one of its dependencies changes. Think of it as "memoised computation."
// ── Without useMemo — recalculates on every render ── function ProductList({ products, filter, theme }) { // Runs on EVERY render, even when only theme changes const filtered = products .filter(p => p.name.includes(filter)) .sort((a, b) => a.price - b.price); return <List items={filtered} theme={theme} />; } // ── With useMemo — only recalculates when products or filter change ── function ProductList({ products, filter, theme }) { const filtered = useMemo(() => products .filter(p => p.name.includes(filter)) .sort((a, b) => a.price - b.price), [products, filter]); // theme change = skip recalc ✅ return <List items={filtered} theme={theme} />; } // ── Stabilise object/array reference for useEffect deps ── // Without: new object every render → effect re-fires every render const options = useMemo( () => ({ endpoint: url, method: 'GET' }), [url] ); useEffect(() => { fetch(options); }, [options]); // stable ✅
Every useMemo adds memory (cache storage) and overhead (dependency comparison on every render). The React team explicitly says: don't add useMemo everywhere "just in case." Only use it when:
- The computation is measurably slow (>1ms — use React Profiler to verify)
- You need a stable reference to prevent downstream re-renders or effect re-runs
- The computation is called many times per second (e.g., inside a scroll handler)
Simple operations like array.filter() on a 10-item list are faster without useMemo than with it.
The best answer pairs the use case with a profiling step: "I'd first profile with React DevTools Profiler to confirm the computation is actually slow before adding useMemo. Premature memoisation makes code harder to read for zero gain."
useCallback(fn, deps) returns a memoised function — the same function reference across renders as long as deps don't change. It's essentially useMemo(() => fn, deps) but specifically for functions.
// ── The problem: new function = React.memo child re-renders anyway ── function Parent() { const [count, setCount] = useState(0); // New function object every render → ExpensiveChild always re-renders const handleSubmit = (data) => saveData(data); return ( <> <button onClick={() => setCount(c => c + 1)}>+</button> <ExpensiveChild onSubmit={handleSubmit} /> // re-renders on every count change </> ); } // ── Fix: useCallback + React.memo on child ── function Parent() { const [count, setCount] = useState(0); // Stable reference — only changes if saveData changes const handleSubmit = useCallback((data) => { saveData(data); }, []); // empty deps = never changes return ( <> <button onClick={() => setCount(c => c + 1)}>+</button> <ExpensiveChild onSubmit={handleSubmit} /> // skips re-render ✅ </> ); } // ExpensiveChild must be wrapped in React.memo to benefit const ExpensiveChild = React.memo(({ onSubmit }) => { console.log('ExpensiveChild rendered'); return <form onSubmit={onSubmit}>...</form>; }); // ── useCallback for stable useEffect dependency ── const fetchUser = useCallback(() => { api.get(`/users/${userId}`).then(setUser); }, [userId]); useEffect(() => { fetchUser(); }, [fetchUser]); // safe — only re-runs when userId changes
useCallback alone does nothing to prevent child re-renders. The child must be wrapped in React.memo (or PureComponent). Without that, the child re-renders regardless of prop stability. Always ask: "Is this child memoised? Does this function appear in a useEffect dep array?"
useReducer(reducer, initialState) returns [state, dispatch]. Instead of calling setX(newVal) directly, you dispatch an action — a plain object describing what happened. The reducer function decides how state should change.
You don't walk into a bank and directly modify the vault. You submit a transaction slip (action) to the teller (reducer). The teller applies the rules and updates the balance (state). You never touch the vault directly — all changes go through one controlled function.
// ── State shape ── const initial = { items: [], total: 0, loading: false }; // ── Reducer — pure function, no side effects ── function cartReducer(state, action) { switch (action.type) { case 'ADD_ITEM': return { ...state, items: [...state.items, action.item], total: state.total + action.item.price }; case 'REMOVE_ITEM': { const removed = state.items.find(i => i.id === action.id); return { ...state, items: state.items.filter(i => i.id !== action.id), total: state.total - (removed?.price ?? 0) }; } case 'CLEAR': return initial; case 'SET_LOADING': return { ...state, loading: action.loading }; default: return state; // always return current state for unknown actions } } // ── Component ── function Cart() { const [cart, dispatch] = useReducer(cartReducer, initial); return ( <div> <p>Total: {cart.total}</p> <button onClick={() => dispatch({ type: 'ADD_ITEM', item: { id: 1, price: 10 } })}> Add </button> <button onClick={() => dispatch({ type: 'CLEAR' })}>Clear</button> </div> ); }
| Situation | Use |
|---|---|
| Single independent value | useState |
| Multiple related values that change together | useReducer |
| Next state depends on previous in complex ways | useReducer |
| Many event types affecting the same state object | useReducer |
| Need to test state transitions in isolation | useReducer (reducer is pure, easy to unit test) |
| State changes are simple and independent | useState |
// useMemo — caches the RETURN VALUE of a function const sortedList = useMemo( () => [...items].sort(), // ← this function runs, result is cached [items] ); // sortedList is an ARRAY (the return value) // useCallback — caches the FUNCTION ITSELF const handleSort = useCallback( () => setItems(i => [...i].sort()), // ← this function is cached as-is [] ); // handleSort is a FUNCTION // They are equivalent: const handleSort = useCallback(fn, [deps]); // is exactly the same as: const handleSort = useMemo(() => fn, [deps]);
| Hook | Caches | Use for |
|---|---|---|
useMemo | Computed value (array, object, number…) | Expensive calculation, stable object reference for deps |
useCallback | Function reference | Stable callback for memoised children or useEffect deps |
React.memo component or a useEffect / useMemo dependency array.
Prop drilling: passing a value through many intermediate components that don't need it, just to reach a deeply nested child. Context creates a "teleport" — any component inside the provider can read the value directly, no matter how deep.
Instead of running an ethernet cable from your router through every room to reach the device that needs it (prop drilling), you broadcast a WiFi signal (context). Any device in range can connect directly — the rooms in between don't need to participate.
// ── 1. Create context with default (for use outside provider) ── const ThemeContext = createContext(null); // ── 2. Provider component encapsulates state ── export function ThemeProvider({ children }) { const [theme, setTheme] = useState('light'); const toggle = useCallback( () => setTheme(t => t === 'light' ? 'dark' : 'light'), [] ); // Memoize value object — avoids all consumers re-rendering on unrelated parent renders const value = useMemo(() => ({ theme, toggle }), [theme, toggle]); return ( <ThemeContext.Provider value={value}> {children} </ThemeContext.Provider> ); } // ── 3. Custom hook — throws if used outside provider ── export function useTheme() { const ctx = useContext(ThemeContext); if (!ctx) throw new Error('useTheme must be inside ThemeProvider'); return ctx; } // ── 4. Usage anywhere in the tree ── function ThemeButton() { const { theme, toggle } = useTheme(); return <button onClick={toggle}>Mode: {theme}</button>; } // ── 5. Wire at app root ── function App() { return ( <ThemeProvider> <Router> <Layout /> // ThemeButton anywhere inside works </Router> </ThemeProvider> ); }
Every component that calls useContext(MyCtx) will re-render whenever the context value changes — even if the part of the value it uses didn't change. Context uses reference equality: a new object reference (even with same contents) triggers all consumers.
// ❌ Problem: new object on every render → all consumers re-render function BadProvider({ children }) { const [user, setUser] = useState(null); const [theme, setTheme] = useState('dark'); return ( // New object literal every render → reference changes → all consumers re-render <AppCtx.Provider value={{ user, setUser, theme, setTheme }}> {children} </AppCtx.Provider> ); } // ✅ Fix 1: useMemo on the value object const value = useMemo( () => ({ user, setUser, theme, setTheme }), [user, theme] // setters are stable, don't need to be deps ); // ✅ Fix 2: Split context by update frequency (best pattern) const UserCtx = createContext(null); // changes when user logs in/out const ThemeCtx = createContext(null); // changes on theme toggle // Now: toggling theme only re-renders ThemeCtx consumers, not user consumers function Providers({ children }) { const [user, setUser] = useState(null); const [theme, setTheme] = useState('dark'); return ( <UserCtx.Provider value={useMemo(() => ({ user, setUser }), [user])}> <ThemeCtx.Provider value={useMemo(() => ({ theme, setTheme }), [theme])}> {children} </ThemeCtx.Provider> </UserCtx.Provider> ); }
A custom hook is a plain JavaScript function whose name starts with use and that calls other hooks inside it. That's it. No special API, no registration. The use prefix tells React (and the linter) to apply the Rules of Hooks to it.
The superpower: extract stateful logic shared across components without changing the component tree. No HOC wrapper, no render prop — just a function call.
function useFetch(url) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { if (!url) return; const controller = new AbortController(); setLoading(true); fetch(url, { signal: controller.signal }) .then(r => { if (!r.ok) throw new Error(r.statusText); return r.json(); }) .then(setData) .catch(e => { if (e.name !== 'AbortError') setError(e); }) .finally(() => setLoading(false)); return () => controller.abort(); }, [url]); return { data, loading, error }; } // Usage — clean component, zero fetch logic function UserProfile({ id }) { const { data, loading, error } = useFetch(`/api/users/${id}`); if (loading) return <Spinner />; if (error) return <Error msg={error.message} />; return <p>{data.name}</p>; }
function useLocalStorage(key, defaultValue) { const [value, setValue] = useState(() => { try { const stored = localStorage.getItem(key); return stored ? JSON.parse(stored) : defaultValue; } catch { return defaultValue; } }); const setStored = useCallback((next) => { const toStore = typeof next === 'function' ? next(value) : next; setValue(toStore); localStorage.setItem(key, JSON.stringify(toStore)); }, [key, value]); return [value, setStored]; } // Usage: const [theme, setTheme] = useLocalStorage('theme', 'dark');
function useWindowSize() { const [size, setSize] = useState({ width: window.innerWidth, height: window.innerHeight }); useEffect(() => { const handle = () => setSize({ width: window.innerWidth, height: window.innerHeight }); window.addEventListener('resize', handle); return () => window.removeEventListener('resize', handle); }, []); return size; } // Usage: const { width } = useWindowSize(); // const isMobile = width < 768;
Each React component instance has a corresponding Fiber node. Attached to that fiber is a linked list of "hook objects." Each call to a hook during render reads from (or writes to) the next node in this list, in order.
function Counter() { const [count, setCount] = useState(0); // Hook node 1: { state: 0, queue: [] } const [name, setName] = useState(''); // Hook node 2: { state: '', queue: [] } useEffect(() => {}, []); // Hook node 3: { effect, deps: [] } const memoVal = useMemo(() => count * 2, [count]); // Hook node 4: { value, deps } } // Fiber's hook list (conceptual linked list): // node1 → node2 → node3 → node4 → null // On RE-RENDER: React walks the SAME list in order // useState call 1 → reads node1.state → returns [0, setter] // useState call 2 → reads node2.state → returns ['', setter] // useEffect call → reads node3.deps → compares, schedules if changed // useMemo call → reads node4.deps → recomputes if changed // MOUNT vs UPDATE — different dispatcher // First render: "mount dispatcher" — creates nodes, stores initial values // Subsequent: "update dispatcher" — reads existing nodes, applies queued updates
setCount(5) is called — React adds an update object to hook node 1's queue: { action: 5, next: null }Counter (marks the fiber as "dirty")node1.state = 5useState reads node1.state (now 5) and returns [5, setter]React patterns exist to solve one core problem: how do you share logic and UI structure between components? The answer changed three times as React matured:
connect, React Router withRouter). Compound components are the gold standard for UI kits (Tabs, Accordion, Select). Error boundaries must be class components. Knowing these patterns is essential for reading and contributing to real codebases.
A HOC is a function that takes a component and returns a new enhanced component. It's a pure function — it doesn't modify the original component, it wraps it. The name conventionally starts with with.
A HOC is like gift-wrapping a present. The original gift (component) is unchanged inside. The wrapping paper (HOC) adds extra presentation — a bow, a tag, protection. You can wrap the same gift in different paper for different occasions.
// ── withAuth HOC — redirect if not logged in ── function withAuth(WrappedComponent) { // Return a new component that wraps the original function AuthGuard(props) { const { user } = useAuth(); if (!user) { return <Navigate to="/login" />; } // Pass ALL original props through — transparency is key return <WrappedComponent {...props} user={user} />; } // Preserve displayName for React DevTools AuthGuard.displayName = `withAuth(${WrappedComponent.displayName || WrappedComponent.name})`; return AuthGuard; } // Usage const ProtectedDashboard = withAuth(Dashboard); // ── withLogger HOC — log renders ── function withLogger(WrappedComponent) { return function(props) { useEffect(() => { console.log(`${WrappedComponent.name} mounted`, props); return () => console.log(`${WrappedComponent.name} unmounted`); }, []); return <WrappedComponent {...props} />; }; } // ── HOCs are composable ── const EnhancedPage = withLogger(withAuth(Dashboard)); // Renders: EnhancedPage → withLogger wrapper → withAuth wrapper → Dashboard
| Pitfall | Problem | Fix |
|---|---|---|
| Prop collision | HOC injects a prop with same name as original | Document injected props; use namespacing |
| Ref not forwarded | ref attaches to wrapper, not inner component | Use forwardRef |
| Static methods lost | Static methods on wrapped component don't copy over | Use hoist-non-react-statics library |
| DevTools naming | Component shows as "Component" not "withAuth(Dashboard)" | Set displayName explicitly |
| Wrapper hell | 3+ HOCs = deeply nested tree, hard to debug | Refactor to custom hook |
A component accepts a function as a prop (usually named render or children) and calls it to produce its output. The component shares its internal state/logic by passing it as arguments to that function — giving the caller full control over rendering.
// ── Mouse tracker with render prop ── function MouseTracker({ render }) { const [pos, setPos] = useState({ x: 0, y: 0 }); return ( <div onMouseMove={e => setPos({ x: e.clientX, y: e.clientY })}> {render(pos)} // caller decides what to do with pos </div> ); } // Caller 1: show coordinates <MouseTracker render={({ x, y }) => <p>{x}, {y}</p>} /> // Caller 2: move an image with the cursor <MouseTracker render={({ x, y }) => ( <img src="/cat.png" style={{ position: 'fixed', left: x, top: y }} /> )} /> // ── children as a function (same pattern, cleaner syntax) ── function Toggle({ children }) { const [on, setOn] = useState(false); return <{children({ on, toggle: () => setOn(v => !v) })}>; } <Toggle> {({ on, toggle }) => ( <button onClick={toggle}>{on ? 'ON' : 'OFF'}</button> )} </Toggle>
shouldComponentUpdate / React.memo. Both are mostly replaced by custom hooks today — but knowing them is essential for understanding library code.
All three solve "share stateful logic between components." The logic: track the current window width and provide it to any component that needs it.
// ── HOC approach ── function withWindowWidth(Component) { return function(props) { const [width, setWidth] = useState(window.innerWidth); useEffect(() => { const h = () => setWidth(window.innerWidth); window.addEventListener('resize', h); return () => window.removeEventListener('resize', h); }, []); return <Component {...props} windowWidth={width} />; }; } const MyPage = withWindowWidth(Page); // extra wrapper in DevTools // ── Render prop approach ── function WindowWidth({ children }) { const [width, setWidth] = useState(window.innerWidth); // ...same effect... return children(width); } // extra nesting in JSX: <WindowWidth> {(width) => <Page width={width} />} </WindowWidth> // ── Custom hook approach ── (winner) function useWindowWidth() { const [width, setWidth] = useState(window.innerWidth); useEffect(() => { const h = () => setWidth(window.innerWidth); window.addEventListener('resize', h); return () => window.removeEventListener('resize', h); }, []); return width; } // Usage — zero component tree impact: function Page() { const width = useWindowWidth(); // clean! no wrappers, no prop injection return <p>{width < 768 ? 'Mobile' : 'Desktop'}</p>; }
| HOC | Render Props | Custom Hook | |
|---|---|---|---|
| Component tree pollution | Adds wrapper node | Adds wrapper node | None ✅ |
| Prop source clarity | Hidden — where did this prop come from? | Explicit at call site | Explicit ✅ |
| Composability | Messy with 3+ HOCs | Callback nesting | Just call multiple hooks ✅ |
| TypeScript | Hard to type correctly | Verbose | Natural ✅ |
| Still used in libraries | Yes (Redux, Router) | Rare | Default choice ✅ |
A group of components that work together and share implicit state via context — like HTML's <select> and <option>. The parent manages state; children read from it without needing explicit props for coordination. The user of the API gets full compositional control over structure.
A compound component is like a restaurant menu. The menu (parent) holds the current selection state. The sections — Starters, Mains, Desserts (children) — each know what's selected without you telling each section separately. You compose the menu layout freely, and the state just works.
// ── 1. Shared context ── const TabsCtx = createContext(null); // ── 2. Parent manages state ── function Tabs({ children, defaultTab = 0 }) { const [active, setActive] = useState(defaultTab); return ( <TabsCtx.Provider value={{ active, setActive }}> <div className="tabs">{children}</div> </TabsCtx.Provider> ); } // ── 3. Sub-components read from context ── function TabList({ children }) { return <div role="tablist" className="tab-list">{children}</div>; } function Tab({ index, children }) { const { active, setActive } = useContext(TabsCtx); return ( <button role="tab" aria-selected={active === index} className={active === index ? 'tab active' : 'tab'} onClick={() => setActive(index)} > {children} </button> ); } function TabPanels({ children }) { const { active } = useContext(TabsCtx); return <div className="panels">{React.Children.toArray(children)[active]}</div>; } function TabPanel({ children }) { return <div role="tabpanel">{children}</div>; } // ── 4. Attach sub-components as static properties (optional, clean API) ── Tabs.List = TabList; Tabs.Tab = Tab; Tabs.Panels = TabPanels; Tabs.Panel = TabPanel; // ── 5. API the consumer sees — flexible, self-documenting ── <Tabs defaultTab={0}> <Tabs.List> <Tabs.Tab index={0}>Overview</Tabs.Tab> <Tabs.Tab index={1}>Details</Tabs.Tab> <Tabs.Tab index={2}>Reviews</Tabs.Tab> </Tabs.List> <Tabs.Panels> <Tabs.Panel>Overview content</Tabs.Panel> <Tabs.Panel>Details content</Tabs.Panel> <Tabs.Panel>Reviews content</Tabs.Panel> </Tabs.Panels> </Tabs>
React.memo wraps a functional component and skips re-rendering if the parent re-renders but the component's props haven't changed. It performs a shallow comparison of each prop using Object.is.
// Without memo — re-renders every time parent renders function ExpensiveList({ items }) { console.log('ExpensiveList rendered'); return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>; } // With memo — skips render if items reference didn't change const ExpensiveList = React.memo(function({ items }) { return <ul>{items.map(i => <li key={i.id}>{i.name}</li>)}</ul>; }); // Custom comparison for deep equality or partial checks const UserCard = React.memo( ({ user }) => <div>{user.name}</div>, (prev, next) => prev.user.id === next.user.id // only re-render if id changes );
| Scenario | Why memo fails |
|---|---|
Prop is an inline object: style={{ color: 'red' }} | New object reference every render → always "changed" |
Prop is an inline function: onClick={() => fn()} | New function reference every render → always "changed" |
| Component has no props at all | Nothing to compare — but it still prevents re-render if parent renders, which is the point |
| Parent passes children JSX | children is a new React element every render — memo breaks unless you also memoize the children |
React.memo prevents child re-render only if all props are stable. Stabilise function props with useCallback. Stabilise object props with useMemo. Without the trio working together, memoisation doesn't help.
When building a reusable component (e.g., a <Select> dropdown), you face a choice: does the component manage its own open/closed state (uncontrolled), or does the parent control it via props (controlled)? The best components support both modes — like HTML's <input>.
function Dropdown({ // Controlled mode props open: controlledOpen, // if provided, parent controls it onOpenChange, // parent's setter // Uncontrolled mode props defaultOpen = false, // initial value (uncontrolled) children }) { // Internal state — used only in uncontrolled mode const [internalOpen, setInternalOpen] = useState(defaultOpen); // Is this component controlled? Check if parent passed the open prop const isControlled = controlledOpen !== undefined; const open = isControlled ? controlledOpen : internalOpen; const handleToggle = () => { if (isControlled) { onOpenChange?.(!open); // notify parent — parent decides the new state } else { setInternalOpen(v => !v); // manage our own state } }; return ( <div> <button onClick={handleToggle}>Toggle</button> {open && <div className="panel">{children}</div>} </div> ); } // Uncontrolled usage (simpler) <Dropdown defaultOpen={false}>Content</Dropdown> // Controlled usage (parent owns state) <Dropdown open={isMenuOpen} onOpenChange={setIsMenuOpen}> Content </Dropdown>
This pattern is used by every serious UI library: Radix UI, Headless UI, Ant Design. Naming convention: value/onChange for controlled, defaultValue for uncontrolled. Mentioning you've thought about API design from a library perspective signals senior-level thinking.
An Error Boundary is a class component that implements componentDidCatch and/or getDerivedStateFromError. It catches JavaScript errors thrown during rendering, in lifecycle methods, and in constructors of child components — then shows a fallback UI instead of crashing the whole app.
An error boundary is an electrical circuit breaker. When a fault (error) occurs in one circuit (component subtree), the breaker trips and isolates it — the rest of the building (app) stays powered. Without it, one fault blows the whole fuse box.
class ErrorBoundary extends React.Component { constructor(props) { super(props); this.state = { hasError: false, error: null }; } // Runs during render phase — update state to show fallback static getDerivedStateFromError(error) { return { hasError: true, error }; } // Runs after render — good for logging to Sentry etc. componentDidCatch(error, info) { logToSentry(error, info.componentStack); } render() { if (this.state.hasError) { return this.props.fallback ?? <h2>Something went wrong.</h2>; } return this.props.children; } } // Usage — wrap any subtree <ErrorBoundary fallback={<ErrorPage />}> <UserDashboard /> </ErrorBoundary> // Multiple granular boundaries — isolate failures <Layout> <ErrorBoundary fallback={<SidebarError />}><Sidebar /></ErrorBoundary> <ErrorBoundary fallback={<FeedError />}><Feed /></ErrorBoundary> </Layout>
| Not caught | Why / Alternative |
|---|---|
| Event handler errors | Not part of rendering — use try/catch inside the handler |
| Async code (setTimeout, Promises) | Not thrown during render — use try/catch + setState |
| Server-side rendering errors | Error boundaries are client-only |
| Errors in the boundary itself | Boundary can't catch its own errors — needs a parent boundary |
react-error-boundary package provides a functional-friendly API with a useErrorBoundary hook and an onReset callback for retry logic — avoids writing class components yourself.
Without code splitting, your entire app ships in one JS bundle. Users on the login page download code for the admin dashboard they'll never see. React.lazy + dynamic import() splits the bundle — each lazy component becomes a separate chunk downloaded only when needed.
import { lazy, Suspense } from 'react'; // lazy() takes a function returning a dynamic import // The component is loaded only when first rendered const AdminDashboard = lazy(() => import('./AdminDashboard')); const UserProfile = lazy(() => import('./UserProfile')); const Analytics = lazy(() => import('./Analytics')); function App() { return ( // Suspense catches lazy components "suspending" and shows fallback <Suspense fallback={<PageSpinner />}> <Routes> <Route path="/admin" element={<AdminDashboard />} /> <Route path="/profile" element={<UserProfile />} /> <Route path="/analytics" element={<Analytics />} /> </Routes> </Suspense> ); } // Nested Suspense — granular loading states <Suspense fallback={<LayoutSkeleton />}> // outer: layout loading <Layout> <Suspense fallback={<ChartSkeleton />}> // inner: chart loading <HeavyChart /> </Suspense> </Layout> </Suspense> // Combine with Error Boundary for robust async UI <ErrorBoundary fallback={<ErrorPage />}> <Suspense fallback={<Spinner />}> <LazyPage /> </Suspense> </ErrorBoundary>
lazy(() => import('./Comp')) expects the module to have a default export. For named exports: lazy(() => import('./Comp').then(m => ({ default: m.NamedComp })))
Combining useReducer with Context gives you a scalable state management pattern without any external library. The reducer handles state transitions; the context broadcasts state + dispatch to the whole tree.
// ── store.js ── const StateCtx = createContext(null); const DispatchCtx = createContext(null); // separate ctx — dispatch is always stable const initial = { user: null, cart: [], notifications: [] }; function appReducer(state, action) { switch (action.type) { case 'LOGIN': return { ...state, user: action.user }; case 'LOGOUT': return { ...state, user: null, cart: [] }; case 'ADD_TO_CART': return { ...state, cart: [...state.cart, action.item] }; default: return state; } } export function AppProvider({ children }) { const [state, dispatch] = useReducer(appReducer, initial); return ( <DispatchCtx.Provider value={dispatch}> <StateCtx.Provider value={state}> {children} </StateCtx.Provider> </DispatchCtx.Provider> ); } // Separate hooks — consumers only re-render when their slice changes export const useAppState = () => useContext(StateCtx); export const useAppDispatch = () => useContext(DispatchCtx); // ── Usage in any component ── function AddToCartButton({ item }) { const dispatch = useAppDispatch(); // stable — never re-renders from state changes return ( <button onClick={() => dispatch({ type: 'ADD_TO_CART', item })}> Add to Cart </button> ); }
dispatch from useReducer is always the same function reference — it never changes. By putting it in a separate context, components that only dispatch actions (buttons, forms) never re-render when state changes. They only subscribed to DispatchCtx which never changes value.
// ── Rule 1: Different type = full rebuild ── // Before: <Counter /> After: <p>...</p> // React unmounts Counter (destroys state), mounts <p> // This is why conditionally swapping components resets state! function App({ isAdmin }) { return isAdmin ? <AdminPanel /> // toggling between these: full unmount/remount each time : <UserPanel />; // state inside AdminPanel is destroyed when switching } // ── Rule 2: Same type, same position = update in place ── // Both are <Counter /> — React reuses the instance, updates props function App({ isPremium }) { return isPremium ? <Counter color="gold" /> // same position, same type : <Counter color="grey" />; // → state PRESERVED, only color prop changes } // ── Trick: reset state by changing key ── // React treats different key = different component = full remount <ProfileForm key={userId} /> // When userId changes, ProfileForm fully remounts — state is fresh ✅ // This replaces the anti-pattern of getDerivedStateFromProps / useEffect sync // ── Position matters, not just type ── function App({ showBanner }) { return ( <div> {showBanner && <Banner />} // position 0 — conditionally rendered <Counter /> // when Banner appears, Counter shifts from pos 0 to 1 </div> // → Counter remounts! State lost. Use key to stabilise. ); }
The key prop isn't only for lists. You can put it on any component to tell React "treat this as a completely new component when the key changes." This is the correct way to reset a component's internal state when a prop changes — far cleaner than useEffect + setState or getDerivedStateFromProps.
Example: <CommentBox key={postId} /> — when the user navigates to a new post, the comment box is brand new with empty state, no manual clearing needed.
The #1 performance mistake in React is optimising blindly. Adding useMemo and React.memo everywhere makes code harder to read and can even slow things down. The correct workflow is:
| # | Trigger | Example | Notes |
|---|---|---|---|
| 1 | State change | setState(newVal) | Only if new value !== old value (Object.is) |
| 2 | Parent re-render | Parent calls setState | Children re-render by default even if their props didn't change — use React.memo to opt out |
| 3 | Context value change | Provider's value prop changes | All consumers re-render regardless of which part of context they use |
| 4 | forceUpdate (class) | this.forceUpdate() | No functional equivalent — use setState with a counter as a workaround |
// Re-render = React calls your function again = cheap JS work // DOM update = React writes to real DOM = expensive // Even if a component re-renders 100 times, React only touches the DOM // for the parts that actually changed. So a "wasted" re-render that produces // the same JSX is cheap — React diffs it and does zero DOM work. // ⚠ Re-renders ARE a problem when: // 1. The component does expensive computation inside the render // 2. It causes a cascade of many child re-renders // 3. It creates new object references that invalidate downstream memos // ✅ Check if a re-render is wasted: // React DevTools → Profiler → check "Highlight updates when components render" // Components that flash unexpectedly are candidates for React.memo // Opt out of parent-triggered re-render: const Child = React.memo(({ name }) => <p>{name}</p>); // Child only re-renders if name prop changes, even if parent re-renders constantly
useState call at App level can trigger hundreds of re-renders. This is why component structure matters — keep frequently changing state low in the tree, close to where it's used.
Where you put state determines which components re-render when it changes. State at the top re-renders the whole tree. State at the bottom re-renders only that leaf — and nothing else.
// ❌ State too high — search re-renders the whole app on every keystroke function App() { const [search, setSearch] = useState(''); // every keystroke re-renders App return ( <> <Header /> // ← re-renders unnecessarily <Sidebar /> // ← re-renders unnecessarily <SearchBox value={search} onChange={setSearch} /> <Results query={search} /> </> ); } // ✅ Push state down — only SearchBox + Results re-render function App() { return ( <> <Header /> // never re-renders from search <Sidebar /> // never re-renders from search <SearchSection /> // self-contained: owns search state internally </> ); } function SearchSection() { const [search, setSearch] = useState(''); // scoped here return ( <> <SearchBox value={search} onChange={setSearch} /> <Results query={search} /> </> ); } // ── Lifting content out of a re-rendering parent ── // "children as props" trick — children don't re-render with parent function AnimatedWrapper({ children }) { const [tick, setTick] = useState(0); // updates every frame return <div style={{ opacity: tick % 2 }}>{children}</div>; } // children are created in the PARENT of AnimatedWrapper // → children's identity is stable → they don't re-render with tick! <AnimatedWrapper> <ExpensiveTree /> // ✅ won't re-render on tick changes </AnimatedWrapper>
The "children as props" trick is a lesser-known gem that interviewers love. Explain: children are created by the parent of the wrapper, so when the wrapper re-renders, the children JSX elements aren't recreated — their reference is stable, so React skips them.
import { Profiler } from 'react'; function onRender( id, // "id" prop of the Profiler phase, // "mount" | "update" | "nested-update" actualDuration, // time spent rendering baseDuration, // estimated time without memoisation startTime, commitTime ) { if (actualDuration > 16) { // 16ms = 1 frame at 60fps console.warn(`${id} took ${actualDuration.toFixed(1)}ms`); } } <Profiler id="ProductList" onRender={onRender}> <ProductList items={items} /> </Profiler> // Profiler is stripped from production builds automatically
// ── Parent component with frequent state changes ── function Dashboard() { const [filter, setFilter] = useState(''); // changes on every keystroke const [sortBy, setSortBy] = useState('name'); // changes rarely const rawData = useData(); // large dataset, changes rarely // 1. useMemo — expensive computation, skip when filter/sortBy/data unchanged const processedRows = useMemo(() => { return rawData .filter(row => row.name.toLowerCase().includes(filter)) .sort((a, b) => a[sortBy] > b[sortBy] ? 1 : -1); }, [rawData, filter, sortBy]); // 2. useCallback — stable function ref so DataGrid (memoised) doesn't re-render const handleRowClick = useCallback((rowId) => { navigate(`/detail/${rowId}`); }, [navigate]); // navigate is stable from react-router const handleSort = useCallback((col) => { setSortBy(col); }, []); // no deps — setSortBy is always stable return ( <> <input value={filter} onChange={e => setFilter(e.target.value)} /> {/* 3. React.memo — DataGrid skips render when rows/handlers didn't change */} <DataGrid rows={processedRows} // stable via useMemo onRowClick={handleRowClick} // stable via useCallback onSort={handleSort} // stable via useCallback /> </> ); } // DataGrid is wrapped in React.memo — only re-renders when its props change const DataGrid = React.memo(function DataGrid({ rows, onRowClick, onSort }) { console.log('DataGrid rendered'); // now only logs when rows/handlers change return (/* grid JSX */); });
DataGrid is memoised but one of its props is an unstabilised inline function or object, all three tools are wasted. Audit every prop passed to a memoised component: primitives ✅, stable refs via useCallback/useMemo ✅, inline {} or () => {} ❌.
Every kilobyte of JavaScript must be downloaded, parsed, and compiled before your app becomes interactive. On a mid-range mobile device on 3G, 1MB of JS ≈ 10 seconds to interactive. Code splitting defers loading code until it's actually needed.
// Each route loads its own chunk — user on /login never downloads /admin code const Home = lazy(() => import('./pages/Home')); const Dashboard = lazy(() => import('./pages/Dashboard')); const Admin = lazy(() => import('./pages/Admin')); const Settings = lazy(() => import('./pages/Settings')); <Suspense fallback={<PageLoader />}> <Routes> <Route path="/" element={<Home />} /> <Route path="/dashboard" element={<Dashboard />} /> <Route path="/admin" element={<Admin />} /> </Routes> </Suspense>
// Split heavy components that aren't needed immediately const RichTextEditor = lazy(() => import('./RichTextEditor')); const HeavyChart = lazy(() => import('./HeavyChart')); const PDFViewer = lazy(() => import('./PDFViewer')); // Only loads RichTextEditor bundle when user opens the editor {isEditorOpen && ( <Suspense fallback={<EditorSkeleton />}> <RichTextEditor /> </Suspense> )} // Preload on hover for near-instant feel const preloadEditor = () => import('./RichTextEditor'); // starts download early <button onMouseEnter={preloadEditor} // preload on hover onClick={() => setEditorOpen(true)} > Open Editor </button>
// vite.config.js — split vendor libraries into separate cached chunks export default { build: { rollupOptions: { output: { manualChunks(id) { if (id.includes('node_modules')) { if (id.includes('react')) return 'vendor-react'; if (id.includes('chart.js')) return 'vendor-charts'; if (id.includes('lodash')) return 'vendor-utils'; } } } } } }; // React chunks are cached by browser — only app code re-downloads on deploy
// ── 1. Lazy load below-the-fold images ── <img src="/product.jpg" loading="lazy" // native browser lazy loading decoding="async" // decode off main thread alt="Product name" /> // ── 2. Responsive images with srcSet ── <img src="/hero-800.webp" srcSet="/hero-400.webp 400w, /hero-800.webp 800w, /hero-1200.webp 1200w" sizes="(max-width: 600px) 400px, (max-width: 1024px) 800px, 1200px" alt="Hero" /> // ── 3. Intersection Observer for custom lazy loading ── function useLazyImage(src) { const imgRef = useRef(null); const [loaded, setLoaded] = useState(false); useEffect(() => { const observer = new IntersectionObserver(([entry]) => { if (entry.isIntersecting) { setLoaded(true); observer.disconnect(); } }); if (imgRef.current) observer.observe(imgRef.current); return () => observer.disconnect(); }, []); return { imgRef, src: loaded ? src : undefined }; }
// Skeleton matches the shape of the content — reduces layout shift function UserCardSkeleton() { return ( <div className="card"> <div className="skeleton circle" style={{ width: 48, height: 48 }} /> <div style={{ flex: 1 }}> <div className="skeleton" style={{ width: '60%', height: 16 }} /> <div className="skeleton" style={{ width: '40%', height: 12, marginTop: 8 }} /> </div> </div> ); } // CSS for the shimmer animation // .skeleton { background: linear-gradient(90deg, #eee 25%, #f5f5f5 50%, #eee 75%); // background-size: 200% 100%; animation: shimmer 1.5s infinite; } function UserCard({ userId }) { const { data, loading } = useFetch(`/api/users/${userId}`); if (loading) return <UserCardSkeleton />; // shows while loading return <div className="card">{data.name}</div>; }
Rendering 10,000 rows means 10,000 DOM nodes — all in memory, all painted, all styled. Even if only 15 are visible. This causes slow initial render, high memory usage, and janky scrolling.
A cinema projector doesn't store every frame of a 2-hour film in the projector at once. It only loads the current frame plus a few ahead. Virtualisation does the same — only render the rows currently in the viewport, plus a small buffer. As the user scrolls, swap old rows out and new rows in.
// ── How it works ── // 1. Container has fixed height + overflow: scroll // 2. Inner div has total height = rowHeight × rowCount (creates scroll space) // 3. On scroll, calculate which rows are visible: start = scrollTop / rowHeight // 4. Only render those rows + overscan buffer, positioned absolutely // ── react-window (lightweight, most popular) ── import { FixedSizeList } from 'react-window'; const Row = ({ index, style }) => ( <div style={style}> // style from react-window — MUST be applied for correct positioning Row {index}: {data[index].name} </div> ); <FixedSizeList height={600} // container height in px width="100%" itemCount={10000} // total rows itemSize={50} // each row height in px overscanCount={5} // extra rows above/below viewport > {Row} </FixedSizeList> // Renders ~15 DOM nodes regardless of 10,000 items ✅ // ── @tanstack/react-virtual (headless, more flexible) ── const rowVirtualizer = useVirtualizer({ count: rows.length, getScrollElement: () => containerRef.current, estimateSize: () => 50, overscan: 5 }); // Gives you virtualItems array — you control the DOM structure completely
| List size | Recommendation |
|---|---|
| < 50 items | Don't bother — standard rendering is fine |
| 50–200 items | Only if each row is complex/heavy — profile first |
| 200–1000 items | Strongly consider virtualising |
| > 1000 items | Always virtualise |
The browser's main thread handles JavaScript execution, React rendering, layout, and painting — all on one thread. A computation that blocks for 200ms freezes the UI: no animations, no input response, no scroll. Moving heavy work to a Web Worker runs it in a background thread so the UI stays responsive.
// ── worker.js ── self.onmessage = (e) => { const { data } = e.data; const result = heavyComputation(data); // runs in background self.postMessage({ result }); }; // ── useWorker custom hook ── function useWorker(workerUrl) { const workerRef = useRef(null); useEffect(() => { workerRef.current = new Worker(new URL(workerUrl, import.meta.url)); return () => workerRef.current.terminate(); }, [workerUrl]); const postMessage = useCallback((msg) => { return new Promise((resolve) => { workerRef.current.onmessage = (e) => resolve(e.data); workerRef.current.postMessage(msg); }); }, []); return { postMessage }; } // ── Component ── function DataProcessor() { const [result, setResult] = useState(null); const [loading, setLoading] = useState(false); const { postMessage } = useWorker('./worker.js'); const process = async () => { setLoading(true); const { result } = await postMessage({ data: bigDataset }); // UI stayed responsive during computation! setResult(result); setLoading(false); }; return ( <> <button onClick={process} disabled={loading}> {loading ? 'Processing...' : 'Process Data'} </button> {result && <Results data={result} />} </> ); }
A search input that filters 10,000 items: every keystroke triggers an expensive re-render of the list. The input feels laggy because React is busy re-rendering the list before it can update the input field. React 18 lets you mark the list update as non-urgent — input stays snappy, list updates when there's spare time.
import { startTransition, useTransition } from 'react'; function SearchPage() { const [input, setInput] = useState(''); const [results, setResults] = useState([]); const [isPending, startTransition] = useTransition(); const handleChange = (e) => { const val = e.target.value; // Urgent — update input immediately (high priority) setInput(val); // Non-urgent — defer list update (can be interrupted) startTransition(() => { setResults(filterItems(val)); }); }; return ( <> <input value={input} onChange={handleChange} /> {/* Show stale results with opacity while new results compute */} <div style={{ opacity: isPending ? 0.5 : 1 }}> {results.map(r => <ResultRow key={r.id} item={r} />)} </div> </> ); }
import { useDeferredValue } from 'react'; function SearchPage({ query }) { // query comes from parent (prop) // Deferred copy — lags behind query intentionally const deferredQuery = useDeferredValue(query); // isStale = true while deferred lags behind const isStale = query !== deferredQuery; return ( <div style={{ opacity: isStale ? 0.6 : 1 }}> {/* ExpensiveList re-renders with deferredQuery, not the latest query */} <ExpensiveList query={deferredQuery} /> </div> ); } // ── Key difference ── // startTransition: you control WHEN the update fires (wrap the setter) // useDeferredValue: you control WHAT value the component sees (wrap the value) // Use startTransition when you own the state setter // Use useDeferredValue when you receive the value via props
| Metric | Measures | Good threshold | React impact |
|---|---|---|---|
| LCP Largest Contentful Paint | How fast the main content loads | < 2.5s | Large JS bundles delay LCP. Fix: code splitting, SSR/SSG, preloading critical resources |
| INP Interaction to Next Paint | Responsiveness to clicks/taps | < 200ms | Long render cycles block INP. Fix: startTransition, virtualisation, debouncing, Web Workers |
| CLS Cumulative Layout Shift | Visual stability — elements jumping around | < 0.1 | Dynamic content without reserved space. Fix: skeleton screens, fixed dimensions on images/iframes |
// ── web-vitals library (from Google) ── import { onLCP, onINP, onCLS, onFCP, onTTFB } from 'web-vitals'; function sendToAnalytics({ name, value, rating, id }) { fetch('/analytics', { method: 'POST', body: JSON.stringify({ name, value, rating, id }) }); } onLCP(sendToAnalytics); onINP(sendToAnalytics); onCLS(sendToAnalytics); // ── reportWebVitals — generated by CRA/Vite ── // index.js: reportWebVitals(console.log); // logs metrics to console during dev // ── Performance.mark for custom timing ── performance.mark('fetch-start'); await fetchData(); performance.mark('fetch-end'); performance.measure('fetch-duration', 'fetch-start', 'fetch-end'); // Visible in DevTools → Performance → Timings lane
Connect React decisions to real metrics: "I use code splitting to improve LCP because it reduces the main-thread blocking time before the browser can paint the largest element. For INP, I use startTransition to keep input handlers fast while deferring expensive list re-renders." This shows you think in user-visible outcomes, not just React internals.
| Scenario | Best choice | Why |
|---|---|---|
| 1–2 levels deep | Props | Simplest, explicit data flow, easy to trace |
| 3+ levels, low-change data | Context | Auth, theme, locale — read often, update rarely |
| 3+ levels, high-change data | External store (Zustand/Redux) | Context re-renders all consumers on every change |
| Server/async data (fetch results) | React Query / SWR | Built-in caching, background refresh, deduplication |
| Complex local state (wizard, cart) | useReducer + Context | Predictable transitions, single source of truth |
Prop drilling is explicit — you can trace data flow with grep. It becomes a problem when intermediary components receive props they don't use, only to pass them down. That's the signal to reach for Context or component composition.
// ❌ Prop drilling — Button doesn't care about user, just passes it down <App user={user} /> <Layout user={user} /> // Layout doesn't use user <Header user={user} /> // Header doesn't use user <Avatar user={user} /> // Finally used here // ✅ Composition — pass Avatar as a prop (children or named slot) function App() { return <Layout header={<Header right={<Avatar user={user} />} />} />; } // Layout and Header never see user — zero coupling // ✅ Context — for truly global data (theme, auth, i18n) const UserCtx = createContext(null); function App() { return ( <UserCtx.Provider value={user}> <Layout /> // No user prop needed anywhere </UserCtx.Provider> ); } function Avatar() { const user = useContext(UserCtx); // reads directly return <img src={user.avatar} />; }
React re-renders every component that calls useContext(Ctx) whenever the Provider's value prop changes reference. If you pass an object literal directly, it's a new reference on every parent render — so all consumers re-render even when data is identical.
// ❌ New object every render → all consumers re-render on every App render function App() { const [user, setUser] = useState(null); return ( <AuthCtx.Provider value={{ user, setUser }}> // ← new {} each render! <Router /> </AuthCtx.Provider> ); } // ✅ Fix 1: Memoize the value object function App() { const [user, setUser] = useState(null); const value = useMemo(() => ({ user, setUser }), [user]); // Now value only changes reference when user changes — not on every App render return <AuthCtx.Provider value={value}><Router /></AuthCtx.Provider>; }
// Split context into what changes (state) and what doesn't (dispatch) // dispatch from useReducer is always the same reference — safe to share standalone const CartStateCtx = createContext(null); // changes when cart updates const CartDispatchCtx = createContext(null); // stable — never changes function CartProvider({ children }) { const [state, dispatch] = useReducer(cartReducer, []); return ( <CartStateCtx.Provider value={state}> <CartDispatchCtx.Provider value={dispatch}> {children} </CartDispatchCtx.Provider> </CartStateCtx.Provider> ); } // AddToCartButton only reads dispatch → NEVER re-renders when cart state changes function AddToCartButton({ item }) { const dispatch = useContext(CartDispatchCtx); // stable ref return <button onClick={() => dispatch({ type: 'ADD', item })}>Add</button>; } // CartBadge reads state → re-renders only when cart changes function CartBadge() { const cart = useContext(CartStateCtx); return <span>{cart.length}</span>; }
// Instead of one giant AppContext, create narrow contexts const ThemeCtx = createContext('light'); // changes ~never const UserCtx = createContext(null); // changes on login/logout const NotifCtx = createContext([]); // changes frequently // Components subscribe only to what they need // A new notification won't re-render the entire app — only NotifCtx consumers // Wrap in a single AppProviders component to avoid nesting hell: function AppProviders({ children }) { return ( <ThemeCtx.Provider value={'dark'}> <UserProvider> <NotifProvider> {children} </NotifProvider> </UserProvider> </ThemeCtx.Provider> ); }
useState is like a variable — you set it directly. useReducer is like a traffic light controller — you send it an event ("NEXT_PHASE") and the controller decides the next state based on the current state. You never update state directly; you describe what happened and the reducer figures out what should be next.
| Use useState when… | Use useReducer when… |
|---|---|
| Simple scalar: boolean, string, number | Multiple related values that update together |
| Next state doesn't depend on current state | Next state depends on current state (toggle, counter) |
| One component owns the state | State is shared via Context across many components |
| Few transitions (2–3 ways to update) | Complex transitions with many action types |
| Simple logic | Logic needs to be tested in isolation (pure function) |
// State shape — all related fields together const initialState = { status: 'idle', // 'idle' | 'loading' | 'success' | 'error' data: null, error: null, }; // Reducer: pure function (state, action) → new state function fetchReducer(state, action) { switch (action.type) { case 'FETCH_START': return { ...state, status: 'loading', error: null }; case 'FETCH_SUCCESS': return { status: 'success', data: action.payload, error: null }; case 'FETCH_ERROR': return { status: 'error', data: null, error: action.payload }; default: return state; } } function UserProfile({ userId }) { const [state, dispatch] = useReducer(fetchReducer, initialState); useEffect(() => { dispatch({ type: 'FETCH_START' }); fetchUser(userId) .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data })) .catch(err => dispatch({ type: 'FETCH_ERROR', payload: err.message })); }, [userId]); if (state.status === 'loading') return <Spinner />; if (state.status === 'error') return <Error msg={state.error} />; if (state.status === 'success') return <Profile data={state.data} />; return <p>Ready</p>; }
loading: true, error: 'Something failed' simultaneously — a contradiction. With a reducer, the state machine only allows valid transitions. status: 'loading' means error is null, always.
Separate state and dispatch into two contexts. Components that only dispatch (buttons) subscribe to the stable dispatch context — they never re-render when cart items change. Components that read data (badge, total) subscribe to state context only.
import { createContext, useContext, useReducer, useMemo } from 'react'; // 1. Create separate contexts const CartStateCtx = createContext(null); const CartDispatchCtx = createContext(null); // 2. Initial state const initial = { items: [], coupon: null }; // 3. Pure reducer — easy to unit test function cartReducer(state, action) { switch (action.type) { case 'ADD': { const existing = state.items.find(i => i.id === action.item.id); if (existing) { return { ...state, items: state.items.map(i => i.id === action.item.id ? { ...i, qty: i.qty + 1 } : i ), }; } return { ...state, items: [...state.items, { ...action.item, qty: 1 }] }; } case 'REMOVE': return { ...state, items: state.items.filter(i => i.id !== action.id) }; case 'CLEAR': return initial; case 'APPLY_COUPON': return { ...state, coupon: action.code }; default: return state; } } // 4. Provider — dispatch is always stable (no useMemo needed for it) export function CartProvider({ children }) { const [state, dispatch] = useReducer(cartReducer, initial); // Memoize state to avoid inline object creation const cartState = useMemo(() => state, [state]); return ( <CartStateCtx.Provider value={cartState}> <CartDispatchCtx.Provider value={dispatch}> {children} </CartDispatchCtx.Provider> </CartStateCtx.Provider> ); } // 5. Custom hooks — throw if used outside provider export function useCartState() { const ctx = useContext(CartStateCtx); if (!ctx) throw new Error('useCartState must be inside CartProvider'); return ctx; } export function useCartDispatch() { const ctx = useContext(CartDispatchCtx); if (!ctx) throw new Error('useCartDispatch must be inside CartProvider'); return ctx; } // 6. Selector hook — derived state computed close to the consumer export function useCartTotal() { const { items } = useCartState(); return useMemo( () => items.reduce((sum, i) => sum + i.price * i.qty, 0), [items] ); } // 7. Usage: components pick what they need function CartBadge() { const { items } = useCartState(); // re-renders on cart change return <span>{items.length}</span>; } function AddButton({ item }) { const dispatch = useCartDispatch(); // NEVER re-renders from cart state return ( <button onClick={() => dispatch({ type: 'ADD', item })}>Add</button> ); }
dispatch function returned by useReducer has a stable reference across renders — it never changes. This makes it safe to put in a separate context without memoization, and safe to use in useEffect dependency arrays without including it.
Redux solves three problems: (1) a predictable single source of truth for application state, (2) time-travel debugging via action history, (3) state sharing across distant component trees without prop drilling. The pattern — store, actions, reducers — makes state transitions explicit and traceable.
| Vanilla Redux problem | RTK solution |
|---|---|
| Boilerplate: action types + action creators + reducer = 3 files for one feature | createSlice generates all three from one object |
| Immutability: must spread everything manually | Immer built in — write mutating code, RTK converts to immutable |
| Async: need redux-thunk + custom middleware setup | createAsyncThunk + auto pending/fulfilled/rejected actions |
| Store setup: middleware, devtools, enhancers | configureStore sets up everything with sensible defaults |
| Selector memoization: need reselect separately | RTK Query includes built-in normalized cache; can use built-in createSelector |
| useReducer + Context | Redux Toolkit | |
|---|---|---|
| Bundle size | Zero (built-in) | ~11kb (redux + RTK) |
| DevTools | None built-in | Excellent time-travel debugging |
| Async | DIY (useEffect + dispatch) | createAsyncThunk + RTK Query |
| Middleware | Not supported | First-class (logging, analytics) |
| Scale | Good for small-medium apps | Proven at large enterprise scale |
| Learning curve | Low | Medium (concepts + RTK API) |
import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; // Async thunk — handles pending/fulfilled/rejected automatically export const fetchProducts = createAsyncThunk( 'cart/fetchProducts', // action type prefix async (categoryId, { rejectWithValue }) => { try { const res = await fetch(`/api/products?cat=${categoryId}`); return res.json(); } catch (err) { return rejectWithValue(err.message); } } ); const cartSlice = createSlice({ name: 'cart', initialState: { items: [], products: [], loading: false, error: null, }, reducers: { // Immer lets you write "mutating" code — RTK converts to immutable updates addItem(state, action) { const existing = state.items.find(i => i.id === action.payload.id); if (existing) { existing.qty++; // direct mutation — Immer handles immutability } else { state.items.push({ ...action.payload, qty: 1 }); } }, removeItem(state, action) { state.items = state.items.filter(i => i.id !== action.payload); }, clearCart(state) { state.items = []; }, }, extraReducers(builder) { // Handles the 3 auto-generated actions from createAsyncThunk builder .addCase(fetchProducts.pending, (state) => { state.loading = true; state.error = null; }) .addCase(fetchProducts.fulfilled, (state, action) => { state.loading = false; state.products = action.payload; }) .addCase(fetchProducts.rejected, (state, action) => { state.loading = false; state.error = action.payload; }); }, }); export const { addItem, removeItem, clearCart } = cartSlice.actions; export default cartSlice.reducer; // Selectors — co-located with the slice export const selectItems = (state) => state.cart.items; export const selectTotal = (state) => state.cart.items.reduce((s, i) => s + i.price * i.qty, 0);
import { configureStore } from '@reduxjs/toolkit'; import cartReducer from '../features/cart/cartSlice'; export const store = configureStore({ reducer: { cart: cartReducer, // add other slices here }, // configureStore auto-adds redux-thunk middleware + devtools }); // TypeScript: export types derived from store export type RootState = ReturnType<typeof store.getState>; export type AppDispatch = typeof store.dispatch;
import { useSelector, useDispatch } from 'react-redux'; import { addItem, selectItems, fetchProducts } from './cartSlice'; function CartBadge() { // useSelector subscribes to only this slice of state const items = useSelector(selectItems); return <span>{items.length}</span>; } function ProductCard({ product }) { const dispatch = useDispatch(); return ( <button onClick={() => dispatch(addItem(product))}>Add</button> ); } function ProductList() { const dispatch = useDispatch(); useEffect(() => { dispatch(fetchProducts('electronics')); // fires the async thunk }, [dispatch]); // ... }
Zustand (German for "state") wraps a vanilla JS store in a React hook. No Provider. No action types. No reducers. You define state and updaters together in one create() call and subscribe to exactly the parts you need via a selector — so components only re-render when the slices they pick change.
| Context | Redux Toolkit | Zustand | |
|---|---|---|---|
| Boilerplate | Low | Medium | Minimal |
| Provider needed | Yes | Yes (<Provider>) | No |
| Re-render control | All consumers re-render | Selector-based | Selector-based |
| DevTools | None | Excellent | Good (via middleware) |
| Bundle | Zero | ~11kb | ~1kb |
| Best for | Infrequent global data | Large apps, teams | Simple–medium apps |
import { create } from 'zustand'; const useCartStore = create((set, get) => ({ items: [], addItem: (item) => set((state) => { const exists = state.items.find(i => i.id === item.id); return { items: exists ? state.items.map(i => i.id === item.id ? { ...i, qty: i.qty+1 } : i) : [...state.items, { ...item, qty: 1 }], }; }), removeItem: (id) => set((state) => ({ items: state.items.filter(i => i.id !== id), })), clearCart: () => set({ items: [] }), // Derived value as a getter — computed on every call getTotal: () => get().items.reduce((s, i) => s + i.price * i.qty, 0), })); // Usage — subscribe to a slice with a selector // Component only re-renders when items.length changes, not on every state update function CartBadge() { const count = useCartStore((s) => s.items.length); return <span>{count}</span>; } function AddButton({ item }) { const addItem = useCartStore((s) => s.addItem); // Actions are stable — no re-renders from state changes return <button onClick={() => addItem(item)}>Add</button>; } // Middleware: persist to localStorage import { persist } from 'zustand/middleware'; const useCartStore = create(persist( (set) => ({ /* ... store definition ... */ }), { name: 'cart-storage' } // localStorage key ));
useCartStore.getState().addItem(item) and useCartStore.subscribe(state => console.log(state)).
| Client State | Server State | |
|---|---|---|
| Owner | Your app | The server / database |
| Source of truth | In-memory | Remote — can be stale at any time |
| Examples | Modal open, form input, theme, selected tab | User profile, product list, order history |
| Challenges | Sharing, avoiding prop drilling | Caching, invalidation, background refresh, deduplication, loading/error states |
| Best tool | useState, useReducer, Zustand, RTK | React Query (TanStack), SWR, RTK Query |
// Without React Query — you own all this complexity: function ProductList() { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { const controller = new AbortController(); fetch('/api/products', { signal: controller.signal }) .then(r => r.json()) .then(setData) .catch(e => { if (!e.name === 'AbortError') setError(e); }) .finally(() => setLoading(false)); return () => controller.abort(); }, []); // No caching — every mount re-fetches from scratch // No background refresh — stale data while user is on page // No deduplication — two components fetch same endpoint twice // No optimistic updates — complex to implement manually }
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; // Reading data function ProductList() { const { data, isLoading, error } = useQuery({ queryKey: ['products'], // cache key — same key = same cache entry queryFn: () => fetch('/api/products').then(r => r.json()), staleTime: 5 * 60 * 1000, // cached for 5 min — won't refetch on remount }); if (isLoading) return <Spinner />; if (error) return <Error />; return <ul>{data.map(p => <li key={p.id}>{p.name}</li>)}</ul>; } // Writing data (mutations) function AddProductForm() { const queryClient = useQueryClient(); const mutation = useMutation({ mutationFn: (newProduct) => fetch('/api/products', { method: 'POST', body: JSON.stringify(newProduct), }), onSuccess: () => { // Invalidate cache → triggers background refetch queryClient.invalidateQueries({ queryKey: ['products'] }); }, }); // ... } // React Query handles: caching, deduplication, background refresh, // refetch on window focus, retry on error, request cancellation
If a value can be calculated from existing state or props, it's derived state — you should not store it separately. Storing derived state in its own useState creates a synchronization problem: the copy and the source can get out of sync, leading to bugs and extra re-renders.
// ❌ Stale copy — if userProp changes, localUser won't update! function UserCard({ user }) { const [localUser, setLocalUser] = useState(user); // ← mirror return <p>{localUser.name}</p>; // If parent passes new user, this still shows the old one } // ✅ Just use the prop directly function UserCard({ user }) { return <p>{user.name}</p>; }
// Scenario: filtered + sorted product list // Option 1: derive during render (fine for cheap computations) function ProductList({ products, filter }) { const visible = products.filter(p => p.category === filter); // inline return visible.map(p => <Product key={p.id} item={p} />); } // Option 2: useMemo (for expensive computation + stable reference) function ProductList({ products, filter, sort }) { const processed = useMemo(() => [...products] .filter(p => p.category === filter) .sort((a, b) => a.price - b.price), [products, filter] // only recompute if these change ); return processed.map(p => <Product key={p.id} item={p} />); } // Option 3: selector in Redux/Zustand (memoized, reusable across components) import { createSelector } from 'reselect'; const selectVisibleProducts = createSelector( [(state) => state.products.all, (state) => state.products.filter], (products, filter) => products.filter(p => p.category === filter) // memoized — returns cached result until products or filter change );
// ❌ Syncing derived state with useEffect — two renders, risk of loops const [filtered, setFiltered] = useState([]); useEffect(() => { setFiltered(products.filter(p => p.active)); }, [products]); // extra render every time products changes // ✅ Derive inline — single render, no extra state const filtered = products.filter(p => p.active); // during render
// Ask: "Who owns this data?" // 🖥 Server state — lives on a server, fetched asynchronously // → React Query / TanStack Query / SWR / RTK Query // Examples: user list, products, orders, comments // 🌐 Global client state — shared across many components // → Zustand / Redux Toolkit / Context // Examples: auth user, theme, shopping cart, language // 🧩 Feature/module state — shared within a feature subtree // → useReducer + Context (scoped Provider) // Examples: multi-step wizard state, data table (sort+filter+page) // 🏠 Local component state — owned by one component // → useState / useReducer // Examples: form field, modal open, hover state, toggle // 📌 URL state — shareable, bookmarkable, browser-back-compatible // → URL search params (useSearchParams in React Router v6) // Examples: search query, filters, pagination, selected tab
| Question | Yes → use | No → continue |
|---|---|---|
| Is it fetched from an API? | React Query / SWR | ↓ |
| Should it be bookmarkable / in URL? | URLSearchParams | ↓ |
| Is it used by only one component? | useState / useReducer | ↓ |
| Is it used within one feature subtree? | useReducer + Context (scoped) | ↓ |
| Is it global but changes rarely? | Context + useMemo | ↓ |
| Is it global and changes often? | Zustand (small) / RTK (large) | ↓ |
| Needs time-travel debugging / middleware? | Redux Toolkit | Zustand |
// Recommended stack — each layer has a clear job // Layer 1: Server state → TanStack Query const { data: user } = useQuery({ queryKey: ['user'], queryFn: fetchUser }); // Layer 2: Global UI state → Zustand (or RTK for large teams) const theme = useThemeStore((s) => s.theme); const isOpen = useModalStore((s) => s.isOpen); // Layer 3: Feature state → useReducer + Context (scoped Provider) const wizardState = useWizardState(); // custom hook over scoped context // Layer 4: Local state → useState const [isEditing, setIsEditing] = useState(false); // Layer 5: URL state → useSearchParams (React Router) const [searchParams, setSearchParams] = useSearchParams(); const query = searchParams.get('q') ?? '';
Before React 18, rendering was like a phone call you couldn't interrupt — once React started rendering a tree it would block the main thread until done. Concurrent React is like a phone with call-waiting: React can start rendering, pause when something more urgent arrives (a user click, an animation frame), handle the urgent work, then resume.
| Legacy (synchronous) | Concurrent (React 18) | |
|---|---|---|
| Rendering | Blocking — can't be interrupted | Interruptible — can pause and resume |
| Work priority | All updates have equal priority | Urgent vs non-urgent lanes (scheduler) |
| UI during long render | Freezes / janky | Stays responsive — urgent work runs first |
| Opt-in? | Default in React <18 | Opt-in via createRoot — legacy root still works |
// React 17 — legacy blocking mode ReactDOM.render(<App />, document.getElementById('root')); // React 18 — concurrent mode (createRoot) import { createRoot } from 'react-dom/client'; createRoot(document.getElementById('root')).render(<App />); // All concurrent features (useTransition, useDeferredValue, Suspense for data) // are unlocked by createRoot — they're no-ops on legacy ReactDOM.render
// React 18 scheduler uses "lanes" — a bitmask system for priority // Priority order (highest → lowest): // 1. SyncLane — discrete user input (click, keypress, focus) // 2. InputContinuous — continuous input (drag, scroll, wheel) // 3. DefaultLane — data fetching, setTimeout callbacks // 4. TransitionLane — startTransition / useTransition (your hint) // 5. IdleLane — prefetching, background work // Example: user types in a search box while a list is rendering // Without concurrent: list render blocks the keystroke → UI freezes // With concurrent + startTransition: function Search() { const [input, setInput] = useState(''); const [query, setQuery] = useState(''); const [isPending, startTransition] = useTransition(); function handleChange(e) { setInput(e.target.value); // Sync — input updates immediately startTransition(() => { setQuery(e.target.value); // Transition — can be interrupted // If user types again before list re-render finishes, // React abandons this transition and starts fresh }); } return ( <> <input value={input} onChange={handleChange} /> {isPending && <Spinner />} <ResultsList query={query} /> // expensive — renders thousands of items </> ); }
| isLoading pattern | Suspense | |
|---|---|---|
| Where loading lives | Inside the component (if isLoading…) | Outside — in the component tree (boundary) |
| Coordination | Each component handles its own spinner | Parent boundary shows one fallback for all children |
| Code complexity | Every component has loading/error state | Component assumes data is ready — clean |
| Works with | Any async pattern | Must use Suspense-compatible libraries (React Query, Next.js, use()) |
When a component needs data that isn't ready, it throws a Promise. React catches it, renders the nearest <Suspense fallback>, and when the Promise resolves, re-renders the component. You never write the throw — the data library does it for you.
// Pattern 1: React Query with suspense: true function UserProfile() { // No isLoading check — component only renders when data is ready const { data: user } = useSuspenseQuery({ queryKey: ['user'], queryFn: fetchUser, }); return <h1>{user.name}</h1>; // data is always defined here } // Pattern 2: React 19 use() hook — reads a promise or context function UserProfile({ userPromise }) { const user = use(userPromise); // suspends until promise resolves return <h1>{user.name}</h1>; } // Parent — wraps with Suspense + ErrorBoundary function App() { return ( <ErrorBoundary fallback={<p>Error loading user</p>}> <Suspense fallback={<Spinner />}> <UserProfile /> // shows Spinner until ready <UserPosts /> // both suspend → one spinner for both </Suspense> </ErrorBoundary> ); } // Nested Suspense — waterfall vs parallel function Dashboard() { return ( <Suspense fallback={<PageSkeleton />}> // outer: page-level <Header /> <Suspense fallback={<CardSkeleton />}> // inner: card-level <MetricsCard /> </Suspense> </Suspense> ); // Header resolves fast → shows immediately // MetricsCard still loading → shows CardSkeleton, not PageSkeleton }
startTransition so React keeps showing the current page until the next page's data is ready, rather than flashing a loading skeleton on every navigation. This is the "content-first" pattern used by Next.js App Router.
| SSR (Server-Side Rendering) | React Server Components | |
|---|---|---|
| What runs on server? | Everything (full render to HTML) | Only Server Components |
| Client hydration? | Yes — full JS bundle re-runs | Only for Client Components ("use client") |
| JS sent to browser? | Full component code | Server Component code is never sent |
| Can access DB/FS? | Only in getServerSideProps (Next.js) | Yes — directly in the component |
| State / hooks? | After hydration on client | Never — Server Components have no state |
| Re-renders? | Client re-renders after hydration | Server re-renders on server — no client update |
// ✅ Server Component CAN do: async function ProductPage({ id }) { // 1. Direct DB access — no API layer needed const product = await db.product.findById(id); // 2. Read filesystem const readme = await fs.readFile('./docs.md', 'utf8'); // 3. Access secrets (env vars) — never exposed to client const price = await stripe.getPrice(product.priceId); // 4. Import heavy libs — NOT sent to browser bundle const { marked } = await import('marked'); // stays on server return ( <div> <h1>{product.name}</h1> <AddToCartButton id={product.id} /> {/* Client Component */} </div> ); } // ❌ Server Component CANNOT do: async function Bad() { useState(0); // ❌ no hooks useEffect(() => {}); // ❌ no effects onClick = () => {}; // ❌ no event handlers return <button onClick={fn}>Click</button>; // ❌ event handlers need client }
'use client'; // This file and everything it imports runs on the client // AddToCartButton.jsx — needs onClick state → must be Client Component export function AddToCartButton({ id }) { const [added, setAdded] = useState(false); return ( <button onClick={() => { addToCart(id); setAdded(true); }}> {added ? 'Added!' : 'Add to Cart'} </button> ); } // ✅ A Server Component CAN render a Client Component: // ProductPage (Server) → renders AddToCartButton (Client) ✅ // ❌ A Client Component CANNOT import a Server Component: // 'use client' file that imports a file doing DB queries → error // ✅ But a Client Component CAN receive Server Components as children: function ClientShell({ children }) { // 'use client' return <div className={theme}>{children}</div>; } // In a Server Component: <ClientShell> <ServerFetchedContent /> {/* passes as children — allowed! */} </ClientShell>
'use client' only when you need interactivity (state, effects, browser APIs, event handlers). Push the boundary down as far as possible — keep expensive data-fetching on the server, isolate interactivity in small leaf components.
Server Actions are async functions marked with 'use server' that run on the server even when called from a Client Component. They replace the need for a separate API route for form submissions and mutations — the function is the endpoint.
// app/actions.ts — Server Action (can access DB directly) 'use server'; export async function createPost(formData: FormData) { const title = formData.get('title') as string; const content = formData.get('content') as string; if (!title) return { error: 'Title is required' }; await db.post.create({ data: { title, content } }); revalidatePath('/posts'); // invalidate Next.js cache for /posts redirect('/posts'); // redirect after success } // Option 1: Use in a Server Component form (no JS required — progressive enhancement) export default function NewPostPage() { return ( <form action={createPost}> {/* action prop accepts Server Action */} <input name="title" /> <textarea name="content" /> <button>Publish</button> </form> ); } // Option 2: In a Client Component with useActionState (React 19) 'use client'; import { useActionState } from 'react'; function PostForm() { const [state, formAction, isPending] = useActionState(createPost, null); return ( <form action={formAction}> {state?.error && <p className="error">{state.error}</p>} <input name="title" /> <button disabled={isPending}> {isPending ? 'Publishing…' : 'Publish'} </button> </form> ); }
action attribute works without JavaScript (progressive enhancement), making apps resilient and accessible.
| Feature | v5 | v6 |
|---|---|---|
| Route nesting | Manually compose <Route> everywhere | Declarative nested routes + <Outlet /> |
| Navigation | useHistory().push() | useNavigate()('/path') |
| Route params | useParams() (same) | useParams() (same) |
| Data loading | Component did own fetching | loader function (parallel, route-level) |
| Matching | First-match wins | Best-match wins (no order dependency) |
| Relative links | Broken in v5 | Fixed — relative to current route |
import { createBrowserRouter, RouterProvider, Outlet, useNavigate, useParams, useLoaderData } from 'react-router-dom'; // Route config — tree mirrors URL structure const router = createBrowserRouter([ { path: '/', element: <RootLayout />, // renders Outlet for child routes errorElement: <ErrorPage />, // catches loader/render errors children: [ { index: true, element: <Home /> }, // matches "/" { path: 'users', element: <UsersLayout />, loader: usersLoader, // runs before render children: [ { index: true, element: <UsersList /> }, { path: ':userId', element: <UserDetail />, loader: userDetailLoader, }, ], }, ], }, ]); function App() { return <RouterProvider router={router} />; } // RootLayout — Outlet renders the matched child route function RootLayout() { return ( <div> <Nav /> <main> <Outlet /> {/* child route renders here */} </main> </div> ); } // Loader — runs parallel to sibling loaders before render (no waterfall) async function userDetailLoader({ params }) { const user = await fetchUser(params.userId); if (!user) throw new Response('Not Found', { status: 404 }); return user; // available via useLoaderData() } // Component — reads loader data and navigates function UserDetail() { const user = useLoaderData(); // data from loader above const navigate = useNavigate(); return ( <div> <h1>{user.name}</h1> <button onClick={() => navigate('..')}>Back</button> <button onClick={() => navigate(`/users/${user.id}/edit`)}>Edit</button> </div> ); }
// lazy() + dynamic import = separate chunk per route const Dashboard = lazy(() => import('./pages/Dashboard')); const Settings = lazy(() => import('./pages/Settings')); const router = createBrowserRouter([ { path: '/', element: <RootLayout />, children: [ { path: 'dashboard', element: ( <Suspense fallback={<PageSpinner />}> <Dashboard /> </Suspense> ), }, ], }, ]); // Or use React Router's built-in lazy (v6.9+) { path: 'dashboard', lazy: async () => ({ Component: (await import('./pages/Dashboard')).default, loader: (await import('./pages/Dashboard')).loader, }), }
"The more your tests resemble the way your software is used, the more confidence they can give you." — Kent C. Dodds
RTL renders components in a real DOM (via jsdom) and queries the output the way a user would — by visible text, accessible role, label, or placeholder — not by CSS class names or component instance internals. Tests that break when you rename a CSS class or restructure a component aren't testing behavior; they're testing implementation.
| Enzyme | React Testing Library | |
|---|---|---|
| Philosophy | Test implementation | Test user behavior |
| Finds elements by | Component name, props, state | Text, role, label, testid |
| Shallow rendering | Yes (isolates components) | No — always full render |
| Accesses internals | Yes: wrapper.state(), wrapper.instance() | No — black-box |
| Refactor safety | Low — implementation changes break tests | High — only behavior changes break tests |
| React 18 support | Poor (abandoned) | First-class |
// ❌ Testing implementation (brittle) it('sets isOpen to true on click', () => { const wrapper = shallow(<Dropdown />); wrapper.find('button').simulate('click'); expect(wrapper.state('isOpen')).toBe(true); // ← tests internal state }); // If you rename isOpen → expanded, test breaks even though behavior is the same // ✅ Testing behavior (resilient) it('shows menu items when trigger is clicked', async () => { render(<Dropdown options={['Edit', 'Delete']} />); // Closed initially expect(screen.queryByRole('menuitem')).not.toBeInTheDocument(); // User clicks trigger await userEvent.click(screen.getByRole('button')); // Menu items visible expect(screen.getByText('Edit')).toBeInTheDocument(); expect(screen.getByText('Delete')).toBeInTheDocument(); }); // Works regardless of whether the state is called isOpen or expanded
| Variant | When element is missing | When to use |
|---|---|---|
getBy* | Throws error immediately | Element must be in the DOM right now |
queryBy* | Returns null | Asserting element is not present |
findBy* | Rejects (async) after timeout | Element appears after async work (fetch, animation) |
getAllBy* | Throws if none found | Multiple elements, all must exist |
queryAllBy* | Returns empty array | Multiple — might not exist |
findAllBy* | Rejects after timeout | Multiple async elements |
| Priority | Query | Why |
|---|---|---|
| 1 (best) | getByRole | Matches ARIA role + accessible name — mirrors screen reader experience |
| 2 | getByLabelText | Forms — finds input by its <label> |
| 3 | getByPlaceholderText | When no label exists |
| 4 | getByText | Non-interactive elements — divs, paragraphs |
| 5 | getByDisplayValue | Current value of form element |
| 6 | getByAltText | Images |
| 7 | getByTitle | title attribute |
| 8 (last) | getByTestId | Escape hatch — only when no semantic query works |
// Roles match semantic HTML automatically: // <button> → role="button" <input> → role="textbox" // <a href> → role="link" <h1-6> → role="heading" // <ul> → role="list" <table> → role="table" // <dialog> → role="dialog" <nav> → role="navigation" render(<LoginForm />); // Finds <input type="text"> or <input> labelled "Username" screen.getByRole('textbox', { name: /username/i }); // Finds <button>Submit</button> screen.getByRole('button', { name: /submit/i }); // Find by heading level screen.getByRole('heading', { level: 1 }); // Using queryBy to assert absence expect(screen.queryByRole('alert')).not.toBeInTheDocument(); // Using findBy for async appearance const successMsg = await screen.findByRole('status'); // waits up to 1000ms expect(successMsg).toHaveTextContent('Saved!');
| fireEvent | @testing-library/user-event | |
|---|---|---|
| Simulates | One DOM event at a time | Full user interaction sequence (focus → hover → keydown → keyup → click…) |
| Typing | fireEvent.change(input, {target:{value:'a'}}) | await userEvent.type(input, 'hello') |
| Async | Synchronous | Returns a Promise — must await |
| Prefer | Low-level, rarely needed | Default choice for all user interactions |
import { render, screen, waitFor } from '@testing-library/react'; import userEvent from '@testing-library/user-event'; it('submits form and shows success message', async () => { const user = userEvent.setup(); // v14+: create instance first render(<ContactForm />); // Simulate typing — fires focus, keydown, keypress, input, keyup events await user.type(screen.getByLabelText(/name/i), 'Alice'); await user.type(screen.getByLabelText(/email/i), 'alice@example.com'); // Click submit button await user.click(screen.getByRole('button', { name: /send/i })); // waitFor — retries assertion until it passes (or timeout) await waitFor(() => { expect(screen.getByText(/message sent/i)).toBeInTheDocument(); }); // Or equivalently: findBy (finds + waits in one call) await screen.findByText(/message sent/i); });
MSW intercepts actual HTTP requests at the network level — not by monkey-patching fetch. This means your tests use the real fetch/axios code and only the server response is mocked, giving much more confidence than jest.mock('axios').
// mocks/handlers.ts — define request handlers import { http, HttpResponse } from 'msw'; export const handlers = [ http.get('/api/users', () => { return HttpResponse.json([ { id: 1, name: 'Alice' }, { id: 2, name: 'Bob' }, ]); }), http.post('/api/users', async ({ request }) => { const body = await request.json(); return HttpResponse.json({ id: 3, ...body }, { status: 201 }); }), ]; // mocks/server.ts — Node.js server for Jest/Vitest import { setupServer } from 'msw/node'; export const server = setupServer(...handlers); // setupTests.ts — runs before all tests beforeAll(() => server.listen({ onUnhandledRequest: 'error' })); afterEach(() => server.resetHandlers()); // clean up per-test overrides afterAll(() => server.close()); // In a test — override handler for error scenario it('shows error when API fails', async () => { server.use( http.get('/api/users', () => new HttpResponse(null, { status: 500 })) ); render(<UserList />); await screen.findByText(/failed to load/i); });
import { renderHook, act } from '@testing-library/react'; import { useCounter } from './useCounter'; // The hook under test function useCounter(initial = 0) { const [count, setCount] = useState(initial); const increment = useCallback(() => setCount(c => c + 1), []); const reset = useCallback(() => setCount(initial), [initial]); return { count, increment, reset }; } describe('useCounter', () => { it('starts at initial value', () => { const { result } = renderHook(() => useCounter(5)); expect(result.current.count).toBe(5); }); it('increments the count', () => { const { result } = renderHook(() => useCounter()); act(() => { result.current.increment(); // must wrap state updates in act() }); expect(result.current.count).toBe(1); }); it('resets to initial', () => { const { result } = renderHook(() => useCounter(10)); act(() => { result.current.increment(); }); act(() => { result.current.reset(); }); expect(result.current.count).toBe(10); }); it('updates when initial prop changes (rerender)', () => { const { result, rerender } = renderHook((initial) => useCounter(initial), { initialProps: 0, }); rerender(100); // simulate parent passing new prop act(() => { result.current.reset(); }); expect(result.current.count).toBe(100); }); });
// test-utils.tsx — custom render that wraps with all providers import { render, RenderOptions } from '@testing-library/react'; import { QueryClient, QueryClientProvider } from '@tanstack/react-query'; function AllProviders({ children }: { children: React.ReactNode }) { const queryClient = new QueryClient({ defaultOptions: { queries: { retry: false } }, // no retries in tests }); return ( <QueryClientProvider client={queryClient}> <ThemeProvider theme="light"> <CartProvider> {children} </CartProvider> </ThemeProvider> </QueryClientProvider> ); } const customRender = (ui: ReactElement, options?: RenderOptions) => render(ui, { wrapper: AllProviders, ...options }); // Re-export everything from RTL, override render export * from '@testing-library/react'; export { customRender as render }; // In tests — import render from test-utils instead of @testing-library/react import { render, screen } from '../test-utils'; // ← not from RTL directly it('shows cart count', async () => { render(<CartBadge />); // automatically wrapped with CartProvider expect(screen.getByText('0')).toBeInTheDocument(); });
// ❌ 1. Using container.querySelector — back to implementation testing const { container } = render(<Button />); container.querySelector('.btn-primary'); // breaks if CSS class renamed // ✅ Fix: use screen.getByRole('button') // ❌ 2. Overusing data-testid screen.getByTestId('submit-btn'); // ✅ Fix: use getByRole('button', { name: /submit/i }) — tests accessibility too // ❌ 3. Not wrapping state updates in act() it('...', () => { render(<Counter />); screen.getByRole('button').click(); // raw .click() bypasses act // → "not wrapped in act" warning }); // ✅ Fix: use userEvent.click() — it handles act() for you // ❌ 4. Using waitFor incorrectly await waitFor(() => { userEvent.click(button); // ❌ side effects inside waitFor expect(screen.getByText('Done')).toBeInTheDocument(); }); // ✅ Fix: fire the event before waitFor, only assert inside it await user.click(button); await waitFor(() => expect(screen.getByText('Done')).toBeInTheDocument()); // ❌ 5. Testing every render detail (snapshot abuse) expect(render(<Button />).container).toMatchSnapshot(); // Snapshots that include the full rendered HTML break on any HTML change, // even cosmetic — developers start blindly updating them // ✅ Fix: snapshot only small, stable things (icon SVG paths, not full pages)
| Principle | How to apply |
|---|---|
| Test behavior, not implementation | Query by role/text, not CSS class or component prop |
| One assertion focus per test | Each test name = one behavior scenario; split if you're asserting too much |
| Use a custom render wrapper | Single test-utils.tsx wraps all providers — no repetition in tests |
| Mock at the network layer | MSW over jest.mock('axios') — tests real HTTP code |
| Avoid unnecessary mocking | Mock only I/O boundaries (API, timers, randomness) — not internal modules |
| Keep setup minimal | beforeEach with complex setup = test pollution; inline setup in each test |
| Accessibility-first queries | If you can't getByRole, your component may have an accessibility gap |