Module 4 · React · Deep Dive

Hooks & Data Fetching

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

Why this matters DocChat's frontend has to do one thing well: fetch documents from your FastAPI backend and show them without the page feeling broken. That means juggling loading spinners, error banners, and live data — all driven by hooks. Get the mental model right here and the rest of the frontend is just more of the same shape. Get it wrong and you'll fight infinite loops and stale data for weeks.
In this lesson
  1. The Rules of Hooks
  2. useState, recapped
  3. useEffect: mount, deps, cleanup
  4. Fetching from FastAPI
  5. Sharing state across components
  6. Custom hooks
  7. Build: DocChat document list
  8. Re-render model & pitfalls
  9. Check yourself

1 · The Rules of Hooks

A hook is any function whose name starts with useuseState, 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:

// ❌ 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.

2 · useState, recapped

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]);

3 · useEffect: mount, deps, cleanup

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 argumentEffect 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.
omittedAfter 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
}, []);
You might not need an effect The 2026 React docs are blunt about this: effects are for synchronising with external systems (the network, the DOM, a third-party widget). If you're using an effect to transform props into state, or to respond to a button click, you almost certainly don't need one — compute the value during render, or do the work in the event handler. Reaching for useEffect too often is the #1 sign of a React beginner.

4 · Fetching from FastAPI

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 you
Interview: "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.

5 · Sharing state across components

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 });
Context is not a state manager Context solves distribution (getting a value to deep children), not caching or server state. Don't put fetched API data in Context expecting it to dedupe or refresh — that's what TanStack Query is for. Use Context for stable, app-wide values like the current user and auth token.

6 · Custom hooks

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.

7 · Build it

Your tangible win Build the DocChat document list: a React UI that calls your FastAPI /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.

8 · Re-render model & common pitfalls

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.

// ❌ 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(); }, []);
useState vs useEffect They're constantly confused, so be crisp. useState stores a value that persists across re-renders and triggers a re-render when it changes — it's your component's memory. useEffect runs side-effect code (fetching, subscriptions, timers) after a render, synchronising your component with the outside world. State is the data; effects are the actions that reach beyond React. A fetch needs both: effect fires the request, state holds the result.

9 · Check yourself

Answer from memory — retrieval is what turns "I read it" into "I can ship it".

Recall quiz

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?

Primary source ⭐ React docs — useEffect reference. The authoritative, 2026-current explanation of the dependency array, cleanup, and the "you might not need an effect" guidance. Pair it with Synchronizing with Effects for the mental model.