01What React is — declarative UI from state★ start here▶
Scenario: in plain JS you manually find a DOM node and update it every time data changes — tedious and bug-prone. React flips it: you describe what the UI should look like for the current state, and React figures out the DOM changes. That shift is the whole idea.
What
React is a library for building UIs from components — functions that take data (props/state) and return a description of the UI. It's declarative: you describe the result, not the steps.
Why
Manual DOM updates ("imperative") don't scale — you track every change by hand. Declarative UI = "UI is a function of state"; change state, React re-renders.
How
You return JSX from components; React builds a virtual tree, diffs it against the last one, and applies the minimal real-DOM changes (topic 6).
IMPERATIVE (vanilla JS): YOU do every step
el = document.querySelector("#count"); el.textContent = count + 1;
...for every element, every change → error-prone
DECLARATIVE (React): describe the result for the state
UI = f(state) → count changes → React re-derives & patches DOM
you write "what it looks like at count=5", not "how to get there"
=A1+B1 and it always reflects the current values. React components are like formulas for your UI: write them once as "UI from state," and they auto-update when state changes.function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount(count + 1)}>
Clicked {count} times
</button>
);
}
// you never touch the DOM. setCount changes state → React re-renders
// the button with the new count. UI = f(state).✅ Do
- Think "UI = f(state)" — describe the result, let React update the DOM
- Build from small, reusable components
- Let state drive the UI; change state to change what's shown
❌ Don't
- Reach into the DOM manually (
document.querySelector) to change what React renders - Fight React's model by imperatively mutating what it controls
document.querySelector to update something React renders, that's a signal you should be changing state instead. The rare legitimate DOM access (focus, measuring) goes through refs (topic 14), not queries.02JSX — HTML-like syntax that's really JavaScript▶
Scenario: you see <div className="x">{user.name}</div> in a .jsx file and wonder if it's HTML. It isn't — it's JSX, syntactic sugar that compiles to React.createElement calls. Knowing it's really JavaScript explains its quirks (className, {} for expressions).
What
JSX is an HTML-like syntax that compiles to plain function calls creating React elements. {} embeds any JS expression; attributes are camelCase (className, onClick).
Why
It lets you write markup and logic together, readably, while staying full JavaScript — so loops, conditionals, and variables all work inside your UI.
How
A build step (Babel/tsx) turns <div/> into createElement("div", …). Because it's JS, it follows JS rules — hence className (not class) and one root element.
you write: <h1 className="t">Hi {name}</h1>
│ compiled by Babel/tsx
▼
becomes: createElement("h1", { className: "t" }, "Hi ", name)
so: {} = "a JS expression goes here"
className (not class), onClick (camelCase) — JS reserved words
must return ONE root (wrap siblings in <>...</> fragment)
createElement calls). You write the quick, readable form; the build step expands it. Knowing the longhand explains the "weird" rules — they're just JavaScript's rules showing through.function Greeting({ user, items }) {
return (
<section className="card"> {/* className, not class */}
<h1>Hi {user.name}</h1> {/* {} = any JS expression */}
{items.length > 0 && <p>{items.length} items</p>}
<ul>{items.map((i) => <li key={i.id}>{i.label}</li>)}</ul>
</section>
);
}
// loops, conditionals, variables — all normal JS, because JSX IS JS{ } (to show a variable or run a loop), and why some names are slightly different (className instead of class). It looks like HTML, behaves like JavaScript.✅ Do
- Use
{}to embed any JS expression (variables, maps, ternaries) - Remember camelCase attributes:
className,onClick,htmlFor - Wrap multiple siblings in a fragment
<>…</>— JSX returns one root
❌ Don't
- Use
classorfor— they're JS reserved words (useclassName,htmlFor) - Put statements (
if,for) directly in JSX — only expressions go in{}
{} renders JS expressions, a subtle bug is rendering the wrong falsy value: {items.length && <List/>} renders the number 0 on screen when the array is empty (because 0 is falsy but still a renderable value), showing a stray "0". Use {items.length > 0 && …} or a ternary. React renders numbers and strings but ignores true/false/null/undefined — so 0 and NaN are the ones that leak through (topic 9).03Components & props — the building blocks★ core idea▶
Scenario: you have a "user card" that appears in ten places with different users. You don't copy-paste ten times — you make one component and pass each user in as props. Components + props are how React apps stay DRY and composable.
What
A component is a function returning JSX. Props are its inputs — a read-only object passed from the parent. Same idea as function arguments, for UI.
Why
They give you reuse and composition: build once, use everywhere with different data. Props flow down (parent → child), keeping data flow predictable.
How
Parent renders <Card user={u}/>; the child receives { user }. Props are immutable inside the child — to change what's shown, the parent passes new props.
<App>
<UserCard user={a} /> props ──► UserCard renders a
<UserCard user={b} /> props ──► UserCard renders b (reused!)
└─ <Avatar src={...} /> props flow further DOWN
one-way data flow: parent → child (never child → parent directly)
props are READ-ONLY inside the child
function UserCard({ name, role }) { // props destructured
return (
<div className="card">
<strong>{name}</strong> — {role}
</div>
);
}
// reuse with different data — no copy-paste:
<UserCard name="Ada" role="admin" />
<UserCard name="Grace" role="editor" />
// props are read-only: a child never mutates its own props✅ Do
- Build small, single-purpose components and compose them
- Pass data down via props; keep components pure (same props → same output)
- Destructure props for readability:
function Card({ name })
❌ Don't
- Mutate props inside a child — they're read-only; lift state up instead (topic 19)
- Build giant "god components" — split by responsibility
onChange) that the child calls. New React devs try to mutate props or reach "up" and get confused when nothing updates; the data-flow contract is deliberately one-directional, which is exactly what makes React apps predictable (topics 10, 19).04useState — state & re-rendering★ core idea▶
Scenario: you set a normal variable let count = 0, increment it on click, and the screen never updates. React doesn't know it changed. useState is how a component remembers data and tells React to re-render when it changes.
What
useState returns a value and a setter. Calling the setter updates the value and schedules a re-render. State persists across renders (a plain variable doesn't).
Why
UI = f(state) (topic 1). To make the UI change, you change state via the setter — that's the signal React needs to re-render with the new value.
How
const [x, setX] = useState(init). Calling setX(next) re-renders. State updates are asynchronous and batched; use the functional form when the next value depends on the current.
let count = 0; count++ → screen NEVER updates (React unaware) ✗
const [count, setCount] = useState(0);
setCount(count + 1) ─► React: state changed → RE-RENDER
component runs again, sees new count ✓
updates are async + batched → use functional form when needed:
setCount(c => c + 1) (correct even with multiple updates)
useState is a whiteboard with a bell — when you update it (setter), the bell rings and everyone repaints the room to match. React only repaints when the bell rings.function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((c) => c + 1)}> {/* functional form */}
{count}
</button>
);
}
// setCount((c) => c + 1) is safe even if called several times in a row;
// setCount(count + 1) twice may only add ONE (stale closure over count)c => c + 1 form to be safe.✅ Do
- Use the setter (never mutate the value directly) to trigger a re-render
- Use the functional form
setX(x => …)when the next value depends on the current - Treat state as immutable — replace objects/arrays, don't mutate them
❌ Don't
- Mutate state directly (
state.push(x)) — React won't re-render - Expect the state variable to update synchronously right after calling the setter
setCount(...) gives the old value — the re-render hasn't happened yet. Second, you must treat state as immutable: arr.push(x); setArr(arr) won't re-render because it's the same array reference — React bails out. You must pass a new reference: setArr([...arr, x]). Mutating state in place is the single most common "why won't it update?" bug (topics 5, 15).05Rendering & re-rendering — when & why★ core idea▶
Scenario: your app feels slow, and you discover a component re-renders 50 times per second. Understanding what triggers a render — and that "render" doesn't mean "touch the DOM" — is the key to both correctness and performance.
What
A render is React calling your component function to get the latest JSX. It re-renders when its state changes, its props change, or its parent re-renders.
Why
Rendering is how UI stays in sync with state. But unnecessary re-renders (and the work inside them) are the main React performance issue — you must know what causes them.
How
State/prop change → React re-runs the component → produces new JSX → diffs vs previous (topic 6) → patches only the changed DOM. Rendering ≠ committing to DOM.
a component re-renders when ANY of these happen:
• its own state changes (setState)
• its props change
• its PARENT re-renders (children re-render by default!)
│
▼
render = run the function → new JSX → diff → patch changed DOM only
⚠ "render" runs your function; it does NOT always touch the real DOM
function Parent() {
const [n, setN] = useState(0);
return (
<div>
<button onClick={() => setN(n + 1)}>{n}</button>
<ExpensiveChild /> {/* re-renders every click, even though its
props never change — unless memoized (25) */}
</div>
);
}
// rule: a parent re-render re-runs ALL children by default✅ Do
- Know the three triggers: own state, props, or parent re-rendering
- Keep render functions pure and cheap; move heavy work out (topics 15, 25)
- Profile with React DevTools to see what's re-rendering and why
❌ Don't
- Assume a re-render means a DOM update — most renders patch nothing
- Prematurely optimize — measure first; most re-renders are cheap (topic 25)
memo-everywhere is an anti-pattern. But it becomes a real problem when a heavy child or a huge subtree re-renders on every keystroke. The fix is targeted — React.memo, moving state down, or passing children as props — not blanket memoization (topics 15, 25).06The virtual DOM & reconciliation▶
Scenario: touching the real DOM is slow, and re-creating the whole page on every state change would be brutal. React's trick: keep a lightweight copy (the virtual DOM), diff the new one against the old, and change only what actually differs. That diffing is reconciliation.
What
The virtual DOM is an in-memory tree of elements (plain objects). Reconciliation is React comparing the new tree to the previous one to compute the minimal real-DOM changes.
Why
Real DOM operations are expensive. Diffing cheap JS objects and applying only the differences is far faster than rebuilding, and lets you write "re-render everything" code.
How
Render produces a new virtual tree; React diffs it type-by-type and uses keys (topic 8) to match list items, then commits the smallest set of real-DOM mutations.
old virtual tree new virtual tree
div div
├ h1 "0" diff ─► ├ h1 "1" ← only this text changed
└ ul [...] └ ul [...] (unchanged → skipped)
│
▼ commit: update ONE text node in the real DOM
real DOM is touched minimally, not rebuilt
// you write this as if rebuilding the whole list every render:
function Todos({ todos }) {
return <ul>{todos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>;
}
// but React diffs old vs new virtual tree and, if you add one todo,
// inserts ONE <li> — it does NOT rebuild the whole <ul>.
// keys (topic 8) tell React which items are which during the diff.✅ Do
- Write UI as "recreate from state" — React makes it efficient via diffing
- Give list items stable
keys so reconciliation matches them correctly (topic 8) - Keep component identity stable so React reuses DOM instead of remounting
❌ Don't
- Assume the virtual DOM makes everything "free" — big diffs still cost (topic 25)
- Change a component's position/type unnecessarily — it forces a remount
07useEffect — synchronizing with the outside★ core idea▶
Scenario: you need to fetch data when a component mounts, subscribe to something, or set the document title. These are side effects — things outside React's render. useEffect runs them after render and cleans them up, keeping your component in sync with the outside world.
What
useEffect(fn, deps) runs fn after the render commits, re-running when a value in deps changes. Return a cleanup function to undo the effect (unsubscribe, clear timer).
Why
Rendering must be pure (no side effects). Effects are the escape hatch for talking to the outside world — network, subscriptions, DOM APIs, timers — safely tied to render.
How
React runs the effect after paint; on the next run (or unmount) it runs your cleanup first. The dependency array controls when it re-runs (topic 13).
render ──► commit to DOM ──► run effect(fn)
│ subscribe / fetch / set title
deps change or unmount ──► run CLEANUP ──► run effect again
[] deps → run once on mount, clean up on unmount
[x] deps → re-run whenever x changes
no deps → run after EVERY render (usually a bug)
useEffect(() => {
const id = setInterval(() => tick(), 1000); // side effect
return () => clearInterval(id); // cleanup (critical!)
}, []); // [] = run once on mount
useEffect(() => {
document.title = `${count} items`; // re-syncs when count changes
}, [count]); // dep array (topic 13)useEffect is where those go. It runs your code after the component draws, and it can clean up after itself (stop the timer, unsubscribe) when the component goes away or needs to redo the effect. The little list at the end (the "dependencies") tells React when to run it again.✅ Do
- Use effects for synchronizing with outside systems (subscriptions, timers, non-React DOM)
- Always return a cleanup for anything you subscribe/start
- List every value the effect uses in the dependency array (topic 13)
❌ Don't
- Use an effect to transform data for rendering — compute it during render instead (topic 19)
- Forget cleanup — leaked subscriptions/timers cause bugs and memory leaks
useEffect is the most overused hook — people reach for it to compute derived values, sync state to props, or respond to user events, all of which belong elsewhere (compute during render, or handle in the event handler). Each unnecessary effect adds an extra render pass and a class of sync bugs. The rule: an effect is for synchronizing with something outside React. If it's just "when X, update Y state," you probably don't need an effect at all (topics 13, 19).08Lists & keys — help React track items▶
Scenario: you render a list with .map(), React warns "each child needs a key," and later your list items' state gets mixed up when you reorder or delete. Keys are how React identifies which item is which across renders — get them wrong and reconciliation breaks.
What
A key is a stable, unique identifier you put on each element in a list, so React can match items between the old and new virtual trees during reconciliation (topic 6).
Why
Without stable keys, React matches list items by position and can associate the wrong DOM/state with the wrong data on insert/reorder/delete — causing subtle, ugly bugs.
How
Use a stable id from your data (key={item.id}), not the array index. React uses the key to know an item moved vs was replaced.
list: [A, B, C] with key=index (0,1,2)
insert X at front → [X, A, B, C]
key 0 was A, now X → React thinks A "became" X, B "became" A...
→ wrong DOM reused, input values & focus attach to wrong rows ✗
with key=item.id (stable): React sees X is NEW, A/B/C just moved ✓
// ✓ stable id → React tracks items correctly across changes
<ul>{todos.map((t) => <li key={t.id}>{t.text}</li>)}</ul>
// ✗ index as key → breaks on insert/reorder/delete
<ul>{todos.map((t, i) => <li key={i}>{t.text}</li>)}</ul>
// symptom: after deleting item 2, item 3's checkbox/input shows on the
// wrong row, because state stuck to the position (key), not the data.✅ Do
- Use a stable, unique id from your data as the key
- Ensure keys are unique among siblings (not globally)
- Key the outermost element returned per
.map()iteration
❌ Don't
- Use the array index as key for lists that can reorder, insert, or delete
- Use a random value (
Math.random()) as key — it remounts every render
09Conditional rendering▶
Scenario: show a spinner while loading, an error message on failure, the data on success, and "empty" when there's nothing. Rendering different UI based on state is constant in React — and there are a few idioms (and traps) to get it right.
What
Choosing what to render based on state/props using JS expressions: ternaries (cond ? a : b), && for "render if true," early returns, or a mapping of states to UI.
Why
UIs have states — loading, empty, error, success. Clean conditional rendering makes those explicit and prevents flashing wrong or broken content.
How
Inside JSX use {} with a ternary or &&; for bigger branching, compute the element above the return or use early returns.
status: loading → <Spinner/>
error → <Error/>
empty → <Empty/>
success → <List/>
{cond ? <A/> : <B/>} // either/or
{cond && <A/>} // render A if cond is TRUE
⚠ {count && <A/>} renders "0" when count===0 (topic 2)!
function Users({ status, users }) {
if (status === "loading") return <Spinner />; // early return
if (status === "error") return <Error />;
return (
<div>
{users.length === 0 ? (
<Empty />
) : (
<ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>
)}
</div>
);
}
// use `users.length === 0`, not `!users.length && ...` style trapscount && ...) accidentally prints "0" on the screen when the number is zero — so check count > 0 instead.✅ Do
- Explicitly handle loading, error, empty, and success states
- Use ternaries for either/or and early returns for whole-component branches
- Guard
&&with a boolean (len > 0), not a number/string
❌ Don't
- Write
{count && …}— it renders0when count is 0 (topic 2) - Forget the empty/error states — users see blank or broken UI
&& shortcut has a famous footgun: {cart.length && <Cart/>} renders a bare 0 when the cart is empty, because 0 && x evaluates to 0, and React renders 0 (unlike false/null, which it ignores). Always make the left side a real boolean — cart.length > 0 && … or a ternary. This single gotcha accounts for countless "why is there a random 0 on my page?" bugs (topic 2).10Events & controlled forms★ core idea▶
Scenario: you build a form and want React to be the source of truth for the input values. A controlled input reads its value from state and updates state on change — so React always knows the current value, enabling validation, formatting, and submission.
What
React events (onClick, onChange) are camelCase handlers on elements. A controlled input has its value bound to state and an onChange that updates that state.
Why
Controlled inputs make React the single source of truth for form data — you can validate, transform, disable, and submit from state, with no manual DOM reads.
How
Set value={x} and onChange={e => setX(e.target.value)}. The input shows state; typing updates state; state re-renders the input. A closed loop.
state x ──value──► <input value={x}>
▲ │ user types
└──── onChange(e) ◄──────┘ setX(e.target.value)
loop: state drives the input; input updates state
→ React ALWAYS knows the value (validate, format, submit from state)
function NameForm() {
const [name, setName] = useState("");
return (
<form onSubmit={(e) => { e.preventDefault(); save(name); }}>
<input
value={name} {/* state → input */}
onChange={(e) => setName(e.target.value)} {/* input → state */}
/>
<button disabled={!name.trim()}>Save</button> {/* validate from state */}
</form>
);
}✅ Do
- Bind
valueto state and update it inonChange(controlled input) - Call
e.preventDefault()in formonSubmitto stop a full page reload - Validate and derive UI (disabled buttons, errors) from the state value
❌ Don't
- Set
valuewithout anonChange— the input becomes read-only (a warning) - Read input values from the DOM when React state already has them
value to undefined (e.g. value={user.name} before user loads) and then giving it a string later. Always initialize controlled values to a defined value (""). And for large forms, controlling every keystroke re-renders on each character; that's usually fine, but heavy forms may want a form library or uncontrolled refs (topics 4, 14).11Composition & children▶
Scenario: you want a reusable <Card> with a border and padding, but different content inside each time. Instead of a hundred props, you pass content between the tags via children. Composition — building complex UI from nested pieces — is React's core design principle.
What
Composition is building UI by nesting components. The special children prop is whatever you put between a component's tags, letting a component wrap arbitrary content.
Why
Composition beats inheritance for UI: flexible, reusable wrappers (layouts, cards, modals) without rigid class hierarchies or prop explosions.
How
Render {children} inside the wrapper. Pass content between tags: <Card>anything here</Card>. Use "slots" (named props holding JSX) for multiple regions.
<Card>
<h2>Title</h2> ┐
<p>Body text</p> ├─ this becomes Card's `children`
</Card> ┘
function Card({ children }) {
return <div className="card">{children}</div>; // render the slot
}
one wrapper, infinite different contents — no prop explosion
<Card> is a frame — it provides the border and matting (styles/structure), and children is whatever photo you slide in. One frame design holds any picture. You don't build a new frame per photo; you reuse the frame and swap the contents.children is whatever you put inside. Same box, endless possible gifts. Composition is nesting boxes within boxes to build up something elaborate from simple containers.function Card({ title, children }) { // "children" = content between tags
return (
<div className="card">
{title && <h2>{title}</h2>} {/* named prop = a "slot" */}
<div className="body">{children}</div>
</div>
);
}
// use it with ANY content:
<Card title="Profile">
<Avatar /> <p>Bio here</p>
</Card>children, and the wrapper decides where to show it. So one <Card> (with its nice border and padding) can hold a photo, a form, or text — you just nest different content inside. Building UIs by nesting pieces like this is called composition.✅ Do
- Use
childrenfor flexible wrappers (layouts, cards, modals) - Prefer composition over inheritance for reuse
- Use named JSX props as "slots" when you need multiple content regions
❌ Don't
- Explode a component with dozens of content props when
childrenwould do - Deeply prop-drill data through many layers — consider context (topic 16)
children means it's created by the parent, so it doesn't re-render when the wrapper's own state changes (its element identity is stable). This "children as a stable slot" pattern often removes the need for memo. Conversely, over-nesting wrappers or drilling props through five layers of composition is a smell — that's when context (topic 16) or restructuring is the better tool (topics 5, 25).12The rules of hooks★ core idea▶
Scenario: you put a useState inside an if to "only track it sometimes," and React throws "rendered fewer hooks than expected." Hooks have two strict rules, and violating them corrupts React's internal bookkeeping. Knowing why makes the rules obvious.
What
Two rules: (1) only call hooks at the top level — never in conditions, loops, or nested functions; (2) only call them from React components or custom hooks.
Why
React tracks hooks by call order, not by name. Conditionally skipping one shifts every subsequent hook's identity, scrambling state. The rules keep order stable.
How
Call all hooks unconditionally at the top of the component. Put the condition inside the hook (e.g. in the effect body), not around the hook call.
render 1: useState(a) useState(b) useEffect(c) ← slots 0,1,2
render 2 (with an `if` skipping the first):
useState(b) useEffect(c) ← now b is in slot 0!
→ React gives b the value meant for a → CORRUPTED state ✗
rule: ALWAYS call the same hooks in the same order every render
// ✗ WRONG — conditional hook call breaks the order
if (loggedIn) { const [x] = useState(0); }
// ✓ RIGHT — always call it; put the condition inside
const [x, setX] = useState(0);
useEffect(() => {
if (!loggedIn) return; // condition INSIDE the effect
subscribe();
}, [loggedIn]);
// enable the eslint-plugin-react-hooks rules — they catch violationsuseState) come with two simple rules: always call them at the very top of your component, and always in the same order — never hide one inside an if or a loop. Why? React remembers your hooks by the order you call them, like numbered slots. If you skip one sometimes, everything shifts and React hands your data to the wrong slot. If you need a condition, put the if inside the hook, not around it. A linter can catch mistakes for you.✅ Do
- Call all hooks at the top level, unconditionally, in the same order
- Put conditions inside the hook (effect body), not around the call
- Enable
eslint-plugin-react-hooksto catch violations automatically
❌ Don't
- Call hooks in
if/loops/nested functions or after an early return - Call hooks from regular functions — only components and custom hooks
return before a hook, or a hook inside a condition, silently misaligns every later hook — often without an error, just wrong values, which is far harder to debug than a crash. The eslint plugin isn't optional nice-to-have; it's the thing that catches these before they become baffling state corruption (topics 4, 13).13useEffect dependencies — the #1 trap★ core idea▶
Scenario: your effect uses a userId but you leave the dependency array empty [] — so it fetches the first user forever, even when userId changes. Or you add a function to deps and it re-runs every render. The dependency array is where most React bugs live.
What
The second argument to useEffect — an array of every reactive value the effect reads. React re-runs the effect when any listed value changes (by Object.is comparison).
Why
Missing a dependency = stale values (effect uses old data). Extra/unstable dependencies = the effect re-runs too often (or infinitely). Getting it exact is the whole game.
How
List every value the effect uses. Stabilize functions/objects with useCallback/useMemo (topic 15) so they don't change identity every render.
useEffect(() => fetch(userId), []) ← lies! ignores userId changes
→ always fetches the FIRST userId (STALE) ✗
useEffect(() => fetch(userId), [userId]) ← correct: re-runs on change ✓
useEffect(() => { ... }, [config]) ← config = {} new each render
→ new object identity every time → effect runs EVERY render ✗
fix: useMemo the object, or depend on primitive fields
// stale: effect captured the FIRST userId and never updates
useEffect(() => { load(userId); }, []); // ✗
// correct: re-runs whenever userId changes
useEffect(() => { load(userId); }, [userId]); // ✓
// object dep changes identity every render → runs forever:
const opts = { limit: 10 }; // new object each render
useEffect(() => { fetch(opts); }, [opts]); // ✗ infinite-ish
const opts2 = useMemo(() => ({ limit: 10 }), []); // ✓ stable identityuseEffect tells React "re-run this whenever these things change." The #1 React bug is getting that list wrong. Leave something out (like the user id) and your effect keeps using old data forever. Put in something that secretly gets recreated every time (like a fresh object) and the effect runs over and over. The rule: list every value the effect actually uses, and make sure objects/functions in the list don't get rebuilt each render.✅ Do
- List every reactive value the effect reads — trust the exhaustive-deps lint rule
- Stabilize function/object deps with
useCallback/useMemo(topic 15) - Depend on primitive fields when an object dep keeps changing identity
❌ Don't
- Leave deps out to "run once" when the effect uses changing values — that's a stale bug
- Disable the exhaustive-deps lint rule to silence a warning — fix the real cause
exhaustive-deps lint warning with an empty array "to make it run once." That doesn't make the bug go away — it hides a stale closure, where the effect permanently captures the values from its first render and ignores every update. The warning is almost always correct. If listing a dependency causes over-running, the real fix is to stabilize that dependency (useCallback/useMemo) or restructure — not to lie to React about what the effect uses (topics 7, 15).14useRef — values that persist without re-rendering▶
Scenario: you need to focus an input, store a timer id, or remember the previous value — but using useState for these causes needless re-renders or doesn't fit. useRef gives you a mutable box that survives renders without triggering them.
What
useRef(init) returns a stable object { current } that persists across renders. Changing .current does not cause a re-render. Also used to reference DOM nodes.
Why
For values you need to keep but that shouldn't drive the UI (timer ids, previous values, DOM handles), refs avoid the re-renders (and rules) of state.
How
Read/write ref.current freely. Attach to DOM with ref={myRef} to get the element for focus/measure. It's an escape hatch from React's declarative model.
useState: change → RE-RENDER (drives the UI)
useRef: change .current → NO re-render (a persistent box)
uses: • DOM handle: <input ref={inputRef}/> → inputRef.current.focus()
• mutable value: timerId.current = setInterval(...)
• previous value: prev.current = value (after render)
⚠ don't read/write ref.current DURING render for UI — it's not reactive
function Search() {
const inputRef = useRef(null); // DOM handle
const renders = useRef(0); // persists, no re-render
renders.current++; // won't cause a loop
useEffect(() => { inputRef.current?.focus(); }, []); // focus on mount
return <input ref={inputRef} placeholder="Search" />;
}
// changing renders.current never re-renders; changing state woulduseState would force a redraw; useRef is a little box (.current) you can quietly write to without React redrawing anything. It's the tool for "keep this, but it's not part of what's shown."✅ Do
- Use refs for DOM access (focus, measure, scroll) and non-UI mutable values
- Store timer/subscription ids and "previous value" in refs
- Read/write
.currentin effects and handlers, not during render
❌ Don't
- Use a ref for data that should update the UI — that's state's job
- Read/write
ref.currentduring rendering to compute UI — it's not reactive
state; if it's incidental bookkeeping or a DOM handle, it's a ref (topics 4, 5).15useMemo & useCallback — memoization▶
Scenario: a component recomputes an expensive value on every render, or a child re-renders because a callback prop is a "new" function each time. useMemo caches a computed value; useCallback caches a function — both keyed to dependencies, so they only change when they must.
What
useMemo(fn, deps) caches the result of fn; useCallback(fn, deps) caches the function itself. Both recompute only when a dep changes — preserving identity otherwise.
Why
Two uses: skip expensive recomputation, and keep stable identity for values/functions passed to memoized children or effect deps (topic 13), preventing needless re-renders.
How
Wrap the expensive computation or the callback; list its deps. React returns the cached version until a dep changes. useCallback(fn, d) ≈ useMemo(() => fn, d).
useMemo: expensive = useMemo(() => compute(a), [a])
recompute only when `a` changes; else return cached value
useCallback: onClick = useCallback(() => do(a), [a])
SAME function reference across renders (until a changes)
→ <MemoChild onClick={onClick}/> won't re-render needlessly
purpose: (1) skip heavy compute (2) stable identity for memo deps
// cache an expensive computation
const sorted = useMemo(() => heavySort(items), [items]); // only re-sorts on items change
// keep a stable function so a memoized child doesn't re-render
const handleSelect = useCallback((id) => select(id), []);
return <MemoizedList items={sorted} onSelect={handleSelect} />;
// without useCallback, onSelect is a NEW function each render →
// MemoizedList re-renders every time despite React.memo (topic 25)useMemo lets you save a calculated result and reuse it unless its inputs change. useCallback does the same for a function, so it stays "the same" between renders. Why care? Because a "new" function or object each render can make child components redraw unnecessarily. These tools skip wasted work — but only use them when there's a real cost, or they just add clutter.✅ Do
- Use
useMemofor genuinely expensive computations - Use
useCallback/useMemoto keep stable identity for memoized children or effect deps - List correct dependencies — a wrong dep list caches stale values
❌ Don't
- Wrap everything in memo hooks by default — they have a cost and add noise
- Rely on them for correctness — they're an optimization, React may discard caches
useMemo/useCallback costs memory and a dependency comparison on each render, so wrapping trivial values makes code slower and noisier, not faster. It only pays off for genuinely expensive computes or for stabilizing identity that a React.memo child (or effect dep) actually depends on. And it's an optimization, never a guarantee: React may throw away the cache, so never rely on a memo for correctness. Measure before memoizing (topics 5, 25).16useContext — avoiding prop-drilling▶
Scenario: the current user (or theme) is needed by components ten levels deep, so you pass it through every intermediate component as a prop — "prop-drilling." Context lets you provide a value at the top and read it anywhere below, skipping the middlemen.
What
Context is a way to share a value with a subtree without passing props through every level. A Provider supplies it; useContext reads it anywhere below.
Why
It removes tedious prop-drilling for truly global-ish data (auth, theme, locale) that many components need — cleaner than threading props through uninterested layers.
How
createContext, wrap the tree in <Ctx.Provider value={…}>, and call useContext(Ctx) in any descendant. Consumers re-render when the value changes.
PROP-DRILLING: App → Page → Layout → Nav → Avatar (user passed 4×)
each layer forwards a prop it doesn't care about ✗
CONTEXT: <UserProvider value={user}> ...deep tree... </UserProvider>
│
any descendant: const user = useContext(UserContext) ✓
skips the middlemen entirely
const UserContext = createContext(null);
function App() {
const [user] = useAuth();
return (
<UserContext.Provider value={user}> {/* provide at the top */}
<Dashboard /> {/* deep tree, no prop passing */}
</UserContext.Provider>
);
}
function Avatar() {
const user = useContext(UserContext); // read directly, 10 levels down
return <img src={user.avatar} />;
}✅ Do
- Use context for genuinely widely-needed data (auth, theme, locale)
- Split contexts by concern so unrelated updates don't re-render everything
- Memoize the provider's
valueso it doesn't change identity each render
❌ Don't
- Reach for context for data only a couple of nearby components need (just pass props)
- Put fast-changing state in one giant context — every consumer re-renders (topic 20)
value changes, and if you pass an inline object value={{ user, setUser }} it's a new identity every render, re-rendering all consumers constantly. Memoize the value, split fast-changing state out, and don't route high-frequency updates through one broad context — reach for a real state library when that's the need (topics 15, 20).17useReducer — structured state transitions▶
Scenario: a component has five related pieces of state that change together in complex ways (a form wizard, a cart), and scattered useStates become spaghetti. useReducer centralizes the logic: dispatch actions, and one reducer function computes the next state.
What
useReducer(reducer, init) returns [state, dispatch]. You dispatch(action); the pure reducer (state, action) => newState decides the next state. Same idea as Redux, built in.
Why
For complex, interrelated state and many transitions, a reducer makes updates predictable, testable, and centralized — versus tangled useState calls scattered across handlers.
How
Define actions ({type, payload}); the reducer switches on type to return new state. Components dispatch actions instead of setting state directly.
component ── dispatch({type:"add", item}) ──► REDUCER(state, action)
switch(action.type){
case "add": ...
case "remove": ...
} → newState
▲ │
└──────────────── new state re-renders ◄─────────────┘
all transition logic in ONE pure, testable function
function cartReducer(state, action) { // pure: (state, action) → state
switch (action.type) {
case "add": return { ...state, items: [...state.items, action.item] };
case "clear": return { ...state, items: [] };
default: return state;
}
}
function Cart() {
const [state, dispatch] = useReducer(cartReducer, { items: [] });
return <button onClick={() => dispatch({ type: "add", item })}>Add</button>;
}
// all transitions in one testable function; dispatch, don't setState directlyuseStates everywhere gets messy. useReducer tidies it up: you send named requests ("add item," "clear cart") and one function decides how the state changes for each. All the "what happens when" rules live in that single function, making it easier to follow, test, and not break. It's like pressing buttons on a machine instead of rewiring it each time.✅ Do
- Use a reducer when state is complex, interrelated, or has many transitions
- Keep the reducer pure (no side effects, no mutation — return new state)
- Model actions as clear intents (
{type: "add"}) — they're testable and loggable
❌ Don't
- Reach for a reducer for one or two simple, independent values —
useStateis simpler - Mutate state inside the reducer — always return a new object
useState, you must return a new object — mutating state in place won't re-render (topics 4, 29).18Custom hooks — reusable stateful logic★ core idea▶
Scenario: three components each need "fetch data with loading and error state," and you copy-paste the same useState+useEffect dance into all three. A custom hook extracts that stateful logic into one reusable function — the primary way to share behavior in React.
What
A custom hook is a function whose name starts with use that calls other hooks. It packages reusable stateful logic so multiple components can share it.
Why
It's how you DRY up repeated hook logic (fetching, form handling, subscriptions) without copy-paste — and without the old patterns (HOCs, render props) React used before hooks.
How
Write a useX function that uses hooks and returns whatever the caller needs. Each component that calls it gets its own independent state — logic is shared, state is not.
before: 3 components copy the same useState + useEffect fetch dance ✗
after: function useFetch(url) { ...hooks...; return { data, loading, error } }
│ component A: const {data} = useFetch("/a") ← own state
│ component B: const {data} = useFetch("/b") ← own state
└ logic SHARED, state INDEPENDENT per caller ✓
function useFetch(url) { // custom hook (name starts "use")
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
let alive = true;
fetch(url).then((r) => r.json()).then((d) => alive && setData(d))
.finally(() => alive && setLoading(false));
return () => { alive = false; }; // cleanup (topic 7)
}, [url]);
return { data, loading };
}
// reuse anywhere; each caller gets independent state:
const { data, loading } = useFetch("/api/users");use) that bundles up that logic. Then every component just calls it. They share the recipe but each keeps its own separate data. It's the main way React lets you reuse stateful behavior cleanly.✅ Do
- Extract repeated hook logic (fetching, forms, subscriptions) into
useXhooks - Name them starting with
useso lint rules and readers recognize them - Return exactly what callers need (values and/or functions)
❌ Don't
- Expect custom hooks to share state — each call is independent (use context/store for shared state)
- Break the rules of hooks inside them (topic 12) — they follow the same rules
useFetch get two separate data/loading states — there's no magic shared store. Newcomers expect a custom hook to be a singleton and are surprised when component B doesn't see component A's data. If you need genuinely shared state, that's context (topic 16) or a store (topic 20), often wrapped in a custom hook for ergonomics. The hook is the interface; where the state lives is a separate decision (topics 12, 20).19Lifting state up & derived state▶
Scenario: two sibling components need to share a value, or you're storing something in state that you could just calculate from existing state. Two foundational patterns: lift state up to the common parent, and derive instead of duplicating.
What
Lifting state up: move shared state to the closest common parent and pass it down. Derived state: compute values during render from existing state/props instead of storing them.
Why
Duplicated or unnecessary state gets out of sync — the top source of bugs. One source of truth (lifted) plus computing the rest (derived) keeps everything consistent.
How
Find the lowest common ancestor of components that need the value; hold state there; pass value + updater down. For derivable values, just compute them in the body.
LIFT: two siblings need `filter`
Parent (holds filter)
├─ <Search value={filter} onChange={setFilter}/>
└─ <List filter={filter}/> shared via the parent
DERIVE (don't store what you can compute):
✗ const [count,setCount]=useState(0) // duplicates items.length
✓ const count = items.length // derived during render
=SUM(...) so it's always correct (derived). And shared inputs live in one authoritative cell that other formulas reference (lifted), not copied around.function Parent() {
const [query, setQuery] = useState(""); // lifted (shared source)
const items = useItems();
const filtered = items.filter((i) => i.name.includes(query)); // DERIVED
return (
<>
<Search value={query} onChange={setQuery} /> {/* both siblings */}
<List items={filtered} count={filtered.length} /> {/* derive count too */}
</>
);
}
// no separate `filtered`/`count` state to keep in sync — compute it✅ Do
- Lift shared state to the lowest common ancestor; pass value + setter down
- Derive values during render instead of storing (and syncing) them
- Keep a single source of truth for each piece of data
❌ Don't
- Duplicate the same data into multiple states — they'll drift apart
- Mirror props into state (then sync with an effect) when you can just use the prop
fullName in state alongside firstName/lastName, then wiring a useEffect to keep them in sync. That effect is a bug factory: it adds a render, can run stale, and the two can diverge. If a value can be computed from existing state/props, compute it during render — no state, no effect, always correct. "Do I need this in state, or can I derive it?" is the question that prevents most sync bugs (topics 7, 13).20State management — local, context, or a store▶
Scenario: your app grows and "where does this state live?" gets hard — component-local, shared via context, or in a global store (Redux/Zustand)? Choosing the right home for each piece of state is a core architecture decision that keeps apps maintainable.
What
The spectrum of state homes: local (useState), lifted/context for a subtree, global stores (Redux, Zustand, Jotai) for app-wide state, and server cache (React Query) for remote data.
Why
Putting state too high (global) creates coupling and re-renders; too scattered creates drift. The right home per state = simpler, faster, more maintainable apps.
How
Default to local; lift when shared; context for widely-read low-frequency data; a store for complex global client state; and treat server data as a cache, not state (topic 21).
LOCAL (useState) one component's concern (input value, toggle) LIFTED / CONTEXT shared across a subtree (theme, current user) GLOBAL STORE complex app-wide client state (Redux/Zustand) SERVER CACHE remote data (React Query/SWR) — NOT useState! rule: start local; move UP only when sharing demands it
// local — belongs to this component only
const [open, setOpen] = useState(false);
// server data — use a cache library, NOT useState + useEffect (topic 21)
const { data, isLoading } = useQuery(["users"], fetchUsers);
// global client state — a store, read via a hook
const cart = useCartStore((s) => s.items); // Zustand-style selector
// choose the SMALLEST scope that works; escalate only when shared✅ Do
- Default to local state; lift or globalize only when sharing requires it
- Use a dedicated data-fetching cache for server state (topic 21)
- Reach for a store (Zustand/Redux) for genuinely complex global client state
❌ Don't
- Put everything in one global store "just in case" — coupling and re-renders
- Store server data in
useStateand hand-sync it — use a cache (topic 21)
useState+useEffect means reinventing caching badly, and it's why apps drown in loading-state boilerplate. Separate server cache (React Query/SWR, topic 21) from client state (useState/store), and most of your "state management" problem shrinks dramatically. Reaching for Redux for server data is solving the wrong problem (topic 21).21Data fetching — treat server data as a cache★ core idea▶
Scenario: you fetch in a useEffect, juggle loading/error/data state by hand, and hit race conditions, duplicate requests, and no caching. Modern React treats remote data as a cache via a library (React Query/SWR) — eliminating most of that boilerplate and its bugs.
What
Fetching and managing server data with a caching library that handles loading/error/success, caching, deduping, background refetch, and invalidation — instead of raw useEffect fetching.
Why
Hand-rolled effect fetching is a swamp of races, duplicate requests, stale data, and repeated boilerplate. A cache library solves all of it as a well-tested default.
How
Declare a query by key + fetch function; the library caches by key, dedupes, refetches on focus/interval, and gives you data/isLoading/error. Mutations invalidate keys to refresh.
useEffect fetch: YOU handle loading, error, race conditions, caching,
dedupe, refetch, invalidation... → boilerplate + bugs ✗
useQuery(["user", id], () => fetchUser(id)):
cache by key · dedupe identical requests · background refetch
· stale-while-revalidate · returns {data, isLoading, error} ✓
mutation → invalidate ["user", id] → auto refetch fresh data
function Users() {
const { data, isLoading, error } = useQuery({
queryKey: ["users"],
queryFn: fetchUsers, // caching, dedupe, refetch — handled
});
if (isLoading) return <Spinner />;
if (error) return <Error />;
return <List items={data} />;
}
// mutation invalidates the key → dependent queries refetch fresh:
// onSuccess: () => queryClient.invalidateQueries(["users"])useEffect is a bug-filled chore. Instead, use a data library (like React Query) that treats server data as a smart, self-refreshing cache. You just say "get me the users," and it handles loading states, caching, and keeping data fresh — deleting most of your boilerplate and its bugs.✅ Do
- Use a query cache (React Query/SWR) for server data instead of raw effects
- Key queries clearly; invalidate keys after mutations to refetch
- Let the library own loading/error/caching/refetch/dedupe
❌ Don't
- Hand-roll
useEffectfetching for anything non-trivial — races & boilerplate await - Store fetched data in
useStateand try to keep it fresh yourself (topic 20)
useEffect-fetch pattern has a nasty, easy-to-miss bug: race conditions. If userId changes quickly, request A and request B are both in flight, and whichever resolves last wins — which may be the older request, showing stale data for the wrong user. You must track and ignore stale responses (the let alive = true cleanup trick, topic 7). Query libraries handle this (and caching, dedupe, refetch) correctly by default — which is the real reason to use them, beyond just less code (topics 7, 20).22Typing props & components★ core idea▶
Scenario: you pass <UserCard user={u} onSelect={fn}/> but forget onSelect, or pass a number where a string goes — and only find out at runtime. Typing your props makes the compiler check every usage, and gives every consumer autocomplete.
What
Declaring a component's props as a TypeScript type/interface, so React (and your editor) know exactly what each component requires and provides.
Why
Typed props catch missing/wrong props at author-time, document the component's API, and power autocomplete — turning a whole class of runtime bugs into red squiggles.
How
Define a Props type and annotate the function's parameter. Prefer typing the props object directly over React.FC (which has downsides). Use ? for optional props.
type Props = { name: string; role?: "admin" | "user"; onSelect: () => void }
function UserCard({ name, role = "user", onSelect }: Props) { ... }
<UserCard name="Ada" onSelect={fn} /> ✓
<UserCard name={42} /> ✗ number not string; onSelect missing
editor autocompletes name/role/onSelect as you type the JSX
type Props = {
name: string;
role?: "admin" | "user"; // optional, and a union of allowed values
onSelect: (id: string) => void; // typed callback
children?: React.ReactNode; // if it wraps content
};
function UserCard({ name, role = "user", onSelect, children }: Props) {
return <div onClick={() => onSelect(name)}>{name} — {role}{children}</div>;
}
// callers now get autocomplete + errors for missing/wrong props✅ Do
- Define a
Propstype and annotate the props parameter directly - Use unions for constrained props (
"admin" | "user") and?for optional - Type
childrenasReact.ReactNodewhen the component wraps content
❌ Don't
- Default to
React.FC— it implicitly addschildrenand complicates generics - Type props as
any— you throw away the whole benefit (topic 18 of Node/TS)
React.FC<Props> — it used to implicitly add a children prop (so components that shouldn't accept children silently did), interacts poorly with generic components, and complicates default props. Typing the props parameter directly (function C(props: Props)) is now the recommended default, adding children explicitly only when needed. If you inherited a codebase full of React.FC, it's not broken — just know why new code avoids it (topic 23).23Typing hooks — useState, useRef, generics▶
Scenario: useState() with no initial value infers undefined, then TS complains when you set a real object. And a ref to a DOM node needs the right element type. Typing hooks correctly is a small skill that removes a lot of friction.
What
Giving hooks explicit type arguments when inference isn't enough: useState<T>, useRef<T>, typed reducers, and typed custom hooks.
Why
Good types here mean state, refs, and reducer actions are all checked — no any leaking in, and the compiler guides every update and access.
How
Let inference work when there's an initial value; add a type argument when starting null/empty (useState<User | null>(null)); type DOM refs by element (useRef<HTMLInputElement>(null)).
useState(0) → inferred number ✓ (no annotation) useState(null) → inferred null (can't set a User!) ✗ useState<User | null>(null) → explicit → set null OR a User ✓ useRef(null) → generic, no element methods useRef<HTMLInputElement>(null) → ref.current?.focus() typed ✓
HTMLInputElement is choosing the exact socket, so .focus() and other input-specific tools actually work. The type picks the correctly-sized tool.const [count, setCount] = useState(0); // inferred: number
const [user, setUser] = useState<User | null>(null); // explicit union
const inputRef = useRef<HTMLInputElement>(null); // typed DOM ref
inputRef.current?.focus(); // .focus() is known
// typed reducer: state + action types checked
const [state, dispatch] = useReducer(reducer, initial as CartState);
dispatch({ type: "add", item }); // action shape checked against your unionuseState(0) obviously holds a number. But when you start with "nothing" (like null) and plan to put a real object in later, you need to tell TypeScript what it can hold: useState<User | null>(null). Same for refs pointing at a specific kind of HTML element — label it so the element's buttons (like .focus()) are available. Small labels, much less fighting the compiler.✅ Do
- Let inference type state that has a real initial value
- Add a type argument when starting from
null/empty (useState<T | null>) - Type DOM refs by their element (
useRef<HTMLInputElement>(null))
❌ Don't
- Leave
useState(null)untyped then cast later withas - Type refs as
any— you lose the element's methods and safety
useState(null) or useState([]) infers an over-narrow type (null, or never[]) that then rejects the real values you try to set — a confusing "Type 'User' is not assignable to type 'null'" error that has nothing to do with your logic. The fix is always an explicit type argument at the empty/null start. And a DOM ref you attach via JSX must be typed useRef<HTMLInputElement>(null) — starting from null because the element doesn't exist until mount (topics 14, 22).24Typing events & forms▶
Scenario: in an onChange you write e.target.value but TS doesn't know e's type, or you guess e: any and lose all safety. React has precise event types — using them makes handlers fully typed, with autocomplete on the event.
What
Using React's synthetic event types (React.ChangeEvent<HTMLInputElement>, React.FormEvent, React.MouseEvent) to type handler parameters precisely.
Why
Typed events give you correct autocomplete on e.target, catch wrong element assumptions, and keep forms fully type-safe end to end.
How
Often you can let TS infer the event type from an inline handler on a JSX element. When defining a handler separately, annotate it with the matching React event type.
onChange={(e) => ...} inline -> TS INFERS e's type from the element ok
separate handler needs an explicit type:
ChangeEvent<HTMLInputElement> -> e.target.value is a string
FormEvent<HTMLFormElement> -> e.preventDefault()
MouseEvent<HTMLButtonElement> -> click handlers
e: any -> works but throws away all the safety no
ChangeEvent<HTMLInputElement> fits an input and exposes .value; using the wrong one (or any) is jamming a mismatched adapter and losing the pins (autocomplete/safety)..value; a form's submit event carries .preventDefault(). Guessing any is opening an unlabeled envelope and hoping the right thing is inside.// inline: TS infers e's type from the element (no annotation needed)
<input onChange={(e) => setName(e.target.value)} /> // e is ChangeEvent
// standalone handler: annotate explicitly
function onChange(e: React.ChangeEvent<HTMLInputElement>) {
setName(e.target.value); // .value is a typed string
}
function onSubmit(e: React.FormEvent<HTMLFormElement>) {
e.preventDefault(); save();
}
<form onSubmit={onSubmit}><input onChange={onChange} /></form>.value for an input). Good news: if you write the handler right there in the JSX, TypeScript usually figures out the type for you. If you write the handler as a separate function, you give it a label like React.ChangeEvent<HTMLInputElement>. Avoid any — that throws away all the help.✅ Do
- Let inline handlers infer the event type from the JSX element
- Annotate standalone handlers with the matching React event type
- Use
React.FormEvent+preventDefault()for typed form submits
❌ Don't
- Type events as
any— you losee.targetsafety and autocomplete - Guess the element type — match it (
HTMLInputElementvsHTMLSelectElement)
const handleChange = (e) => …, e becomes an implicit any (an error under strict) and you must annotate it. Also, e.target vs e.currentTarget differ: currentTarget is typed to the element the handler is on, while target can be a descendant — reach for currentTarget when you need the typed element (topics 10, 23).25Performance — memo, re-renders, code splitting▶
Scenario: your app got slow — janky typing, sluggish lists, a huge initial bundle. React performance is mostly about fewer, cheaper renders and shipping less code. A handful of tools (React.memo, virtualization, code splitting) fix the vast majority of it.
What
The core React perf levers: React.memo (skip re-render if props are equal), stable identities (topic 15), list virtualization, and code splitting (lazy-load parts of the bundle).
Why
The two costs are runtime (too many/too-heavy re-renders) and load (too big a bundle). Targeting the real bottleneck — found by profiling — beats guessing.
How
Profile with React DevTools; wrap genuinely-heavy children in memo with stable props; virtualize long lists; lazy+Suspense to split routes/heavy components.
RENDER: React.memo(Child) → skip re-render when props are equal
(needs STABLE props — useCallback/useMemo, topic 15)
virtualize 10k rows → render only the ~20 visible ones
LOAD: const Heavy = lazy(() => import("./Heavy")) → separate chunk
<Suspense fallback={<Spinner/>}><Heavy/></Suspense>
→ PROFILE first (React DevTools) to find the REAL bottleneck
memo is telling the painters "skip this room, nothing changed here." Virtualization is only furnishing the rooms guests are actually in. You save enormous effort by not redoing untouched work — but only where it's genuinely heavy.const Row = React.memo(function Row({ item, onPick }) { // skip if props equal
return <li onClick={() => onPick(item.id)}>{item.name}</li>;
});
// props must be STABLE or memo does nothing (topic 15):
const onPick = useCallback((id) => pick(id), []);
// code splitting: load a heavy component only when needed
const Chart = lazy(() => import("./Chart"));
<Suspense fallback={<Spinner />}><Chart /></Suspense>React.memo), and for giant lists, only draw the rows people can see. For the second, you split your code so the browser loads big pieces only when needed. Most important: measure first with React's profiler to find what's actually slow, instead of guessing.✅ Do
- Profile with React DevTools before optimizing — find the real bottleneck
- Virtualize long lists; code-split routes and heavy components with
lazy - Wrap genuinely-heavy children in
memo— with stable props (topic 15)
❌ Don't
memoeverything by default — it has a cost and often does nothing without stable props- Optimize by guessing — measure, or you'll add complexity for no gain
React.memo is widely cargo-culted and just as widely useless — it only skips a re-render if the props are referentially equal, so a memoized child that receives an inline object, array, or arrow function (onClick={() => …}) re-renders every single time anyway, because those are new references each render. Memo without useCallback/useMemo on its props is a no-op that adds a comparison cost for nothing. Profile, then memoize the right boundary with stable props — or restructure so the state lives closer to where it's used (topics 5, 15).26Error boundaries & Suspense▶
Scenario: one component throws during render and your entire app goes blank white. And async components need a loading fallback. Error boundaries catch render errors and show a fallback; Suspense shows a fallback while something loads — both contain failure to a region.
What
Error boundaries are components that catch render/lifecycle errors in their subtree and render a fallback instead of crashing the whole app. Suspense shows a fallback while a lazy component or data is loading.
Why
Without a boundary, one thrown error unmounts the entire React tree (blank screen). Boundaries + Suspense keep failures and loading states local and graceful.
How
Wrap a subtree in an error boundary (a class component with componentDidCatch, or a library) and/or <Suspense fallback={…}>. Place them around risky/async regions.
no boundary: <Widget/> throws → WHOLE app unmounts → blank screen ✗
<ErrorBoundary fallback={<Oops/>}>
<Suspense fallback={<Spinner/>}>
<Widget/> throws → <Oops/> shows; rest of app is FINE ✓
</Suspense> loading → <Spinner/> shows here only
</ErrorBoundary>
class ErrorBoundary extends React.Component {
state = { hasError: false };
static getDerivedStateFromError() { return { hasError: true }; }
componentDidCatch(err, info) { log(err, info); } // report it
render() {
return this.state.hasError ? <Fallback /> : this.props.children;
}
}
<ErrorBoundary>
<Suspense fallback={<Spinner />}>
<LazyDashboard /> {/* loads async; errors caught above */}
</Suspense>
</ErrorBoundary>✅ Do
- Wrap risky/async regions (and route roots) in error boundaries
- Use
Suspensewithlazyand data libraries for clean loading fallbacks - Report caught errors (logging/monitoring) from
componentDidCatch
❌ Don't
- Rely on error boundaries to catch event handler or async errors — they only catch render/lifecycle
- Ship without any boundary — one thrown render error blanks the whole app
setTimeout, promises), or the boundary itself. A click handler that throws sails right past the boundary (handle those with try/catch). Also, boundaries are still class-only in React (no hook equivalent yet), which is why teams use a small library or a shared class boundary. Place them thoughtfully — one at the app root plus a few around independent regions (topics 8-ish, 21).27Testing components — React Testing Library★ core idea▶
Scenario: you refactor a component's internals and every test breaks, even though the UI works identically — because the tests checked implementation details. React Testing Library flips this: test what the user sees and does, so tests survive refactors and catch real breakage.
What
Testing components the way a user experiences them: render the component, query by accessible text/role, simulate user interactions, and assert on the visible result — with React Testing Library (RTL) + Vitest/Jest.
Why
Tests tied to internals (state, instance methods) break on every refactor and don't prove the UI works. User-centric tests survive refactors and catch what users would actually notice.
How
Render, query by role/label/text (not CSS classes or internals), fire events via userEvent, and assert on the DOM. "Test behavior, not implementation."
IMPLEMENTATION test: assert state.count === 1 → breaks on refactor ✗
USER-CENTRIC test:
render(<Counter/>)
userEvent.click(screen.getByRole("button"))
expect(screen.getByText("1")).toBeInTheDocument() ← what users see ✓
query priority: role > label > text (accessible, like a real user)
import { render, screen } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
test("increments on click", async () => {
render(<Counter />);
await userEvent.click(screen.getByRole("button")); // act like a user
expect(screen.getByText("1")).toBeInTheDocument(); // assert visible result
});
// no peeking at state/props — refactor internals freely, test still holds✅ Do
- Query by accessible role/label/text — the way a user (and screen reader) finds things
- Simulate real interactions with
userEventand assert on visible output - Test behavior and edge cases, not internal state or implementation
❌ Don't
- Assert on component state, instance methods, or CSS class names — brittle
- Query by test-id when a role/label works — you're bypassing accessibility (topic 28)
data-testid everywhere or querying by class name, recreating the exact brittleness RTL prevents. The query priority (role → label → text → testid-as-last-resort) isn't bureaucracy: querying by role both tests the UI and verifies accessibility (topic 28) at the same time. If you can't find an element by role or label, that's often a sign the component isn't accessible — a real bug, not a testing inconvenience (topic 28).28Accessibility (a11y) — usable by everyone▶
Scenario: you build a "button" out of a <div onClick>, and keyboard users can't reach it, screen readers don't announce it, and you've locked out real users. Accessibility isn't a nice-to-have — it's building UI that everyone, including assistive-tech users, can actually use.
What
Building UIs usable with keyboards, screen readers, and other assistive tech: semantic HTML, proper roles/labels, focus management, and sufficient contrast (the WCAG guidelines).
Why
It's the difference between a usable and unusable app for millions of people — and it's often legally required. Bonus: accessible markup is more testable (topic 27) and better for SEO.
How
Use real semantic elements (<button>, <label>, <nav>) instead of div soup; label inputs; manage focus; add ARIA only when semantics don't suffice.
✗ <div onClick={...}>Save</div>
not focusable, not keyboard-activatable, not announced as a button
✓ <button onClick={...}>Save</button>
keyboard + Enter/Space + screen-reader "button" role — all FREE
<label htmlFor="email">Email</label><input id="email" /> labeled input
rule: reach for the real element FIRST; add ARIA only to fill gaps
<div>-button is a set of stairs with no ramp — fine for some, impossible for others. A real <button> is a building designed with ramps and elevators from the start. Accessibility is universal design: it works for everyone, and retrofitting later is far harder.<div> where a <button> belongs, there's no caption, and that user is left out.// ✓ accessible: real button + labeled input
<button onClick={save} disabled={!valid}>Save</button>
<label htmlFor="email">Email</label>
<input id="email" type="email" value={email} onChange={onChange} />
// manage focus for modals/routes:
useEffect(() => { closeBtnRef.current?.focus(); }, []); // focus on open
// add ARIA only when no semantic element fits:
<div role="alert">{error}</div> // announces changes<button> can be reached with the keyboard and is announced as a button automatically; a <div> pretending to be a button leaves those users stuck. Label your inputs, manage focus for pop-ups, and only add special "ARIA" hints when a normal element won't do. It's the right thing to do and often the law.✅ Do
- Use semantic elements (
button,label,nav, headings) — they're accessible by default - Label every input, ensure keyboard operability, and manage focus for modals/routes
- Test with keyboard + a screen reader; query by role in tests (topic 27)
❌ Don't
- Build interactive controls from
<div>/<span>+ onClick — inaccessible - Sprinkle ARIA to "fix" bad markup — correct semantics first; ARIA is a last resort
role/aria-* to patch a <div> button, but a real <button> gives you focus, keyboard activation, and the correct role for free, while hand-rolled ARIA is easy to get subtly wrong (and wrong ARIA actively misleads screen readers). Semantic HTML first; ARIA only to fill genuine gaps. A great free check: try using your whole feature with only the keyboard — if you can't, neither can a large group of your users (topic 27).29Common pitfalls & anti-patterns▶
Scenario: "Why does my state not update? Why does this effect loop forever? Why is there a random 0 on screen?" Most React frustration comes from a small set of recurring anti-patterns. Knowing them by name means you spot and avoid them instantly.
What
The recurring React mistakes: mutating state, missing/wrong effect deps, index keys, storing derivable data, unnecessary effects, the && 0 render, and prop-drilling everything.
Why
These few patterns cause the majority of React bugs. Recognizing them turns hours of confused debugging into "oh, that's the index-key bug."
How
Treat state as immutable; trust the deps lint rule; key by id; derive instead of store; avoid effects for non-external sync; guard && with booleans.
✗ state.push(x); setState(state) → same ref, no re-render (topic 4)
✗ useEffect(fn, []) using changing values → stale (topic 13)
✗ key={index} on reorderable lists → wrong DOM reused (topic 8)
✗ store derivable data + sync effect → drift (topic 19)
✗ useEffect to compute for render → extra render, bugs (topic 7)
✗ {count && <X/>} → renders "0" (topic 9)
// ✗ mutate → ✓ new reference
setItems([...items, x]); // not items.push(x); setItems(items)
// ✗ derived state + effect → ✓ derive during render
const fullName = `${first} ${last}`; // not useState + useEffect sync
// ✗ index key → ✓ stable id
{rows.map((r) => <Row key={r.id} {...r} />)}
// wrong: {n && <X/>} can render "0" → right: boolean guard
{n > 0 && <X />}&& that prints a "0." Once you know these by name, you spot them instantly and stop losing hours to them. This card is your cheat-sheet of "don't do these."✅ Do
- Treat state as immutable; derive values; key by id; trust the deps lint rule
- Ask "do I need this in state, or can I compute it?" (topic 19)
- Reserve effects for syncing with things outside React (topic 7)
❌ Don't
- Mutate state, use index keys on dynamic lists, or silence the deps warning
- Reach for an effect when computing during render or handling an event would do
useEffects and memos — step back and ask which rule of the model you're violating. The fix is usually removing code, not adding it (topics 4, 7, 19).30Capstone — a typed, data-driven feature★ capstone▶
Scenario: assemble everything into one real feature — a typed, accessible user list that fetches from a server, filters, handles loading/error/empty, and won't crash the app. This is the whole playbook working together.
What
A production React + TS feature wiring together typed components & hooks, a data cache, derived state, controlled inputs, error boundaries, accessibility, and memoization where it matters — the concepts from all 29 topics.
Why
Any one piece is easy alone. Production is making them coexist: type-safe, data-driven, accessible, resilient, and fast enough — without over-engineering.
How
Typed props/hooks → fetch via a query cache → derive the filtered view → controlled search input → handle every UI state → wrap in an error boundary → accessible markup.
<ErrorBoundary> (26 — contain crashes)
<Suspense fallback={<Spinner/>}> (26 — loading)
useQuery(["users"]) server cache (21 — no effect fetch)
const [q,setQ]=useState("") controlled input (4,10,24 — typed)
const shown = users.filter(...) DERIVED (19 — not stored)
status → loading/error/empty/list states (9)
<label>+<input> · <ul>key={u.id} accessible+keys (8,28)
</Suspense>
</ErrorBoundary>
types on props/hooks/events throughout (22,23,24) · memo only if profiled (25)
type Props = { initialQuery?: string };
function UserList({ initialQuery = "" }: Props) {
const [q, setQ] = useState(initialQuery); // typed controlled (4,24)
const { data, isLoading, error } = useQuery({ // server cache (21)
queryKey: ["users"], queryFn: fetchUsers,
});
const shown = useMemo( // derive (19); memo if heavy (25)
() => (data ?? []).filter((u) => u.name.includes(q)),
[data, q]
);
if (isLoading) return <Spinner />; // states (9)
if (error) return <ErrorMsg />;
return (
<section>
<label htmlFor="s">Search</label> {/* accessible (28) */}
<input id="s" value={q} onChange={(e) => setQ(e.target.value)} />
{shown.length === 0
? <Empty />
: <ul>{shown.map((u) => <li key={u.id}>{u.name}</li>)}</ul>} {/* keys (8) */}
</section>
);
}
// wrap the tree in <ErrorBoundary> + <Suspense> (26); type every boundary (22,23,24)✅ Do
- Type props, hooks, and events; treat server data as a cache; derive don't store
- Handle every UI state, key by id, use accessible markup, and add a boundary
- Optimize only what you profiled — reach for memo/virtualization when needed (topic 25)
❌ Don't
- Bolt on accessibility, error handling, and types "later" — later is after the bug report
- Over-engineer a simple feature with global stores, memo-everything, and abstractions it doesn't need
Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.