UI Layer · React & TypeScript · Field Guide

React & TypeScript Pro Playbook

From JSX to a typed, data-driven feature — how React really renders, how hooks work, and how to type it all so the compiler catches your bugs. Explained for students and pros.

The frontend layer of the Field Library
What it is Why it matters How it works In plain words ASCII diagram
Part I · React Fundamentals
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 vs declarative UI
  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"
🧠
Analogy 1 — a thermostat vs adjusting the furnace by hand: imperative is you running to the furnace every time to nudge it. Declarative is setting a target temperature (state) and letting the thermostat (React) do whatever's needed to reach it. You declare the goal; the system handles the steps.
🧾
Analogy 2 — a spreadsheet formula: you don't manually recompute a cell when inputs change — you write =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.
UI as a function of state
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).
🧒
In plain wordsNormally, to update a web page you have to find each piece and change it yourself — exhausting and easy to mess up. React lets you instead just describe what the page should look like for the current data, and it handles updating the actual page for you. Change the data, and React redraws the parts that need it. You say "here's how it looks," not "here's every step to change it."

✅ 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
Gotcha: the mental shift trips up everyone coming from jQuery/vanilla JS — in React you almost never touch the DOM directly, because React owns it and will overwrite your manual changes on the next render. If you find yourself reaching for 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.

JSX compiles to function calls
  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)
🧠
Analogy 1 — a photo filter that looks like the real scene: JSX looks like HTML the way a filtered photo looks like the moment — familiar, but it's actually data (function calls) underneath. You get HTML's readability with JavaScript's power, because it's JavaScript wearing an HTML costume.
📝
Analogy 2 — shorthand a stenographer writes: JSX is shorthand that expands into longhand (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.
It's JavaScript — use JS inside {}
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
🧒
In plain wordsJSX is the HTML-looking code you write inside React. But it's a trick — it's actually JavaScript in disguise. A build tool turns it into regular function calls. That's why you can drop real JavaScript inside curly braces { } (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 class or for — they're JS reserved words (use className, htmlFor)
  • Put statements (if, for) directly in JSX — only expressions go in {}
Gotcha: because {} 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.

Props flow down; components compose
   <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
🧠
Analogy 1 — a cookie cutter: the component is the cutter (one shape), props are the dough you press it into (different data). One cutter makes many cookies of the same shape but different flavors. You don't carve each cookie by hand — you reuse the cutter with new dough.
📋
Analogy 2 — a fill-in-the-blank template: a component is a form letter with blanks; props fill the blanks ("Dear ___, your order ___"). Same template, personalized output. And like a printed letter, the child can't change the sender's original — props are read-only.
One component, many uses via props
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
🧒
In plain wordsA component is a reusable piece of UI — like a "user card" you can drop in anywhere. Props are the info you hand it, like the specific user's name and photo. So instead of building ten nearly-identical cards, you build one card component and give it different props each time. The info always flows downward from parent to child, and the child treats it as read-only.

✅ 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
Gotcha: props are strictly one-way and read-only — a child can't change its own props or push data back up by mutating them. To let a child affect the parent, the parent passes a callback prop (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.

Setter triggers a re-render
  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)
🧠
Analogy 1 — a whiteboard with a bell: a plain variable is scribbling on scrap paper no one checks. 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.
📮
Analogy 2 — a request slip, not a live edit: calling the setter is submitting a "please change to X" slip, not editing on the spot. React processes slips in batches, then re-renders. That's why reading the value right after setting it still shows the old one — the slip hasn't been processed yet.
State that re-renders; functional updates
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)
🧒
In plain wordsIf you change a normal variable, React has no idea and the screen stays the same. useState gives you a special value plus a "change it" button (the setter). When you press that button, React not only updates the value but also redraws the component to show it. One catch: the update isn't instant — it's more like submitting a request — so when the new value depends on the old one, use the 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
Gotcha: two classic traps. First, state updates are asynchronous and batched, so reading the variable immediately after 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.

