Module 4 · React · Deep Dive
The single idea that separates juniors from seniors on the frontend: not all state is the same. Once you can name the four kinds of state and match each to its right tool, DocChat's data layer stops being a pile of useState and becomes an architecture.
BasicIntermediateBuild
useState + useEffect. It worked — for one screen, with one user, clicking slowly. The moment two components want the same documents, the moment you upload a file and expect the list to refresh, the moment the network is slow and a user double-clicks, that approach cracks. This lesson gives you the modern 2026 toolkit: TanStack Query for data that lives on your server, Zustand for UI state that lives in the browser, the URL for state worth sharing, and React Hook Form for forms. Get the mental model and you'll answer the most common senior-frontend interview question in your sleep.
"State management" sounds like one problem. It's actually four, and they have nothing in common except the word. The whole skill is sorting a piece of state into the right bucket — because each bucket has a different correct tool.
| Kind | Lives where | Examples in DocChat | Right tool |
|---|---|---|---|
| Server state | Your database, reached over HTTP | The document list, a chat's messages, the user's profile | TanStack Query |
| Client / UI state | The browser, this session only | Is the sidebar open, which document is selected, dark mode | Zustand (or useState) |
| URL state | The address bar | Active filter, search query, current page | useSearchParams |
| Form state | Inputs being edited, not yet saved | The upload form, the rename dialog | React Hook Form + zod |
The defining trait of server state is that you don't own it. It's a cached copy of something that lives elsewhere, that someone else can change, that can go stale the instant you read it. That single fact is why it needs its own tool — one that knows about caching, refetching, and staleness. Client state is the opposite: you own it completely, it's true the moment you set it, and it vanishes on refresh. Confusing the two is the root cause of most frontend mess.
useState. If no, it's client state. This one question replaces about 80% of "where should this live?" debates.
$("#sidebar").is(":visible") to know if the sidebar was open. React flips that: state is the source of truth and the DOM is just a projection of it. The four-kinds model is about deciding where each piece of that truth should live.
The pattern from Lesson 4.2 — three pieces of state, an effect that fetches — is honest, and you should understand it. But it's an anti-pattern for server state in real apps, for four concrete reasons. Naming them is an interview answer in itself.
// The Lesson 4.2 approach — fine to learn, wrong to ship const [docs, setDocs] = useState<Doc[]>([]); const [loading, setLoading] = useState(true); const [error, setError] = useState<string | null>(null); useEffect(() => { /* fetch, set all three... */ }, []);
token or a filter changes mid-flight, two requests are in the air and the slower one can land last, overwriting fresh data with stale. You have to manually track an active flag (you saw the hack in 4.2) on every fetch./documents fetches it independently; there's no dedupe.useEffect to fetch is the single clearest "I learned React in 2019" tell in an interview.
TanStack Query (v5, formerly React Query) treats server state as what it is: a cache you keep in sync with the server. You describe what you want and how to fetch it; the library handles caching, deduping, background refetching, retries, and the loading/error states — the entire list of pains from §2, gone.
You set it up once at the root with a QueryClient and a provider:
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; const queryClient = new QueryClient(); function App() { return ( <QueryClientProvider client={queryClient}> <DocChat /> </QueryClientProvider> ); }
Then anywhere in the tree, useQuery reads the data. Three things define a query: a queryKey (the cache address), a queryFn (how to fetch), and the options.
useDocuments.ts
import { useQuery } from "@tanstack/react-query"; import { api } from "./api"; // an axios instance with the JWT attached interface Doc { id: string; title: string; pages: number; } export function useDocuments() { return useQuery({ queryKey: ["documents"], queryFn: async (): Promise<Doc[]> => { const { data } = await api.get("/documents"); return data; }, staleTime: 60_000, // 1 min: treat data as fresh, don't refetch on every mount }); }
The component now reads like a description of what it shows — the three-state pattern from 4.2, but you no longer write it by hand:
function DocList() { const { data: docs, isPending, isError, error } = useDocuments(); if (isPending) return <p>Loading documents…</p>; if (isError) return <p>Couldn't load: {error.message}</p>; return <ul>{docs.map((d) => <li key={d.id}>{d.title}</li>)}</ul>; }
The queryKey is the heart of it. It's the cache key — any two components that call useQuery with ["documents"] share one fetch and one cache entry. Make it dynamic to vary the cache by input, e.g. the messages for one chat: ["chats", chatId, "messages"]. When chatId changes, TanStack Query fetches the new chat and keeps the old one cached.
Two timing options you must understand:
| Option | Meaning |
|---|---|
staleTime | How long data is considered fresh. While fresh, mounting a component reads the cache instantly with no refetch. Default is 0 (always stale → refetch in the background on mount/focus). |
gcTime | How long an unused cache entry survives before garbage collection. Default 5 minutes. This is why navigating away and back is instant — the entry is still there. |
staleTime and gcTime?" staleTime controls freshness — whether a background refetch fires. gcTime controls retention — how long data nobody is using stays in memory. Stale data is still shown instantly (stale-while-revalidate); garbage-collected data is gone and must be refetched. Mixing them up is a classic stumble.
useQuery reads; useMutation writes. When a user uploads a document, you POST it — and then the cached list is out of date. The mutation's job is to perform the write and then tell the cache "the documents changed, refetch them." That's invalidation.
useUploadDocument.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"; export function useUploadDocument() { const queryClient = useQueryClient(); return useMutation({ mutationFn: async (file: File) => { const form = new FormData(); form.append("file", file); const { data } = await api.post("/documents", form); return data; }, onSuccess: () => { // the list is now stale — refetch it queryClient.invalidateQueries({ queryKey: ["documents"] }); }, }); }
In the component, you get mutate plus the same status flags as a query:
const { mutate: upload, isPending } = useUploadDocument(); <button onClick={() => upload(selectedFile)} disabled={isPending}> {isPending ? "Uploading…" : "Upload"} </button>
That invalidateQueries call is the modern answer to "how do I refresh the list after a change?" No manual refetch, no passing a callback down three components, no setDocs([...docs, newDoc]). You mutate, you invalidate, the UI updates everywhere the query is used.
Optimistic updates take it one step further: update the cache before the server responds so the UI feels instant, then roll back if it fails. The shape is onMutate (patch the cache, save a snapshot), onError (restore the snapshot), onSettled (invalidate to reconcile with the truth):
useMutation({
mutationFn: (file: File) => api.post("/documents", toForm(file)),
onMutate: async (file) => {
await queryClient.cancelQueries({ queryKey: ["documents"] });
const prev = queryClient.getQueryData<Doc[]>(["documents"]);
queryClient.setQueryData<Doc[]>(["documents"], (old = []) => [
...old,
{ id: "temp", title: file.name, pages: 0 }, // instant placeholder
]);
return { prev }; // context handed to onError
},
onError: (_err, _file, ctx) => {
queryClient.setQueryData(["documents"], ctx?.prev); // roll back
},
onSettled: () => queryClient.invalidateQueries({ queryKey: ["documents"] }),
});
Interview: "How would you make an upload feel instant?" Optimistic update: in onMutate cancel in-flight queries, snapshot the cache, write the expected result immediately; in onError restore the snapshot; in onSettled invalidate to sync with the server's real answer. Mentioning the snapshot-and-rollback shows you've actually shipped it.
Now the other bucket. "Which document is open" and "is the sidebar collapsed" don't live on any server — they're pure UI state. For state local to one component, useState is still perfect. But when several components across the tree need the same UI state, you want a small global store without the ceremony. In 2026 that's Zustand (v5).
A store is created once, outside React, with create. No provider, no boilerplate, no actions/reducers split unless you want it:
useUiStore.ts
import { create } from "zustand"; interface UiState { openDocId: string | null; sidebarOpen: boolean; openDoc: (id: string) => void; toggleSidebar: () => void; } export const useUiStore = create<UiState>((set) => ({ openDocId: null, sidebarOpen: true, openDoc: (id) => set({ openDocId: id }), toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), }));
Any component reads from it as a hook. The crucial habit is the selector — pass a function that picks out exactly the slice you need, so the component only re-renders when that slice changes:
// ✅ selector: re-renders only when sidebarOpen changes const sidebarOpen = useUiStore((s) => s.sidebarOpen); const toggleSidebar = useUiStore((s) => s.toggleSidebar); // ❌ no selector: subscribes to the WHOLE store — re-renders on every change const { sidebarOpen } = useUiStore();
openDocId doesn't re-render when sidebarOpen flips. Always select the narrowest slice; never destructure the whole store unless you genuinely need all of it.
You met Context in Lesson 4.2 for distributing the auth token. Context is a distribution mechanism, not a state manager — it gets a value to deep children without prop drilling. That's all it does, and for the right values it's perfect.
// ❌ a single fast-changing context = everyone re-renders on every change const AppContext = createContext({ openDocId, sidebarOpen, setOpenDoc, ... }); // typing in a search box that lives in this context re-renders the whole tree
Zustand solves exactly this: same "global, no drilling" benefit, but with selectors so each component re-renders only for its slice. The rule of thumb: Context for what rarely changes, a store for what changes a lot.
Interview: "Why not just put everything in Context?" Because Context has no selector mechanism — every consumer re-renders whenever the provider'svalue changes, so a frequently-updated context becomes a performance landmine. Context distributes; it doesn't optimise. For high-frequency shared state, use a store (Zustand) that supports fine-grained subscriptions.
Some state belongs in the address bar. If a user filters DocChat to "PDFs only" and wants to send that exact view to a colleague, the filter has to live in the URL — that's the test for URL state: "should this survive a refresh and be shareable by copying the link?" Active filters, the search query, sort order, the current page, the selected tab — all yes.
With React Router (or Next.js' equivalent) you read and write it through useSearchParams. The URL becomes the source of truth; no useState mirror needed:
import { useSearchParams } from "react-router-dom"; function DocFilters() { const [params, setParams] = useSearchParams(); const type = params.get("type") ?? "all"; // read from the URL return ( <select value={type} onChange={(e) => setParams({ type: e.target.value })} // writes ?type=pdf > <option value="all">All</option> <option value="pdf">PDFs</option> </select> ); }
It composes beautifully with TanStack Query: put the URL value in the queryKey and the server refetch happens automatically when the filter changes, with each filter's result cached separately:
const type = params.get("type") ?? "all"; useQuery({ queryKey: ["documents", { type }], // changes with the URL → refetch + separate cache queryFn: () => api.get("/documents", { params: { type } }).then((r) => r.data), });
The last bucket is the values a user is actively editing but hasn't saved — the upload dialog, a rename field. You can hold each input in useState, but for anything beyond one field it gets noisy fast (a setter per input, manual validation, manual error display). The 2026 standard is React Hook Form for the form mechanics and zod for the validation schema.
import { useForm } from "react-hook-form"; import { zodResolver } from "@hookform/resolvers/zod"; import { z } from "zod"; const schema = z.object({ title: z.string().min(1, "Title is required"), }); type Values = z.infer<typeof schema>; // types derived from the schema — one source of truth function RenameForm() { const { register, handleSubmit, formState: { errors } } = useForm<Values>({ resolver: zodResolver(schema) }); return ( <form onSubmit={handleSubmit((v) => console.log(v))}> <input {...register("title")} /> {errors.title && <span>{errors.title.message}</span>} </form> ); }
Two wins: React Hook Form keeps inputs uncontrolled so typing doesn't re-render the whole form (fast even with many fields), and zod gives you one schema that validates at runtime and infers your TypeScript types. Define the shape once; get validation and types from it. On submit you typically hand the validated values to a TanStack Query mutation — the buckets connect.
The whole lesson on one screen — when a piece of state appears, run it through this:
| If the state is… | Use |
|---|---|
| Fetched from / saved to your backend | TanStack Query (useQuery / useMutation) |
| UI-only and local to one component | useState |
| UI-only but shared across the tree | Zustand with selectors |
| Worth sharing via a link / surviving refresh | URL (useSearchParams) |
| Being typed into a form | React Hook Form + zod |
| Rarely-changing, app-wide (user, theme) | Context |
The Zustand store for the UI bits (open document, sidebar):
useUiStore.ts
import { create } from "zustand"; interface UiState { openDocId: string | null; sidebarOpen: boolean; openDoc: (id: string) => void; toggleSidebar: () => void; } export const useUiStore = create<UiState>((set) => ({ openDocId: null, sidebarOpen: true, openDoc: (id) => set({ openDocId: id }), toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })), }));
The panel wires the query, the mutation, and the store together — notice each kind of state comes from its own source:
DocPanel.tsx
import { useDocuments } from "./useDocuments"; import { useUploadDocument } from "./useUploadDocument"; import { useUiStore } from "./useUiStore"; export function DocPanel() { // server state const { data: docs, isPending, isError } = useDocuments(); const { mutate: upload, isPending: uploading } = useUploadDocument(); // client state — narrow selectors, no over-rendering const openDocId = useUiStore((s) => s.openDocId); const openDoc = useUiStore((s) => s.openDoc); const sidebarOpen = useUiStore((s) => s.sidebarOpen); const toggleSidebar = useUiStore((s) => s.toggleSidebar); if (isPending) return <p>Loading documents…</p>; if (isError) return <p>Couldn't load your documents.</p>; return ( <div> <button onClick={toggleSidebar}>{sidebarOpen ? "Hide" : "Show"}</button> {sidebarOpen && ( <ul> {docs.map((doc) => ( <li key={doc.id} onClick={() => openDoc(doc.id)} className={doc.id === openDocId ? "active" : ""} > {doc.title} </li> ))} </ul> )} <input type="file" disabled={uploading} onChange={(e) => e.target.files?.[0] && upload(e.target.files[0])} /> </div> ); }
Trace what happens on upload: upload(file) POSTs to FastAPI → onSuccess calls invalidateQueries(["documents"]) → TanStack Query refetches the list → every component using useDocuments shows the new file. You never touched docs by hand, never passed a refresh callback, never managed a loading flag. The query owns the server data; the store owns the UI. That separation is the lesson.
Answer from memory — retrieval is what turns "I read it" into "I can architect it".
The DocChat document list (fetched from FastAPI) is which kind of state?
After a successful upload mutation, how do you refresh the list?
Why pass a selector function to a Zustand store hook?
What does staleTime control?
A shareable, refresh-surviving document filter belongs in…