Module 4 · React · Deep Dive

State Management: Server vs Client

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

Why this matters In Lesson 4.2 you fetched DocChat's documents with 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.
In this lesson
  1. The four kinds of state
  2. Why useState + useEffect fetching breaks
  3. TanStack Query: server state
  4. Mutations & cache invalidation
  5. Zustand: client/UI state
  6. When Context is enough (and when it isn't)
  7. URL state with searchParams
  8. Form state: React Hook Form + zod
  9. Build: DocChat's data layer
  10. Check yourself

1 · The four kinds of state

"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.

KindLives whereExamples in DocChatRight tool
Server stateYour database, reached over HTTPThe document list, a chat's messages, the user's profileTanStack Query
Client / UI stateThe browser, this session onlyIs the sidebar open, which document is selected, dark modeZustand (or useState)
URL stateThe address barActive filter, search query, current pageuseSearchParams
Form stateInputs being edited, not yet savedThe upload form, the rename dialogReact 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.

The one question that organises everything For any piece of state, ask: "Does this also exist on the server?" If yes, it's server state — it belongs in TanStack Query, not useState. If no, it's client state. This one question replaces about 80% of "where should this live?" debates.
jQuery bridge: in jQuery days there was no concept of state at all — the DOM was your state. You'd read $("#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.

2 · Why useState + useEffect fetching breaks

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... */ }, []);
The 2026 consensus The React team's own docs now say it plainly: don't use Effects to fetch data in production apps. Use a framework's data loader or a dedicated library. For client-rendered apps that library is, overwhelmingly, TanStack Query. Reaching for useEffect to fetch is the single clearest "I learned React in 2019" tell in an interview.

3 · TanStack Query: server state

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:

OptionMeaning
staleTimeHow 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).
gcTimeHow 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.
Interview: "What's the difference between 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.

4 · Mutations & cache invalidation

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.

5 · Zustand: client / UI state

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();
Selectors are the whole performance story Zustand's superpower is that a component subscribes to only the slice its selector returns. The component reading 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.
Interview: "Zustand vs Redux Toolkit — when would you pick each?" Zustand is the default for new apps: a few lines, no provider, no boilerplate, hooks-native, and selectors handle re-render performance. Redux Toolkit is the enterprise/legacy choice — reach for it when a large team already standardised on it, when you need its DevTools time-travel and strict action-log discipline, or when you're maintaining an existing Redux codebase. Both are fine; the mistake is putting server state in either one instead of TanStack Query.

6 · When Context is enough (and when it isn't)

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's value 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.

7 · URL state with searchParams

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),
});
Free features for free Lift a filter into the URL and you get the browser Back button, refresh-survival, deep links, and shareable views — all without writing a line of persistence code. URL state is the most under-used of the four kinds; reach for it more than you think.

8 · Form state: React Hook Form + zod

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.

Decision guide

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 backendTanStack Query (useQuery / useMutation)
UI-only and local to one componentuseState
UI-only but shared across the treeZustand with selectors
Worth sharing via a link / surviving refreshURL (useSearchParams)
Being typed into a formReact Hook Form + zod
Rarely-changing, app-wide (user, theme)Context

9 · Build it

Your tangible win Rebuild DocChat's document panel as a proper data layer: the list comes from TanStack Query, uploads go through a mutation that invalidates the cache, and the currently-open document plus sidebar state live in a Zustand store. Server state and client state, each in its right home — the exact architecture you'd ship.

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.

10 · Check yourself

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

Recall quiz

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…

Primary source ⭐ TanStack Query v5 docs — Overview: the canonical, 2026-current reference for queries, mutations, query keys, and invalidation. Pair it with the Zustand docs for stores and selectors, and React's own You Might Not Need an Effect for why fetching in effects is out.