What triggers a re-render
  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
🧠
Analogy 1 — redrawing a blueprint vs rebuilding the house: a render is redrawing the blueprint (cheap-ish); committing is the construction crew changing only the walls that differ. React redraws the blueprint often but only rebuilds what actually changed. Slowness comes from redrawing giant blueprints too often.
🌊
Analogy 2 — a ripple in a pond: a parent re-rendering sends ripples down to all children by default, even ones whose data didn't change. Most are harmless, but a heavy child caught in every ripple is your performance leak. Memoization (topic 25) is the breakwater.
Parent render ripples to children
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
🧒
In plain words"Rendering" means React runs your component function again to see what the UI should look like now. It does this whenever the component's data (state or props) changes — or whenever its parent re-runs. Important: running the function is not the same as changing the actual page — React compares the new result to the old and only updates the real differences. Too many unnecessary re-runs of heavy components is what makes React apps feel slow.

✅ 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)
Gotcha: the surprise for most people is that a parent re-render re-runs all its children by default, regardless of whether their props changed. This is usually fine (React's diff is fast), which is why premature 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.

Diff the copy, patch only differences
  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
🧠
Analogy 1 — track changes in a document: instead of reprinting the whole document for one edit, "track changes" shows exactly what differs and you apply just those edits. The virtual DOM is the draft copy; reconciliation is track-changes; the commit is applying only the marked edits to the real page.
🏗️
Analogy 2 — a renovation vs a rebuild: you don't demolish the whole house to move one wall. React drafts the new floor plan (virtual DOM), compares it to the current house, and the crew changes only the wall that moved. Cheap diff on paper, minimal real construction.
You "recreate" the UI; React patches minimally
// 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.
🧒
In plain wordsChanging the real web page is slow, so React keeps a lightweight copy in memory. When your data changes, React makes a new copy, compares it to the old one, and figures out the tiny list of real changes needed — then applies just those. So even though you write code as if redrawing everything, React is smart enough to only touch the few things that actually changed. That compare-and-patch step is called reconciliation.

✅ 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
Gotcha: reconciliation matches elements by position and type, which has a surprising consequence — if you conditionally render two different component types in the same spot, or reorder a list without stable keys, React can tear down and recreate DOM (and lose component state like input focus or scroll) instead of updating in place. It can also accidentally preserve state you wanted reset. Stable keys and stable component structure are what keep reconciliation doing the efficient, correct thing (topic 8).
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).

Effect + cleanup tied to deps
  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)
🧠
Analogy 1 — a subscription you must cancel: an effect is subscribing to a magazine when you move into an apartment (mount); the cleanup is canceling it when you move out (unmount) so the next tenant isn't billed. Forget the cleanup and subscriptions pile up — that's a memory leak.
🔌
Analogy 2 — plugging in and unplugging a device: the effect plugs your component into an external outlet (an API, an event). The cleanup unplugs it. Every plug needs a matching unplug, or you leave live wires (dangling listeners, timers) draining the system.
Subscribe on mount, clean up on unmount
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)
🧒
In plain wordsSome things need to happen outside of just drawing the UI — like fetching data, starting a timer, or listening for events. 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
Gotcha: 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.

Index keys break on reorder/insert
  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 ✓
🧠
Analogy 1 — name tags vs seat numbers: identifying people by seat number (index) fails the moment everyone shifts one seat — you'd think person 1 turned into person 2. Name tags (stable ids) follow the person wherever they sit. Keys are name tags; indexes are seat numbers.
🏷️
Analogy 2 — coat-check tickets: the coat check gives each coat a unique ticket (key), so no matter how the coats are rearranged on the rack, yours is found correctly. Numbering by rack position instead would hand you the wrong coat every time the rack shifts.
Stable id keys, not the index
// ✓ 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.
🧒
In plain wordsWhen you show a list, React needs a way to tell the items apart between updates — a key, which should be a unique id for each item (like a database id). If you instead use the item's position number, things break when you add, remove, or reorder items: React gets confused about which is which, and stuff like typed-in text or checkboxes jumps to the wrong row. Always key by a stable id, not by position.

