Module 4 · React · Drills

Drills: State Management

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.

How to use this page Each drill is a small task. Attempt it first in your DocChat project (or a scratch component), then click "Show solution" to compare. Assume an axios instance 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.

A · Warm-up reps Basic

Drill 1 classify state

For each piece of DocChat state, name its kind (server / client-UI / URL / form) and the tool you'd use:

Show solution
// 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);
  });
}, []);
Show solution
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.

Show solution
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.

B · Stretch Intermediate

Drill 4 useMutation + invalidate

Write a useDeleteDocument mutation: it DELETEs /documents/:id, and on success invalidates the ["documents"] query so the list refreshes.

Show solution
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.

Show solution
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>;
}
Show solution
// 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.

C · Build challenge Build

Mini-project Rebuild DocChat's document panel end to end with the right tool for each kind of state: the list via TanStack Query, an upload mutation that invalidates the cache, and a Zustand store holding the open-document id and sidebar flag. When upload succeeds, the list must refresh on its own — no manual state poke. This is the exact architecture you'd ship and defend in an interview.

Build · DocChat data layer

Wire three files: useDocuments (query), useUploadDocument (mutation + invalidation), useUiStore (Zustand), then a DocPanel that consumes all three.

Show solution
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 → onSuccessinvalidateQueries → 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.

D · Rapid recall Flashcards

Click a card to flip it. Say the answer out loud before you flip — that's the rep that builds storage strength.

The four kinds of state?
Server, client/UI, URL, form.
click to flip
One test for server state?
"Does it also exist on the server?" If yes → TanStack Query.
click to flip
Refresh a list after a mutation?
queryClient.invalidateQueries({ queryKey: ["documents"] })
click to flip
staleTime vs gcTime?
staleTime = how long data is fresh (no refetch). gcTime = how long unused cache survives.
click to flip
Stop a Zustand over-render?
Pass a selector: useStore((s) => s.slice) — subscribe to one slice only.
click to flip
Where do shareable filters live?
The URL — useSearchParams. Survives refresh, copy-paste shareable.
click to flip

E · Self-check before moving on

Tick each only if you can do it without looking:

Next All ticked? You now manage state the way 2026 production apps do — and you can answer "how do you handle server vs client state?" with an architecture, not a shrug. Keep going through the course: each module builds on this same DocChat foundation.