modern-react-spa

Chapter 12

The Four Kinds of State

Every value in a React SPA belongs to one of four kinds of state — local, global, server cache, or URL. A decision flowchart that ends the 'which library should I use' debate.

Published 2026-05-23

🎯 Chapter Goal — After this chapter you can name the four kinds of state every SPA has, recognise which one you’re holding when you ask “where should this useState live”, and pick a tool that fits the kind — not the loudest framework that week.

🧭 Prerequisites — Ch 2 (hooks), Ch 5 (routing — URL is one of the four).


🔹 12.1 The taxonomy

                      ┌──────────────────────────────────────┐
                      │  Where does this value live?        │
                      └──────────────────────────────────────┘

        ┌──────────────────┬───────────┴───────────┬──────────────────┐
        ▼                  ▼                       ▼                  ▼
   ┌─────────┐        ┌─────────┐            ┌──────────┐       ┌──────────┐
   │ LOCAL   │        │ GLOBAL  │            │ SERVER   │       │ URL      │
   │         │        │         │            │ CACHE    │       │          │
   │ useState│        │ Zustand │            │ TanStack │       │ search-  │
   │ useReducer       │ Jotai   │            │ Query    │       │  params  │
   │         │        │ Recoil  │            │ SWR      │       │ pathname │
   │         │        │ Redux   │            │ RTK Query│       │          │
   └─────────┘        └─────────┘            └──────────┘       └──────────┘

Local state

Single component owns it. Form input, toggle, hover flag. useState / useReducer cover 100 % of the cases. Anything more is overengineering.

Global state

Two or more unrelated components need the same value. Shopping cart, user profile, feature flags loaded at boot, UI chrome state (sidebar open?).

The disqualifying test: if the value comes from the server and could change behind your back, it’s not global state — it’s server cache.

Server cache

Data your backend owns, fetched over HTTP. Lists of invoices, the current user record, autocomplete results. The cache concerns: when is it stale? when do we re-fetch? how do mutations invalidate? how do two components asking for the same thing coordinate?

Tools: TanStack Query (the book’s default — Ch 14), SWR, RTK Query, Apollo if you’re on GraphQL.

URL state

State that should be bookmarkable / shareable / survives reload. Filter selections, multi-step wizard position, current dashboard view. Lives in the URL pathname or search params.

Tools: native URLSearchParams, nuqs, TanStack Router’s validateSearch (Ch 5 §5.2, Ch 15).


🔹 12.2 The decision flowchart

Does the value come from the server (HTTP/WS)?

   ├── Yes ──▶ Server cache (Ch 14)

   └── No

        Does it need to survive reload or be shareable?

        ├── Yes ──▶ URL state (Ch 15)

        └── No

             Does more than one unrelated component need to read it?

             ├── Yes ──▶ Global state (Ch 13)

             └── No  ──▶ Local state (useState / useReducer)

Run the flow before you decide. Most “I need Redux for this” instincts are wrong; usually you need server cache or URL state.


🔹 12.3 Picking the wrong kind — the cost

Wrong: storing server data in global state.

Symptoms: stale data, manual revalidation logic in components, refresh button everywhere. Cost: 200–500 lines of cache-invalidation code that TanStack Query would handle for free.

Wrong: storing URL-worthy state in local state.

Symptoms: refresh resets filters; “can you share this view?” → “no, you’ll have to apply the filters again.” Cost: every user has to re-apply selections; bookmarks don’t work; deep links into product UIs are impossible.

Wrong: storing local state globally.

Symptoms: a Zustand store named formInputStore with setEmailFieldValue, setPasswordFieldValue. Cost: re-render storms across the app on every keystroke; obscures the form’s natural locality.


🔹 12.4 The “Context as global state” trap

This is the most common bug pattern in 2026 React. People reach for useContext because it’s built in.

const AppContext = createContext<AppState>(/* … */);

// Top-level provider that holds half the app's state:
<AppContext value={{ user, theme, cart, notifications, filters }}>
  {children}
</AppContext>

The trap: every consumer of AppContext re-renders whenever any field changes. Update a notification, every component reading user re-renders. Update the cart, every component reading theme re-renders.

The fix depends on what you really have:

  • Server data (user, notifications) → TanStack Query.
  • Cross-component UI state (theme, sidebar-open) → Zustand (selectors avoid the re-render storm).
  • Form/filter state — usually URL state.

useContext is the right tool only when the value rarely changes (locale, a stable feature-flag set) or when consumers are explicitly co-located in a subtree (FormProvider).


🔹 12.5 Concrete examples — “which kind?”

ValueKindWhy
email field on a login formLocalSingle component, transient
isMenuOpen for a nav drawerLocal or GlobalLocal if only the nav reads; global if the page also needs it
List of invoicesServer cacheBackend owns it; can change
Current logged-in userServer cacheEven though “global”-feeling, it’s data from the server
Theme (light/dark/system)GlobalMultiple components react; no server involvement
Selected invoice filtersURLBookmarkable view
Pagination page numberURLBookmarkable; survives reload
Modal open/closedLocalUI state of one parent
Toast notificationsGlobalAny component can fire a toast
Feature flagsServer cache or GlobalServer cache if fetched at runtime; global if baked at build

Running through this exercise on every new feature catches 80 % of “wrong tool” decisions.


🪤 Common Pitfalls

  1. Treating “shared between two components” as “needs global state” — sometimes it’s just lift-state-up.
  2. Putting server data in Zustand — you reinvent half of TanStack Query, badly.
  3. Putting form input state in URL — every keystroke is a navigation; URL gets noisy.
  4. Using useContext for high-frequency-changing values — re-render storm.
  5. URL state without a schema — bookmarks break when you rename a param.
  6. Local state for a value the user expects to survive reload — accidental data loss.

✅ Recap

  • Four kinds: local, global, server cache, URL.
  • The kind dictates the tool, not the other way around.
  • Most “I need Redux” instincts are really “I need TanStack Query” or “I need URL state.”
  • Context is the right tool for stable, low-frequency values — not as a general-purpose store.
  • Audit one screen at a time; correcting the kind usually deletes code.

🔗 Further Reading

  • Kent C. Dodds — “Application State Management with React” — the canonical decision-tree post.
  • TKDodo — “Why I don’t like reduce” series and the TanStack Query thinking.
  • Ch 13 (Global), Ch 14 (Server cache), Ch 15 (URL).

In the book — not on the site

Each topic has an 🧠 Under-the-hood subsection — the algorithm, the data structures, what React DevTools surfaces, debugging recipes. Plus a 🧪 hands-on lab per chapter with a starter repo. Reserved for the book.

Topics in this chapter (5)
  1. 12.1 The taxonomy
  2. 12.2 The decision flowchart
  3. 12.3 Picking the wrong kind — the cost
  4. 12.4 The “Context as global state” trap
  5. 12.5 Concrete examples — “which kind?”