✅ 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
Gotcha: using the array index as a key is the classic subtle bug — it works fine until the list is reordered, filtered, or has items inserted/removed, at which point React reuses the wrong DOM nodes and component state "sticks to the position" rather than the data. The visible symptoms are bizarre: a deleted row's checkbox state appears on a different row, an input keeps the previous item's text, or animations glitch. It looks like a data bug but it's a keying bug — use stable ids (topic 6).
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.

Model every UI state explicitly
  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)!
🧠
Analogy 1 — a traffic sign that changes with conditions: the same signpost shows "road clear," "detour," or "closed" depending on the situation. Conditional rendering is that signpost — one place in the UI that displays the right thing for the current state, so users are never shown a stale or wrong message.
🚪
Analogy 2 — a hotel front desk routing you: depending on your status (checking in, no reservation, VIP), the desk sends you down a different path. Your component "routes" the user to loading, error, or content UI based on state — each state gets its own clear path.
Handle every state; avoid the 0 trap
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 traps
🧒
In plain wordsYour UI usually has to show different things at different times — a loading spinner, an error, the actual content, or an "empty" message. Conditional rendering is just using simple if-like checks inside your component to pick which one to show right now. The main trap: a common shortcut (count && ...) 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 renders 0 when count is 0 (topic 2)
  • Forget the empty/error states — users see blank or broken UI
Gotcha: the && 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.

Controlled input: state is the source of truth
   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)
🧠
Analogy 1 — a puppet on strings: a controlled input is a puppet — it only shows what the puppeteer (state) says, and every move it makes is reported back to the puppeteer. React holds the strings, so it always knows the exact pose. An uncontrolled input is a puppet that moves on its own; you have to go check its position.
🪞
Analogy 2 — a mirror that reports back: the input mirrors state (shows its value) and, whenever touched, tells state what changed. State and input stay perfectly in sync, so you never have to ask the DOM "what's in the box?" — you already know from state.
A controlled input
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>
  );
}
🧒
In plain wordsWhen you build a form in React, the cleanest way is to let React own what's typed. You show the input's value from state, and every keystroke updates that state. So the input and your data are always in sync, and you can easily check it ("is it empty?"), fix it, or submit it — all from state, without ever poking at the page to ask "what did they type?" That's a "controlled" input.

✅ Do

  • Bind value to state and update it in onChange (controlled input)
  • Call e.preventDefault() in form onSubmit to stop a full page reload
  • Validate and derive UI (disabled buttons, errors) from the state value

❌ Don't

  • Set value without an onChange — the input becomes read-only (a warning)
  • Read input values from the DOM when React state already has them
Gotcha: a common React warning is "a component is changing an uncontrolled input to be controlled" — caused by initializing 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.

children = content between the tags
   <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
🧠
Analogy 1 — a picture frame: a <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.
📦
Analogy 2 — a gift box: the box (wrapper component) provides wrapping and a bow (layout/behavior); 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.
A flexible wrapper via children (and slots)
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>
🧒
In plain wordsInstead of making a separate component for every layout, you make wrappers that hold whatever you put inside them. Anything you place between a component's opening and closing tags becomes its 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 children for 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 children would do
  • Deeply prop-drill data through many layers — consider context (topic 16)
Gotcha: a powerful, underused composition trick solves performance and prop-drilling: passing a component as 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).
Part II · Hooks & State
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.

React matches hooks by call order
  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
