Module 4 · React · Deep Dive
State that survives re-renders, effects that talk to your FastAPI backend, and the custom hooks that keep DocChat's UI clean. This is where React stops being templating and starts being an application.
BasicIntermediateBuild
A hook is any function whose name starts with use — useState, useEffect, and the custom ones you'll write. React tracks hooks by call order, not by name, so the order must be identical on every render. That gives you two hard rules:
if, a loop, or after an early return — a skipped hook shifts the order and corrupts state.// ❌ WRONG — hook hidden behind a condition function Bad({ userId }) { if (userId) { const [name, setName] = useState(""); # order changes when userId is falsy } } // ✅ RIGHT — always called, branch on the value instead function Good({ userId }) { const [name, setName] = useState(""); if (!userId) return null; return <p>{name}</p>; }Interview: "Why can't you call a hook inside an
if?" Because React identifies each hook purely by the order it's called in. A conditional call changes that order between renders, so React would hand back the wrong state slot. The ESLint plugin eslint-plugin-react-hooks catches this for you — mention it and you sound like you've shipped React.
You met useState in Lesson 4.1. Quick refresher: it returns a pair — the current value and a setter. Calling the setter schedules a re-render with the new value.
const [count, setCount] = useState(0); setCount(count + 1); // fine for one-off updates setCount(c => c + 1); // updater form — safe when the new value depends on the old
Two things that trip up newcomers from a jQuery world: state updates are asynchronous (the variable doesn't change until the next render), and you must treat state as immutable — replace objects and arrays, never mutate them in place.
// ❌ mutating — React won't notice, no re-render docs.push(newDoc); setDocs(docs); // ✅ new array — React sees a new reference and re-renders setDocs([...docs, newDoc]);
Rendering should be pure — given the same props and state, it returns the same JSX with no side effects. But real apps need side effects: fetching data, subscriptions, timers. useEffect is the escape hatch that runs code after the render is painted.
import { useEffect } from "react"; useEffect(() => { // side-effect code runs here, after render }, [/* dependency array */]);
The dependency array is the whole game. It tells React when to re-run the effect:
| Second argument | Effect runs… |
|---|---|
[] (empty) | Once, after the first render (on mount). The classic "fetch on load". |
[a, b] | On mount, then again whenever a or b changes. |
| omitted | After every render. Almost always a bug. |
If your effect starts something ongoing — a subscription, an interval, an event listener — return a cleanup function. React runs it before the next effect and when the component unmounts.
useEffect(() => {
const id = setInterval(() => tick(), 1000);
return () => clearInterval(id); // cleanup: stop the timer
}, []);
useEffect too often is the #1 sign of a React beginner.
Now the real job: pull DocChat's documents from GET /documents on your FastAPI backend. A fetch has three possible states at any moment — loading, error, or data — and good UI tracks all three. This is the three-state pattern.
const [docs, setDocs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { async function load() { try { const res = await fetch("http://localhost:8000/documents", { headers: { Authorization: `Bearer ${token}` }, }); if (!res.ok) throw new Error(`HTTP ${res.status}`); setDocs(await res.json()); } catch (err) { setError(err.message); } finally { setLoading(false); } } load(); }, []);
Notice the shape: the effect callback itself is not async (an effect must return a cleanup function or nothing, never a Promise), so we define an inner async function and call it. The finally block guarantees loading flips off whether the request succeeds or fails.
Sending the JWT. Your protected FastAPI routes expect the token from Lesson 3.3 in an Authorization: Bearer <token> header. With fetch you pass it in headers; with axios it's the same idea, a little terser:
import axios from "axios"; const { data } = await axios.get("/documents", { baseURL: "http://localhost:8000", headers: { Authorization: `Bearer ${token}` }, }); // axios throws on non-2xx automatically and parses JSON for youInterview: "How do you handle the three states of a data fetch?" Track
loading, error, and data as separate state, start loading at true, and in the JSX render the spinner, then the error, then the data — in that priority. Add that in real apps you'd reach for a library like TanStack Query or SWR that gives you caching, retries, and deduping for free, and the interviewer knows you've felt the pain manual fetching causes.
State lives inside one component. When two components need the same data, you lift state up to their closest common parent and pass it down as props.
function Dashboard() { const [user, setUser] = useState(null); return ( <> <Header user={user} /> <DocList user={user} /> </> ); }
Passing the same prop through three or four layers that don't use it — just to reach a deep child — is prop drilling, and it gets tedious fast. For genuinely global data (the logged-in user, a theme, the auth token) reach for Context.
const AuthContext = createContext(null); function App() { const [user, setUser] = useState(null); return ( <AuthContext.Provider value={{ user, setUser }}> <Dashboard /> </AuthContext.Provider> ); } // any descendant, at any depth, reads it directly — no drilling function Header() { const { user } = useContext(AuthContext); return <p>Hello {user?.name}</p>; }
For complex state with many related transitions, useReducer centralises the logic into one reducer function — the same (state, action) => newState shape you'd recognise from Redux, built into React:
function reducer(state, action) { switch (action.type) { case "loading": return { ...state, loading: true }; case "success": return { loading: false, docs: action.docs, error: null }; case "error": return { ...state, loading: false, error: action.error }; default: return state; } } const [state, dispatch] = useReducer(reducer, { loading: true, docs: [], error: null });
When a chunk of stateful logic — like our three-state fetch — gets reused or just clutters a component, extract it into a custom hook. It's a plain function starting with use that calls other hooks and returns whatever you want.
// useDocuments.js — all the fetch logic in one reusable place import { useState, useEffect } from "react"; export function useDocuments(token) { const [docs, setDocs] = useState([]); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); useEffect(() => { let active = true; setLoading(true); fetch("http://localhost:8000/documents", { headers: { Authorization: `Bearer ${token}` }, }) .then((res) => { if (!res.ok) throw new Error(`HTTP ${res.status}`); return res.json(); }) .then((data) => active && setDocs(data)) .catch((err) => active && setError(err.message)) .finally(() => active && setLoading(false)); return () => { active = false; }; // cleanup: ignore a late response }, [token]); return { docs, loading, error }; }
Now any component gets the whole pattern in one line: const { docs, loading, error } = useDocuments(token);. The component reads like a description of what it shows, not how it fetches.
/documents endpoint through the useDocuments hook and renders a spinner while loading, an error banner if the request fails, and the list of documents when it lands. This is the first screen a DocChat user sees after logging in.
The hook does the heavy lifting; the component just maps the three states to JSX:
DocList.jsx
import { useDocuments } from "./useDocuments"; export function DocList({ token }) { const { docs, loading, error } = useDocuments(token); if (loading) return <p className="status">Loading documents…</p>; if (error) return <p className="error">Couldn't load: {error}</p>; if (docs.length === 0) return <p>No documents yet — upload one.</p>; return ( <ul className="doc-list"> {docs.map((doc) => ( <li key={doc.id}> <strong>{doc.title}</strong> — {doc.pages} pages </li> ))} </ul> ); }
Two details that matter: every item in a .map needs a stable key (use the database id, never the array index), and the three guard clauses render in priority order — loading first, then error, then the empty and happy paths. That ordering is the three-state pattern.
The mental model: a component re-renders when its state or props change. Re-rendering means React calls your function again — every line runs again, every const is re-created. Hold that picture and the classic bugs become obvious.
token isn't in the dependency array, the effect keeps using the old token forever. Fix: list every value the effect reads in the deps.react-hooks/exhaustive-deps lint rule — when it asks for a dependency, add it (or restructure so you genuinely don't need it).setState with no dependency array (or with a dep that the effect itself changes) re-renders → re-runs the effect → re-renders, forever. Give it a correct dependency array, usually [] for a one-time fetch.// ❌ infinite loop — sets state every render, no deps useEffect(() => { setCount(count + 1); }); // ❌ infinite loop — object dep is a NEW reference every render useEffect(() => { load(); }, [{ id: 1 }]); // ✅ runs once useEffect(() => { load(); }, []);
Answer from memory — retrieval is what turns "I read it" into "I can ship it".
When does an effect with [] deps run?
Where must every hook be called from?
What sends a JWT to the backend?
What does a cleanup function do?
Which value distributes global app data?