The State Management Ecosystem: Categories, Libraries, and When You Actually Need One (ECOSYSTEM)

Understand the distinct categories of state a React app deals with — local, context, reducer, URL, server, global client, and external store state — and get oriented in the ecosystem of libraries (Redux Toolkit, Zustand, MobX, Jotai, TanStack Query) built to manage them, while learning that most apps need far less of this ecosystem than their tooling choices might suggest.

State management' sounds like one problem, but it is really several different problems wearing the same name. The state of whether a dropdown is open, the state of what's in the user's shopping cart, the state currently reflected in the URL, and the state of data fetched from a server all behave differently and often need different tools to manage them well. This lesson is marked ECOSYSTEM because it surveys the landscape of libraries the community has built around these problems — you do not need to memorize every API, but you should be able to name the category of state you're dealing with and recognize what kind of tool fits it.

The categories of state

Local state is state that only one component (and maybe its direct children) cares about — useState for a form field or a hover flag. Context state is state shared across a subtree without prop drilling, delivered via React's built-in Context API — appropriate for state like the current theme or the logged-in user that many unrelated components need to read. Reducer state (useReducer) is local or Context state whose updates follow more complex rules than a plain setter — several related fields that change together, or update logic that benefits from being expressed as explicit action types rather than ad hoc setter calls.

URL state is state that belongs in the address bar — the current page number, a search filter, a selected tab — because it should survive a refresh and be shareable as a link; React Router's useSearchParams or a similar router hook is the right tool, not useState, because useState state disappears the moment the page reloads. Server state is data that actually lives on a server and is only cached on the client — a list of products, a user's profile — and it is fundamentally different from client state because it can go stale, needs refetching, and can be being edited by someone else at the same time.

Global client state is state that is genuinely client-only (not mirrored from a server) but needed across far-apart parts of the app — a shopping cart before checkout, whether a global command palette is open, accumulated undo history. External stores is the general term for state that lives outside React's own state system entirely (a WebSocket connection's latest message, a third-party map library's current viewport) and is synchronized into React via useSyncExternalStore or a similar hook rather than useState.

Recognizing which category a piece of state falls into is most of the work: a huge amount of 'do I need Redux' anxiety disappears once you notice that a given piece of state is actually server state (and belongs in TanStack Query) or URL state (and belongs in the router), not global client state at all.

The ecosystem: Redux Toolkit, Zustand, MobX, Jotai, and TanStack Query

This section is ECOSYSTEM: none of these libraries ship with React, and a production app can be built entirely without any of them using only useState, useReducer, and Context. Redux Toolkit is the modern, official way to write Redux — a single, centralized store with explicit actions and reducers, strong DevTools support, and a steep-but-worth-it learning curve for apps with complex, deeply shared client state. Zustand is a much smaller library offering a similar centralized-store idea with a far simpler API (no actions, no reducers, just a hook backed by a plain store object) and minimal boilerplate, popular for apps that want Redux-like global state without Redux's ceremony.

MobX takes a different philosophical approach: instead of an explicit dispatch-and-reducer flow, you mutate observable state directly and MobX automatically tracks which components read which pieces of state, re-rendering only what changed — attractive to teams who find Redux's immutability discipline heavier than they want. Jotai offers atomic state: instead of one big store, you define small independent pieces of state ('atoms') and compose them, which scales well when different parts of an app need small, loosely related bits of shared state rather than one large shared store.

TanStack Query (formerly React Query) is not a general state manager at all — it is specifically a server state library: it fetches, caches, deduplicates, retries, and invalidates data from an API, tracks loading/error states for you, and refetches on window focus or on an interval. It solves a different problem than Redux/Zustand/MobX/Jotai, and it is common (and often correct) to use TanStack Query for all server state alongside Context or Zustand for the much smaller amount of genuinely global client state an app has left over.

Not every app needs a global state library

This point is worth stating plainly: most React applications do not need a global state library at all. A large share of what looks like 'global state' at first glance is actually server state (fetch it with TanStack Query or even a plain useEffect + useState for a small app) or URL state (put it in the router) — once those are pulled out, what's left as genuinely global, client-only state is often small enough that Context plus useReducer handles it comfortably.

Reach for a dedicated global state library only when you have concrete symptoms: deeply nested prop drilling that composition and Context can't reasonably fix, frequent updates to global state causing performance problems because Context re-renders every consumer on every change, or complex update logic shared across many unrelated components that benefits from centralized, testable reducers and time-travel debugging. Adopting Redux Toolkit on day one of a small app 'because that's what real apps use' adds boilerplate and indirection the app does not yet need.