🧠
Analogy 1 — numbered lockers assigned by arrival order: React hands out lockers (state slots) in the exact order hooks show up. Skip a hook one render and everyone shifts down a locker — you open locker 1 and find someone else's stuff. Fixed order = everyone keeps their locker.
🎼
Analogy 2 — a song that must play the same notes in the same order: React "plays" your hooks in sequence each render and expects the identical sequence. Skip or reorder a note (conditional hook) and the whole melody (state mapping) goes wrong. Same notes, same order, every time.
Condition inside the hook, not around it
// ✗ 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 violations
🧒
In plain wordsHooks (like useState) 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-hooks to 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
Gotcha: the rule seems arbitrary until you know the mechanism: React stores hook state in an ordered list and matches each hook call to a slot by position, not by any name. So an early 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.

Missing dep = stale; unstable dep = loops
  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
🧠
Analogy 1 — a standing reminder tied to triggers: the deps are the "remind me when X changes" list. Leave X off and you never get reminded (stale). Put a thing that "changes" constantly on the list and you get pinged nonstop (infinite loop). The list must name exactly the real triggers.
📸
Analogy 2 — a photo caption that forgets to update: an empty deps array is a caption written once and never revised, even as the photo changes underneath — it now describes the wrong picture (stale closure). You must list what the caption depends on so it re-writes when those change.
List every value; stabilize objects
// 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 identity
🧒
In plain wordsThe little list at the end of useEffect 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
Gotcha: the seductive mistake is silencing the 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.

Ref: persists, but doesn't re-render
  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
🧠
Analogy 1 — a sticky note on your monitor: state is writing on a shared board that alerts the whole team (re-render). A ref is a private sticky note — you can jot and update it freely, and nobody's alerted. Perfect for "remember this for later" that shouldn't trigger a redraw.
🎫
Analogy 2 — a coat-check stub for a DOM element: a ref can hold a direct handle to a real input box, like a claim ticket. When you need to focus it or measure it, you present the ticket and get the actual element. State can't give you that physical handle — a ref can.
DOM handle + a non-rendering value
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 would
🧒
In plain wordsSometimes you need to remember something between renders but you don't want the screen to redraw when it changes — like a timer's id, or a direct handle to an input box so you can focus it. useState 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 .current in 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.current during rendering to compute UI — it's not reactive
Gotcha: the defining trait — mutating a ref does not trigger a re-render — is exactly what makes it a footgun when misused. Store display data in a ref and the value updates but the screen doesn't, leaving UI out of sync with a bug that looks like "React isn't updating." Refs are for things outside the render output. The decision rule: if changing it should change what the user sees, it's 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).

Cache by dependencies; preserve identity
  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
🧠
Analogy 1 — a saved calculation on a receipt: useMemo is writing down a hard sum so you don't recompute it every time someone asks — you only redo it when the numbers change. Recomputing a giant total on every glance is wasteful; the saved receipt is the memo.
🪪
Analogy 2 — the same ID card, not a new one daily: without useCallback, you hand out a freshly-printed ID (new function) every render, so anyone checking "is this the same person?" says "no, re-verify" (child re-renders). useCallback gives you one stable ID card that stays valid until your details change.
Memoize compute and callback identity
// 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)
🧒
In plain wordsEvery time a component redraws, it rebuilds everything inside it — including expensive calculations and functions. 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 useMemo for genuinely expensive computations
  • Use useCallback/useMemo to 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
Gotcha: memoization is not free — every 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.

Provide at the top, read anywhere below
  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
🧠
Analogy 1 — a building's public address system: instead of passing a memo hand-to-hand down every floor (prop-drilling), the PA system broadcasts to the whole building at once. Anyone who cares tunes in (useContext); those who don't aren't bothered. Context is the PA for a subtree.
💧
Analogy 2 — building-wide plumbing: you don't carry water bucket-by-bucket up each floor; you run pipes and any room can open a tap (useContext). The Provider is the water main for its part of the building — rooms below just tap in when they need it.
Provide once, consume deep
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} />;
}
🧒
In plain wordsSome data (like who's logged in, or the color theme) is needed all over your app. Passing it down through every component in between — even ones that don't use it — is annoying ("prop-drilling"). Context fixes this: you set the value once at the top, and any component deep inside can grab it directly, skipping all the middle layers. It's like a building-wide announcement instead of passing a note desk to desk.

✅ 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 value so 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)
Gotcha: context is a dependency-injection tool, not a state-management or performance tool — and misusing it as the latter bites. Every consumer re-renders whenever the provider's 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.

