Module 4 · React · Drills
Reading the four-kinds model is not the same as wiring it. Type each drill yourself — convert the fetch, add the mutation, build the store — before you reveal the solution. The classifying drill especially: say your answer out loud first.
api with the JWT attached and a QueryClientProvider already wrapping your app, exactly as in the lesson. Tick each box as you go; progress saves in this browser.
Drill 1 classify state
For each piece of DocChat state, name its kind (server / client-UI / URL / form) and the tool you'd use:
// document list → SERVER state → TanStack Query (useQuery) // sidebar collapsed → CLIENT/UI → Zustand (or useState if local) // shareable filter → URL state → useSearchParams // rename title input → FORM state → React Hook Form + zod // chat messages → SERVER state → TanStack Query, key ["chats", id, "messages"]
The test for server state: "does it also exist on the backend?" Documents and messages do; sidebar/filter/title do not.
Drill 2 useQuery
Convert this Lesson 4.2 fetch into a useQuery hook called useChats, with a staleTime of 30 seconds.
const [chats, setChats] = useState([]); const [loading, setLoading] = useState(true); useEffect(() => { api.get("/chats").then((r) => { setChats(r.data); setLoading(false); }); }, []);
import { useQuery } from "@tanstack/react-query"; export function useChats() { return useQuery({ queryKey: ["chats"], queryFn: () => api.get("/chats").then((r) => r.data), staleTime: 30_000, }); } // in the component — no useState, no useEffect, no manual loading flag const { data: chats, isPending } = useChats();
Drill 3 zustand store
Create a Zustand store with a number fontSize (start at 16) and an action bump that increases it by 2. Then read only fontSize in a component with a selector.
import { create } from "zustand"; interface PrefState { fontSize: number; bump: () => void; } export const usePrefStore = create<PrefState>((set) => ({ fontSize: 16, bump: () => set((s) => ({ fontSize: s.fontSize + 2 })), })); // component — selector means it re-renders only when fontSize changes const fontSize = usePrefStore((s) => s.fontSize);
No provider needed — the store exists the moment create runs.
Drill 4 useMutation + invalidate
Write a useDeleteDocument mutation: it DELETEs /documents/:id, and on success invalidates the ["documents"] query so the list refreshes.
import { useMutation, useQueryClient } from "@tanstack/react-query"; export function useDeleteDocument() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (id: string) => api.delete(`/documents/${id}`), onSuccess: () => queryClient.invalidateQueries({ queryKey: ["documents"] }), }); } // usage const { mutate: remove } = useDeleteDocument(); <button onClick={() => remove(doc.id)}>Delete</button>
Drill 5 URL state
You have a local filter: const [type, setType] = useState("all"). Lift it into the URL so the filtered view is shareable, then feed it into a query so changing the filter refetches.
import { useSearchParams } from "react-router-dom"; import { useQuery } from "@tanstack/react-query"; const [params, setParams] = useSearchParams(); const type = params.get("type") ?? "all"; // read from URL // setParams({ type: "pdf" }) writes ?type=pdf const { data: docs } = useQuery({ queryKey: ["documents", { type }], // in the key → auto refetch queryFn: () => api.get("/documents", { params: { type } }).then((r) => r.data), });
The URL is now the single source of truth — no useState mirror. Each filter value caches separately.
Drill 6 spot the bug
This component re-renders on every change to the UI store, even when only openDocId changes. Why, and how do you fix it?
function SidebarToggle() { const { sidebarOpen, toggleSidebar } = useUiStore(); return <button onClick={toggleSidebar}>{sidebarOpen ? "Hide" : "Show"}</button>; }
// Bug: calling useUiStore() with NO selector subscribes to the WHOLE store, // so any field changing (e.g. openDocId) re-renders this component. // Fix: select each slice you actually use. function SidebarToggle() { const sidebarOpen = useUiStore((s) => s.sidebarOpen); const toggleSidebar = useUiStore((s) => s.toggleSidebar); return <button onClick={toggleSidebar}>{sidebarOpen ? "Hide" : "Show"}</button>; }
Selectors are how Zustand avoids over-rendering. Narrow slice in, narrow subscription out.
Build · DocChat data layer
Wire three files: useDocuments (query), useUploadDocument (mutation + invalidation), useUiStore (Zustand), then a DocPanel that consumes all three.
useDocuments.ts
import { useQuery } from "@tanstack/react-query"; export interface Doc { id: string; title: string; pages: number; } export function useDocuments() { return useQuery({ queryKey: ["documents"], queryFn: (): Promise<Doc[]> => api.get("/documents").then((r) => r.data), staleTime: 60_000, }); }
useUploadDocument.ts
import { useMutation, useQueryClient } from "@tanstack/react-query"; export function useUploadDocument() { const queryClient = useQueryClient(); return useMutation({ mutationFn: (file: File) => { const form = new FormData(); form.append("file", file); return api.post("/documents", form).then((r) => r.data); }, onSuccess: () => queryClient.invalidateQueries({ queryKey: ["documents"] }), }); }
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 })), }));
DocPanel.tsx
import { useDocuments } from "./useDocuments"; import { useUploadDocument } from "./useUploadDocument"; import { useUiStore } from "./useUiStore"; export function DocPanel() { const { data: docs, isPending, isError } = useDocuments(); const { mutate: upload, isPending: uploading } = useUploadDocument(); 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 the upload: mutate → POST → onSuccess → invalidateQueries → refetch → every useDocuments consumer updates. You never touched docs by hand. Server state in the query, UI state in the store — that's the whole architecture.
Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.
queryClient.invalidateQueries({ queryKey: ["documents"] })staleTime vs gcTime?useStore((s) => s.slice) — subscribe to one slice only.useSearchParams. Survives refresh, copy-paste shareable.Tick each only if you can do it without looking:
useState+useEffect fetch into a useQueryuseMutation that invalidates the right query key