Selector patterns, normalization, optimistic updates, and cache invalidation

A selector is a function that reads a derived slice of state out of a larger store — const cartTotal = useCartStore(state => state.items.reduce(...)) — and the point of writing selectors instead of reading the whole store is performance: a component that only subscribes to cartTotal via a selector re-renders only when that computed value changes, not whenever any unrelated field in the store changes. Selector patterns matter more as a store grows, because without them every component that touches the store tends to re-render on every store update.

State normalization is storing collections the way a relational database would — a flat map of id to entity (users: { '1': {...}, '2': {...} }) plus arrays of ids for ordering — instead of deeply nested objects. Normalized state avoids duplicate copies of the same entity living in different places (and drifting out of sync with each other) and makes 'update this one entity everywhere it appears' a single, cheap operation instead of a deep, error-prone tree walk.

An optimistic update changes the UI immediately when the user takes an action (liking a post, deleting an item) before the server has confirmed it succeeded, then rolls the change back if the request ultimately fails — it makes an app feel instant instead of waiting on network latency for every interaction, at the cost of needing a rollback path for the failure case. Cache invalidation is the act of marking cached server data as stale so it gets refetched — after a mutation that changes data the cache is holding (creating a new invoice should invalidate the cached invoice list), a cache-aware library like TanStack Query needs to be told which cached queries that mutation affects so it can refetch them rather than silently showing stale data.

Matching a state category to a tool

Each category has a natural home — the mistake to avoid is defaulting to one tool (usually a global store) for every category of state instead of matching the tool to the category. TanStack Query and Zustand are both ECOSYSTEM (third-party) — React itself ships only useState, useReducer, and Context.

// Local state — useState
const [isOpen, setIsOpen] = useState(false);

// Reducer state — useReducer, for related fields with complex update rules
const [state, dispatch] = useReducer(formReducer, initialFormState);

// Context state — shared across a subtree, no external library
const ThemeContext = createContext<"light" | "dark">("light");

// URL state — the router, not useState
const [searchParams, setSearchParams] = useSearchParams();
const page = Number(searchParams.get("page") ?? "1");

// Server state — TanStack Query (ECOSYSTEM)
const { data: invoices, isLoading } = useQuery({
  queryKey: ["invoices"],
  queryFn: fetchInvoices,
});

// Global client state — Zustand (ECOSYSTEM), only if Context truly isn't enough
const useCartStore = create<{ items: CartItem[]; addItem: (i: CartItem) => void }>((set) => ({
  items: [],
  addItem: (item) => set((s) => ({ items: [...s.items, item] })),
}));

Code Examples

The selector version only re-renders CartBadge when state.items.length actually changes, not whenever any unrelated field in the cart store updates — this matters increasingly as a store grows to hold more unrelated state.

const itemCount = useCartStore((state) => state.items.length);

onMutate updates the cached UI immediately (optimistic update) and stashes the previous data; onError rolls back to that snapshot if the request fails; onSettled invalidates the posts query so the cache is refreshed from the server regardless of outcome, keeping the client eventually consistent with the real data. This pattern — optimistic update, rollback path, then invalidate — is the standard shape for any mutation-heavy UI (likes, follows, inline edits) built on a cache-aware server-state library.

// Without a selector: subscribes to the ENTIRE store
function CartBadge() {
  const store = useCartStore(); // re-renders on ANY store change
  return <span>{store.items.length}</span>;
}

// With a selector: subscribes only to the derived value it needs
function CartBadge() {
  const itemCount = useCartStore((state) => state.items.length);
  return <span>{itemCount}</span>;
}
const queryClient = useQueryClient();

const likeMutation = useMutation({
  mutationFn: (postId: string) => api.likePost(postId),
  onMutate: async (postId) => {
    await queryClient.cancelQueries({ queryKey: ["posts"] });
    const previous = queryClient.getQueryData<Post[]>(["posts"]);
    queryClient.setQueryData<Post[]>(["posts"], (old) =>
      old?.map((p) => (p.id === postId ? { ...p, liked: true, likes: p.likes + 1 } : p))
    );
    return { previous }; // saved for rollback
  },
  onError: (_err, _postId, context) => {
    if (context?.previous) queryClient.setQueryData(["posts"], context.previous); // rollback
  },
  onSettled: () => {
    queryClient.invalidateQueries({ queryKey: ["posts"] }); // refetch the source of truth
  },
});