Dispatch actions → reducer → new state
   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
🧠
Analogy 1 — a vending machine: you don't reach inside and rearrange the mechanics — you press a button (dispatch an action) and the machine's internal logic (reducer) decides what happens next. All the "what a button does" rules live in one place, not scattered across whoever presses buttons.
🏦
Analogy 2 — a bank teller processing requests: customers submit slips ("deposit $50," "withdraw $20") — those are actions. The teller (reducer) applies bank rules to update the balance. Customers never edit the ledger directly; they submit requests and one authority applies them consistently.
Centralize transitions in a reducer
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 directly
🧒
In plain wordsWhen a component's state gets complicated — lots of related values that change together in different ways — sprinkling many useStates 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 — useState is simpler
  • Mutate state inside the reducer — always return a new object
Gotcha: the reducer must be a pure function — no API calls, no mutation, no randomness inside it. React may call your reducer twice in development (Strict Mode) to help surface impurity, so a reducer with side effects will fire them twice and behave erratically. Side effects belong in event handlers or effects, not the reducer. And like 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.

Extract shared logic; each caller gets own state
  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 ✓
🧠
Analogy 1 — a recipe you can reuse: a custom hook is a written recipe (the steps/logic). Each cook who follows it makes their own dish (own state) in their own kitchen. You share the method, not the food. Copy-pasting the steps into every cookbook would be the pre-hook mess.
🧰
Analogy 2 — a power tool vs building the motor each time: instead of wiring a motor from scratch in every project (copy-pasted logic), you grab the same drill (custom hook). Each project uses its own bit and material (independent state), but the tool's mechanism is shared and battle-tested.
A reusable useFetch hook
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");
🧒
In plain wordsIf several components need the same bit of behavior — like "load some data and track loading/error" — you shouldn't copy-paste that code into each one. Instead you make a custom hook: a reusable function (its name starts with 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 useX hooks
  • Name them starting with use so 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
Gotcha: the crucial mental model: custom hooks share logic, not state. Two components calling 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.

One source of truth; compute the rest
  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
🧠
Analogy 1 — one shared clock on the wall: if two people keep their own watches (duplicated state), they drift apart. Put one clock on the shared wall (lift state to the parent) and both read the same time. Derived state is like reading the date from the clock rather than writing it on a second sticky note that can go stale.
🧮
Analogy 2 — a spreadsheet total: you don't type the total in a cell and update it by hand (duplicated, drift-prone) — you write =SUM(...) so it's always correct (derived). And shared inputs live in one authoritative cell that other formulas reference (lifted), not copied around.
Lift shared state; derive the rest
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
🧒
In plain wordsTwo rules that prevent a huge class of bugs. (1) If two components need the same piece of information, don't give each its own copy — put it in their shared parent and pass it down ("lift it up"), so there's one source of truth. (2) Don't store something you can calculate from what you already have (like counting a list) — just calculate it while drawing. Stored copies drift out of sync; a single source and calculated values stay correct.

✅ 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
Gotcha: the most common state anti-pattern is storing derivable data — e.g. keeping 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).

Put each state at the right level
  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
🧠
Analogy 1 — where you keep your stuff: your toothbrush lives in your bathroom (local), the shared remote in the living room (lifted/context), important documents in a home safe (global store), and library books belong to the library, just borrowed (server cache). Putting everything in the safe (all-global) makes daily life miserable.
🗄️
Analogy 2 — org structure: a decision one person makes stays with them (local); a team decision goes to the team lead (lifted); a company policy goes to HQ (global store). Escalating every tiny choice to HQ grinds the company to a halt — keep decisions as local as possible.
Match the tool to the state
// 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
🧒
In plain wordsAs your app grows, you have to decide where each piece of information lives. A checkbox's on/off? Keep it right in that component (local). Something a few nearby components share? Put it in their parent. The theme or logged-in user that's needed everywhere? Context or a global store. Data that comes from a server? Treat it as a cached copy using a data-fetching library, not regular state. The guideline: keep each thing as local as possible, and only move it "up" when something else genuinely needs to share it.

✅ 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 useState and hand-sync it — use a cache (topic 21)
Gotcha: the biggest modern state-management insight is that server data is not client state — it's a cache of something that lives elsewhere, with its own concerns (staleness, refetching, dedupe, retries). Cramming it into 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.

Raw effect fetching vs a cache library
  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
🧠
Analogy 1 — a smart fridge that restocks itself: raw fetching is walking to the store every single time you want milk (re-fetch, no cache). A query library is a fridge that remembers what you have, hands it over instantly (cache), quietly restocks in the background (revalidate), and doesn't send five people to buy the same milk (dedupe).
📚
Analogy 2 — a library vs owning every book: server data belongs to the server (the library); you just borrow a copy. A query cache is your library card system — it tracks what you've borrowed, returns it when stale, and re-checks-out fresh copies automatically. Treating borrowed books as if you own them (useState) is where the mess starts.
Declare a query; the cache does the rest
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"])
🧒
In plain wordsGetting data from a server is trickier than it looks — you have to handle loading, errors, avoid asking twice, remember results, and refresh when things change. Doing all that by hand with 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 useEffect fetching for anything non-trivial — races & boilerplate await
  • Store fetched data in useState and try to keep it fresh yourself (topic 20)
Gotcha: the raw-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).
Part III · TypeScript + Production
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.

Typed props = checked usage + autocomplete
  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
🧠
Analogy 1 — a labeled outlet: typed props are like an outlet stamped "110V, 3-prong" — a mismatched plug simply won't fit, and you know instantly instead of frying the device (runtime crash). The type is the shape of the socket; wrong props don't plug in.
📋
Analogy 2 — a job posting with required skills: the Props type is the job description listing exactly what's required (and what's optional). Applicants (call sites) missing a required skill are rejected up front (compile error), and the posting doubles as documentation of what the role needs.
Type the props object (not React.FC)
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
🧒
In plain wordsWhen you define a component, you can spell out exactly what info it needs — "a name (text), an optional role, and an onSelect function." Then TypeScript checks every place you use that component: forget a required prop or pass the wrong kind, and you get an error while typing, not a crash later. Bonus: your editor auto-suggests the props for you. It's like giving your component a clear "instructions label" the computer enforces.

✅ Do

  • Define a Props type and annotate the props parameter directly
  • Use unions for constrained props ("admin" | "user") and ? for optional
  • Type children as React.ReactNode when the component wraps content

❌ Don't

  • Default to React.FC — it implicitly adds children and complicates generics
  • Type props as any — you throw away the whole benefit (topic 18 of Node/TS)
Gotcha: the community moved away from 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)).

Annotate hooks when inference falls short
  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 ✓