Deciding which category a piece of state belongs to

Work through the categories in order — most state resolves before ever reaching the 'add a global library' branch.

Does it come from a server?  --yes--> Server state (TanStack Query / SWR)
        | no
Should it survive a refresh / be shareable as a link?  --yes--> URL state (router)
        | no
Do many unrelated components need it?  --no--> Local state (useState/useReducer)
        | yes
Does Context re-rendering become a real perf problem?  --no--> Context
        | yes
Global client state library (Zustand / Jotai / Redux Toolkit)

Important Rules

Identify which category (local, Context, reducer, URL, server, global client, external store) a piece of state belongs to before picking a tool for it.

Put server-fetched data in a server-state tool (or at minimum a dedicated fetch hook), never in the same bucket as genuinely client-only global state.

Put anything that should survive a page refresh or be shareable as a link into the URL, not into component or store state.

Do not adopt a global state library by default — reach for one only once Context and local state produce a concrete, observed problem (prop-drilling pain or re-render performance issues).

Use selectors when reading from any store-like state so components only re-render for the specific slice of data they actually use.

Where this shows up

Production teams commonly end up with a layered mix rather than one library for everything: TanStack Query (or SWR) for all server data, the router's own state for filters and pagination reflected in the URL, React Context for small pieces of truly global client state like the current theme or locale, and a lightweight store like Zustand only for state that is both global and does not fit Context well (like a shopping cart that many unrelated screens read and write). Reaching for Redux Toolkit specifically tends to happen in larger apps with complex, deeply interconnected client-side workflows that benefit from Redux's centralized action log and time-travel debugging.

// Storing fetched server data in a global Zustand store as if it were client state const useAppStore = create((set) => ({ invoices: [], fetchInvoices: async () => { const data = await api.getInvoices(); set({ invoices: data }); // no loading state, no error state, no cache invalidation, no refetch-on-focus }, }));

// Server data belongs in a server-state tool that handles loading/error/staleness for you const { data: invoices, isLoading, error } = useQuery({ queryKey: ["invoices"], queryFn: () => api.getInvoices(), });

Hand-rolling server data inside a generic global store means reimplementing loading states, error handling, caching, and refetch logic yourself — a dedicated server-state library already solves all of that, and mixing server data into client state also makes cache invalidation and staleness much harder to reason about.

[multiple-choice] Which category of state is data fetched from a server, such as a list of products from an API? A. Local state B. URL state C. Server state D. Global client state

C. Server state — Data fetched from a server is server state — it can go stale, needs refetching, and behaves fundamentally differently from client-only state, which is why dedicated tools like TanStack Query exist specifically for it.

[multiple-choice] According to this lesson, when should a team adopt a dedicated global state library like Redux Toolkit or Zustand? A. On day one of every new React project, regardless of size B. Only once Context and local state produce a concrete, observed problem such as re-render performance issues or unmanageable prop drilling C. Only when the app has no server data at all D. Never — global state libraries should always be avoided

B. Only once Context and local state produce a concrete, observed problem such as re-render performance issues or unmanageable prop drilling — Not every React application needs a global state library; reaching for one should be driven by concrete symptoms (performance problems, unmanageable prop drilling) rather than adopted by default.

[multiple-choice] What does 'cache invalidation' mean in the context of a server-state library like TanStack Query? A. Deleting all data from the browser's local storage B. Marking cached data as stale so it gets refetched, typically after a mutation changes data the cache is holding C. Preventing a component from ever re-rendering D. Converting server state into URL state

B. Marking cached data as stale so it gets refetched, typically after a mutation changes data the cache is holding — Cache invalidation tells the library that some cached query result is no longer trustworthy (usually because a mutation changed the underlying data) so it should be refetched rather than continuing to show stale data.

For each of the following, name the state category it belongs to and the kind of tool that fits it: (a) whether a mobile nav menu is open, (b) the list of products currently shown after applying a price filter reflected in the URL, (c) a logged-in user's profile fetched from /api/me, (d) items in a shopping cart accessible from both the product page and the checkout page.

(a) Local state — useState in the nav component. (b) URL state — router search params, with the product list itself likely derived from server state (TanStack Query) filtered by those params. (c) Server state — TanStack Query / SWR, since it is fetched and can go stale. (d) Global client state — Context or a small store like Zustand, since it's client-only but needed across far-apart screens.

Ask: does it come from a server? Should it survive a refresh? Do unrelated, far-apart components need it?