🧠
Analogy 1 — labeling an empty jar: if you hand someone an empty jar (useState(null)), they assume it only ever holds "nothing." Label it "cookies or empty" (useState<Cookie | null>) and now they know it can hold cookies later. The label tells the future what's allowed in the container.
🔧
Analogy 2 — the right socket for the bolt: a generic ref is a socket that doesn't fit any specific bolt (no element methods). Typing it HTMLInputElement is choosing the exact socket, so .focus() and other input-specific tools actually work. The type picks the correctly-sized tool.
Type args where inference isn't enough
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 union
🧒
In plain wordsMost of the time TypeScript figures out your hook types on its own — useState(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 with as
  • Type refs as any — you lose the element's methods and safety
Gotcha: 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.

React's typed synthetic events
  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
🧠
Analogy 1 — the right adapter for the plug: each event type is an adapter shaped for a specific element. 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).
🏷️
Analogy 2 — a labeled envelope: the event type labels the envelope so you know what's inside before opening it — an input's change event carries a .value; a form's submit event carries .preventDefault(). Guessing any is opening an unlabeled envelope and hoping the right thing is inside.
Let inference help; annotate standalone handlers
// 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>
🧒
In plain wordsWhen you handle a click or a typed character, React hands your function an "event" object. TypeScript needs to know what kind it is so it can offer the right options (like .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 lose e.target safety and autocomplete
  • Guess the element type — match it (HTMLInputElement vs HTMLSelectElement)
Gotcha: the friction usually comes from standalone handlers — an inline arrow gets its event type for free from the element, but the moment you extract 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.

Two costs: render time & bundle size
  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
🧠
Analogy 1 — only repaint the rooms that changed: 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.
📦
Analogy 2 — shipping the whole warehouse vs what's ordered: code splitting is sending only the box the customer needs now, not the entire warehouse up front (huge bundle). The rest ships later when requested (lazy load). The customer's first delivery arrives far faster.
memo with stable props; split the bundle
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>
🧒
In plain wordsTwo things make React apps slow: redrawing too much, and downloading too much code up front. For the first, you can tell heavy components "only redraw if your inputs actually changed" (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

  • memo everything 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
Gotcha: 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.

Contain failure & loading to a region
  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>
🧠
Analogy 1 — circuit breakers per room: an error boundary is a breaker for one room — if that room shorts out, only its lights go off, not the whole house. Without breakers, one faulty lamp plunges the entire house into darkness (blank app). Boundaries isolate the fault.
🚧
Analogy 2 — a "section closed" sign at a store: instead of shuttering the whole store because one aisle flooded, you put up a sign for that aisle (fallback) and shoppers use the rest. Suspense is the "this part is being restocked, back shortly" sign; error boundaries are "this aisle is temporarily closed."
Wrap risky/async regions
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>
🧒
In plain wordsIf one piece of your app crashes while drawing, React normally wipes out the whole screen — scary. An error boundary is a safety net you wrap around a section: if that section breaks, it shows a small "oops" message and the rest of the app keeps working. Suspense is similar but for waiting — it shows a spinner in just that spot while something loads. Both keep problems contained to one area instead of taking everything down.

✅ Do

  • Wrap risky/async regions (and route roots) in error boundaries
  • Use Suspense with lazy and 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
Gotcha: error boundaries only catch errors thrown during rendering, lifecycle, and constructors of their subtree — they do not catch errors in event handlers, async code (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."

Test what the user sees, not internals
  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)
🧠
Analogy 1 — reviewing a car by driving it: you don't judge a car by inspecting the exact wiring (internals) — you drive it and see if it goes, stops, and steers (user behavior). RTL tests drive the component: click the buttons, read the screen. Rewire the engine and, as long as it still drives, the test passes.
🎭
Analogy 2 — judging a play from the audience: a good review is written from a seat in the house (what the audience sees), not from backstage notes about how actors memorized lines. Test from the "audience" — the rendered UI — and your tests reflect the real experience, not fragile backstage details.
Query by role/text; simulate the 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
🧒
In plain wordsThe best way to test a React component is to use it like a real person would: draw it on a fake screen, click its buttons, type in its inputs, and check that the right things show up. That's what React Testing Library does. The old way — peeking at the component's private inner workings — breaks every time you tidy up the code, even when nothing's actually wrong for users. Testing what people see and do gives you tests that catch real problems and survive rewrites.

✅ Do

  • Query by accessible role/label/text — the way a user (and screen reader) finds things
  • Simulate real interactions with userEvent and 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)
Gotcha: RTL's design deliberately makes it hard to test implementation details — and people fight it by reaching for 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.

Semantic HTML gives accessibility for free
  ✗ <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
🧠
Analogy 1 — building ramps, not just stairs: a <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.
📻
Analogy 2 — subtitles on a video: semantic HTML is like properly captioning your content so people who can't hear (or see) still get everything. A screen reader "reads the captions" of your UI — if you used a <div> where a <button> belongs, there's no caption, and that user is left out.
Real elements + labels beat div soup
// ✓ 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
🧒
In plain wordsAccessibility means making your app usable by everyone — including people who navigate with a keyboard or use a screen reader that reads the page aloud. The easiest win: use the real HTML elements. A true <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
Gotcha: the counterintuitive rule is "no ARIA is better than bad ARIA" — developers reach for 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.

The greatest-hits of React bugs
  ✗ 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)
🧠
Analogy 1 — a checklist of common driving mistakes: most fender-benders come from a handful of habits — not checking mirrors, following too close. Learn the list and you avoid 90% of accidents. These anti-patterns are React's "common mistakes" list; internalize them and most bugs never happen.
🐛
Analogy 2 — recognizing bug bites: a doctor who's seen a rash a hundred times diagnoses it in seconds. Once you've been bitten by the index-key bug or the mutate-state bug, you recognize the symptoms instantly and stop wasting hours. Naming the pattern is half the cure.
Fix the greatest hits
// ✗ 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 />}
🧒
In plain wordsAlmost all React headaches come from the same handful of mistakes: changing state in place instead of making a new copy (so nothing updates), getting the effect's "when to re-run" list wrong (stale data or endless loops), using an item's position as its id (list bugs), storing things you could just calculate, and the sneaky && 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
Gotcha: the through-line of nearly every entry here is a misunderstanding of React's core model — that UI is derived from immutable state, that hooks run by order, and that effects sync with the outside world. Mutating state, storing derivable data, and overusing effects all come from fighting that model instead of leaning into it. When something's mysteriously not updating or looping, don't add more 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.

The whole feature, assembled
  <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)
🧠
Analogy 1 — a well-run shop counter: types are the labeled shelves (everything in its place), the data cache is the stockroom that restocks itself, derived state is the live "what's in stock" board, the controlled input is the order form, error boundaries are the "back in 5" sign for one register, and accessibility is the ramp at the door. One transaction is easy; running the whole counter smoothly all day is the skill.
🏗️
Analogy 2 — a finished building vs a pile of materials: you learned components (bricks), hooks (wiring), and types (the parts that must fit) separately. The capstone is the standing, inspected, occupied building — every system connected and up to code, with ramps (a11y) and fire exits (error boundaries). That integration is the engineering.
Everything, wired together
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)
🧒
In plain wordsThis is everything put together: a searchable user list that loads data from a server (using a smart cache, not hand-rolled fetching), lets you type to filter (a controlled input), calculates the filtered list instead of storing it, shows the right thing whether it's loading, errored, empty, or full, uses real accessible HTML with proper ids, and is wrapped in a safety net so one glitch can't blank the whole app — all fully typed so mistakes are caught as you write. Each part was simple alone; making them work together, correctly and for everyone, is the real skill.

✅ 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
Gotcha: the hardest part of a production React feature isn't any single hook — it's restraint. The same tools that help (effects, memo, context, global stores) become the problem when overused: an effect that should've been derived state, a memo that does nothing, a global store for what's local. A great React feature is usually the one with the fewest moving parts that still handles every state, types every boundary, and works for every user. Lean into the model — UI from immutable state — and reach for the heavy tools only when profiling or real sharing demands them. That judgment, not cleverness, is what "senior React" means. 🚀
Test yourself · Round-based quiz

Answer 5 per round. Your progress and score are saved in this browser — come back anytime and continue where you left off.

Score: 0 / 0 answered · 0 total