Chapter 20

Routing and Global State Management

Client routing, URL state, context, stores and server state.

20. Client Routing and State Management

Modern front-end applications often feel like websites while behaving like long-lived programs. A navigation should change the address, support the browser's Back button, and render a new screen without unnecessarily reloading the whole document. Filters should be shareable as links. Server data should remain fresh without being fetched redundantly. Local interactions should feel immediate without turning every value into global state.

This chapter connects those concerns. It starts with the browser History API, treats the URL as durable state for navigation and filters, then distinguishes React Context, client state stores such as Zustand, and server-state tools such as TanStack Query.

Learning objectives

By the end of this chapter, you will be able to:

  1. Explain how pushState, replaceState, popstate, and browser history work.
  2. Use query parameters as the source of truth for navigable filters.
  3. Explain when Context is useful and where it becomes awkward.
  4. Model client-only state with Zustand without duplicating server records.
  5. Use TanStack Query for caching, invalidation, and optimistic updates.
  6. Choose a clear owner and lifetime for each kind of state.

Client-side routing and the History API

A traditional navigation requests a new HTML document. Client-side routing intercepts eligible link clicks, updates the active screen in JavaScript, and changes the address bar. The browser still owns the navigation history, so users can copy a URL, open it in a new tab, or return with Back and Forward.

The History API provides two key operations:

  • history.pushState(state, "", url) adds a history entry without loading the document.
  • history.replaceState(state, "", url) updates the current entry rather than adding another.

Neither operation fires a popstate event. The browser fires popstate when a user traverses history with Back or Forward. A router listens to that event, reads the current URL, and renders the matching route. A minimal demonstration:

js
function navigate(path) {
  history.pushState({}, "", path);
  renderRoute(new URL(location.href));
}

window.addEventListener("popstate", () => {
  renderRoute(new URL(location.href));
});

function renderRoute(url) {
  const root = document.querySelector("#app");
  if (url.pathname === "/products") {
    root.textContent = "Product catalogue";
  } else if (url.pathname === "/about") {
    root.textContent = "About us";
  } else {
    root.textContent = "Not found";
  }
}

This is a teaching sketch, not a replacement for a production router. It does not intercept links, preserve modified-click behavior, handle nested routes, coordinate loading, manage focus, or define error boundaries. A router such as React Router, TanStack Router, Vue Router, or Angular Router handles those concerns and provides route matching, links, nested layouts, navigation state, and often data-loading integration.

The server must also know how to serve the application's entry document for client-side paths. If /products/42 is opened directly, the web server receives that path before JavaScript runs. A static SPA host usually needs a fallback to index.html; otherwise the client router never gets the chance to match the route. The fallback must not swallow missing asset requests or server-owned API paths. Chapter 22 covers hosting rules.

Use native anchors or the router's link component for navigation rather than clickable <div> elements. Links support keyboard interaction, open-in-new-tab, copying the destination, and browser conventions. A router can intercept ordinary same-origin navigation while allowing modifier-clicks and external destinations to retain native behavior.

The URL as the source of truth for filters

The URL is an excellent home for state that should survive reloads, be shared, or participate in Back and Forward navigation: search terms, sort order, pagination, selected category, and sometimes tabs. It is usually not the right home for every ephemeral detail, such as whether a tooltip is open.

Consider a catalogue where the URL may be:

text
/products?query=lamp&category=lighting&page=2

The query string describes the visible result set. A user can bookmark it or send it to a colleague. Reloading should reconstruct the same filter controls and results. Parse and validate URL input because query parameters are untrusted text:

js
function readProductFilters(url = new URL(location.href)) {
  const params = url.searchParams;
  const query = (params.get("query") ?? "").trim().slice(0, 100);
  const category = params.get("category") ?? "all";
  const requestedPage = Number(params.get("page") ?? "1");
  const page = Number.isInteger(requestedPage) && requestedPage > 0
    ? Math.min(requestedPage, 1000)
    : 1;

  return { query, category, page };
}

When the user changes a filter, update only the relevant parameters and preserve other route state. replaceState is suitable for high-frequency text edits if each keystroke should not create a Back entry; pushState is suitable when a committed change should be navigable. Routers expose their own navigation functions and should be preferred in router-managed applications.

js
function writeProductFilters(filters, { replace = true } = {}) {
  const url = new URL(location.href);
  const query = filters.query.trim();
  if (query) url.searchParams.set("query", query);
  else url.searchParams.delete("query");

  if (filters.category !== "all") {
    url.searchParams.set("category", filters.category);
  } else {
    url.searchParams.delete("category");
  }
  if (filters.page > 1) url.searchParams.set("page", String(filters.page));
  else url.searchParams.delete("page");

  history[replace ? "replaceState" : "pushState"]({}, "", url);
}

Calling replaceState does not make React, Vue, or another framework rerender automatically. The router or an explicit state subscription must be told that navigation occurred. Do not update the address bar independently while a router maintains its own location state: that creates two competing sources of truth.

A router-centric React example using React Router's useSearchParams illustrates the intended model:

jsx
import { useSearchParams } from "react-router";

function ProductFilters() {
  const [searchParams, setSearchParams] = useSearchParams();
  const query = searchParams.get("query") ?? "";
  const category = searchParams.get("category") ?? "all";

  function changeQuery(nextQuery) {
    const next = new URLSearchParams(searchParams);
    if (nextQuery.trim()) next.set("query", nextQuery);
    else next.delete("query");
    next.delete("page");
    setSearchParams(next, { replace: true });
  }

  function changeCategory(nextCategory) {
    const next = new URLSearchParams(searchParams);
    if (nextCategory === "all") next.delete("category");
    else next.set("category", nextCategory);
    next.delete("page");
    setSearchParams(next);
  }

  return (
    <>
      <label>
        Search products
        <input value={query} onChange={(event) => changeQuery(event.target.value)} />
      </label>
      <label>
        Category
        <select value={category} onChange={(event) => changeCategory(event.target.value)}>
          <option value="all">All</option>
          <option value="lighting">Lighting</option>
          <option value="furniture">Furniture</option>
        </select>
      </label>
    </>
  );
}

The exact router import and option types depend on the installed router version and router mode; use the API for that project rather than mixing routers. The important design is that controls read from the current URL and updates navigate through the router. Reset pagination when a filter changes because page 8 of one result set may not exist in another.

For text search, decide whether every keystroke should update the URL or whether input is debounced. A common design keeps a short-lived draft string in the input and commits to the URL after a pause or form submission. If using a draft, synchronize it on browser navigation so Back restores the field. Treat this as an intentional two-step interaction rather than a second permanent copy of the search term.

State categories and ownership

Before choosing a library, classify the value:

State kind Examples Typical owner
Route state Current path, selected tab, route parameters Router
Shareable view state Search, sort, pagination URL query parameters
Local UI state Open menu, draft text, focused row Component
Shared client state Unsaved wizard draft, client preference Store or Context
Server state Products, user profile, orders Query cache / framework loader
Persistent preference Theme, dismissed hint Storage plus a defined sync policy

The categories can intersect. A filter can be URL state while its results are server data cached by TanStack Query. A wizard can have a local draft that is saved to an API. The objective is not to label everything once, but to define one canonical owner and explicit transitions.

React Context: useful scope, not an all-purpose store

Context lets a provider make a value available to descendants without threading it through every intermediate component. It is useful for stable dependencies such as a theme, localization object, or authentication session, and for state whose consumers belong to a clear subtree.

jsx
import { createContext, useContext, useMemo, useState } from "react";

const ThemeContext = createContext(null);

export function ThemeProvider({ children }) {
  const [theme, setTheme] = useState("light");
  const value = useMemo(() => ({ theme, setTheme }), [theme]);
  return <ThemeContext.Provider value={value}>{children}</ThemeContext.Provider>;
}

export function useTheme() {
  const value = useContext(ThemeContext);
  if (value === null) throw new Error("useTheme must be used inside ThemeProvider");
  return value;
}

Every consumer of a context can rerender when its provider value changes. A newly created object on every parent render also changes identity; memoizing a value can prevent updates caused only by a fresh wrapper object, though it does not prevent consumers from rerendering when theme truly changes. Split unrelated, frequently updated values into separate contexts or use a store with selector subscriptions when profiling indicates broad updates are expensive.

Context does not provide persistence, selectors, undo history, server fetching, cache invalidation, or a reducer automatically. It can pair with useReducer for a small state machine:

jsx
const initialCart = { items: [] };
function cartReducer(state, action) {
  switch (action.type) {
    case "add":
      return { ...state, items: [...state.items, action.item] };
    case "remove":
      return { ...state, items: state.items.filter((item) => item.id !== action.id) };
    default:
      return state;
  }
}

Use the reducer when transitions benefit from a named action model. Avoid one enormous provider containing unrelated application state: it couples distant features and makes ownership difficult to understand.

Zustand for shared client state

Zustand is a small external store for client-owned state. Components subscribe through selectors, so a component can read just the field it needs. A compact cart store might look like this:

jsx
import { create } from "zustand";

const useCartStore = create((set) => ({
  items: [],
  addItem: (item) => set((state) => ({ items: [...state.items, item] })),
  removeItem: (id) =>
    set((state) => ({ items: state.items.filter((item) => item.id !== id) })),
  clear: () => set({ items: [] }),
}));

function CartCount() {
  const count = useCartStore((state) => state.items.length);
  return <span>Cart: {count}</span>;
}

function AddToCartButton({ product }) {
  const addItem = useCartStore((state) => state.addItem);
  return <button onClick={() => addItem(product)}>Add to cart</button>;
}

Selector-based subscription avoids rerendering CartCount when an unrelated store field changes, provided the selected output remains equal. Select primitive values or stable references; a selector that constructs a new object on each call may require Zustand's equality helpers or a different selector shape. Store actions should express domain intent, not expose arbitrary mutation of internal data.

Zustand is appropriate for client-owned shared state such as a multi-step editor draft, transient cross-page UI, or a cart before server synchronization. It is not automatically a better place for API records. Putting fetched products in both Zustand and TanStack Query creates duplicate caches with separate freshness and error rules. If using persistence middleware, define what is safe to persist, version the stored shape, handle corrupt or obsolete data, and never put secrets such as access tokens in casual browser storage.

TanStack Query for server state

Server data has a different lifecycle from local state. It can become stale because another user changed it, a request can fail, the network can disappear, and multiple components may request the same record. TanStack Query (also called React Query) manages asynchronous server-state caching, request status, deduplication, invalidation, and refetch policies.

In TanStack Query v5, a QueryClient is provided at the application root. Queries have stable array keys that describe the requested data. Include all request-defining parameters in the key so distinct filters do not collide:

jsx
import { QueryClient, QueryClientProvider, useQuery } from "@tanstack/react-query";

const queryClient = new QueryClient();

function AppRoot() {
  return (
    <QueryClientProvider client={queryClient}>
      <ProductResults />
    </QueryClientProvider>
  );
}

function ProductResults({ query, category }) {
  const productsQuery = useQuery({
    queryKey: ["products", { query, category }],
    queryFn: async ({ signal }) => {
      const params = new URLSearchParams({ query, category });
      const response = await fetch(`/api/products?${params}`, { signal });
      if (!response.ok) throw new Error("Could not load products");
      return response.json();
    },
  });

  if (productsQuery.isPending) return <p>Loading products…</p>;
  if (productsQuery.isError) {
    return <p role="alert">Unable to load products: {productsQuery.error.message}</p>;
  }
  return <ProductList products={productsQuery.data} />;
}

The signal allows an obsolete request to be aborted when the query is no longer needed. A query function must reject or throw for an unsuccessful response; fetch does not throw just because the server returned an HTTP error status. The query key includes filter parameters so the cache can represent each result set separately. In a real component, get URL state from the router and pass normalized values into the query.

Freshness, cache, and invalidation

TanStack Query keeps query results in a client cache. staleTime controls how long data is considered fresh for automatic refetch decisions; gcTime controls how long an inactive query remains in memory before garbage collection. These settings are not the server's cache headers and do not make data correct forever. Choose freshness based on the domain: live inventory can need rapid revalidation, while a country list may change rarely.

After a successful mutation, invalidate queries that may now be out of date:

jsx
import { useMutation, useQueryClient } from "@tanstack/react-query";

function useRenameProduct() {
  const queryClient = useQueryClient();
  return useMutation({
    mutationFn: async ({ id, name }) => {
      const response = await fetch(`/api/products/${id}`, {
        method: "PATCH",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ name }),
      });
      if (!response.ok) throw new Error("Rename failed");
      return response.json();
    },
    onSuccess: (updatedProduct) => {
      queryClient.setQueryData(["product", updatedProduct.id], updatedProduct);
      queryClient.invalidateQueries({ queryKey: ["products"] });
    },
  });
}

setQueryData can update a known detail record immediately. Invalidating the collection asks matching queries to revalidate when appropriate. The mutation endpoint remains responsible for authorization, validation, and persistence; client-side checks are not security controls.

Optimistic updates with rollback

An optimistic update changes the visible cache before the server confirms success. This can make a quick reversible action feel responsive, but it must restore state if the request fails and reconcile with the server's response.

jsx
function useToggleFavorite(productId) {
  const queryClient = useQueryClient();
  const key = ["product", productId];

  return useMutation({
    mutationFn: async (favorite) => {
      const response = await fetch(`/api/products/${productId}/favorite`, {
        method: "PUT",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ favorite }),
      });
      if (!response.ok) throw new Error("Could not update favorite");
      return response.json();
    },
    onMutate: async (favorite) => {
      await queryClient.cancelQueries({ queryKey: key });
      const previous = queryClient.getQueryData(key);
      queryClient.setQueryData(key, (product) =>
        product ? { ...product, favorite } : product,
      );
      return { previous };
    },
    onError: (_error, _favorite, context) => {
      if (context?.previous !== undefined) {
        queryClient.setQueryData(key, context.previous);
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: key });
    },
  });
}

This strategy is safest when mutations are quick, reversible, and not likely to conflict with concurrent writes. If two requests race, a simple snapshot rollback can overwrite a newer update. For high-impact changes such as payments, inventory reservation, or collaborative edits, prefer explicit pending feedback or a server-defined conflict strategy. Optimistic UI is a presentation technique, not proof that the server accepted an action.

Putting URL state and query state together

A useful flow for the catalogue is: the router parses query, category, and page from the URL; the filter controls navigate when changed; the normalized values form the TanStack Query key; the query fetches server-owned results; components render loading, error, and success states. This keeps the filter addressable and results cacheable without keeping a second authoritative filter object in Zustand.

Client state can still participate: a local input draft may debounce URL updates, and a store may hold a temporary comparison list. Keep the query key aligned with the request. When filters change, the new key describes a new result; use query invalidation after mutation when cached server data is affected. Decide which direction data flows and which layer owns updates before implementing cache synchronization.

Troubleshooting and best practices

Back changes the address but not the screen. The router is not observing popstate, or the application bypassed its navigation API. Use the router's navigate/link/search-parameter API for router-controlled state.

A URL filter and control disagree. Determine whether an input draft is intentionally separate. Otherwise read the displayed filter from route state, and synchronize drafts when navigation changes. Do not initialize local state from the URL once and then let it drift permanently.

Different searches show the wrong results. Include every request parameter in the query key. Avoid mutable key objects or hand-built strings that omit fields. Normalize values consistently before key construction and before sending the request.

A query does not show an HTTP failure. Check response.ok and throw on non-success statuses. fetch resolves normally for 404 and 500 responses.

A mutation appears successful but the list is stale. Update the exact detail query if appropriate and invalidate affected collection queries. Ensure invalidation keys share a correct common prefix.

An optimistic change remains after failure. Return a snapshot from onMutate, restore it in onError, and reconcile/invalidate in onSettled. Check for concurrent writes and use a more robust model where rollback is unsafe.

Context causes broad rendering. Split contexts by update frequency and ownership, stabilize provider values, or adopt selector subscriptions after measuring the issue. Context is not inherently slow; broad, frequently changing values are the problem.

Too many libraries manage the same records. Establish a state inventory. Server records generally belong to one query cache or framework data layer. URL state belongs to the URL. Local interactions stay local unless another view needs them.

Exercises

Exercise 1: Add sort order and preserve navigation

Add a sort query parameter with name and price options. Keep it when the user changes category, reset page to 1 after changing sort, and ensure Back restores the previous sort.

Solution

Read the value from the router, validate it against an allowlist, and use a default:

js
const allowedSorts = new Set(["name", "price"]);
const requestedSort = searchParams.get("sort");
const sort = allowedSorts.has(requestedSort) ? requestedSort : "name";

When changing sort, clone current parameters, set the allowed value, delete page, and navigate with setSearchParams(next) using push navigation so Back can restore the previous choice. For category changes, clone current parameters and update only category; do not rebuild the whole search string from scratch, which would silently discard sort. Add sort to the query key, for example ["products", { query, category, sort, page }], and send it to the API only if the API supports that sort. Validate it again on the server. A client-side sort can sort a copied result array, but it should not mutate query cache data.

Exercise 2: Optimistically update a cart quantity

Create a client-side Zustand store with items, setQuantity(id, quantity), and a selector that computes the item count. Explain why this is client state rather than a TanStack Query cache.

Solution

js
import { create } from "zustand";

export const useCartStore = create((set) => ({
  items: [],
  setQuantity: (id, quantity) =>
    set((state) => ({
      items: quantity <= 0
        ? state.items.filter((item) => item.id !== id)
        : state.items.map((item) =>
            item.id === id ? { ...item, quantity } : item,
          ),
    })),
}));

export function useCartItemCount() {
  return useCartStore((state) =>
    state.items.reduce((total, item) => total + item.quantity, 0),
  );
}

The store owns a client-side draft of the cart interaction and makes immutable replacements. The count is derived rather than independently stored. If a server persists the cart, submit changes to the API and treat the server response as authoritative; use a query mutation or an explicitly designed synchronization protocol. A cart's local draft can live in Zustand, but the server's canonical order or checkout inventory should not be disguised as a client-only truth.

Key takeaways

  • A router connects paths to UI while respecting browser history; navigate through its APIs.
  • Shareable filters and pagination belong in query parameters, with validation and deliberate push/replace behavior.
  • Context provides subtree access, Zustand holds shared client-owned state, and TanStack Query manages asynchronous server data.
  • Query keys describe every request input; invalidation restores freshness after mutations.
  • Optimistic updates require a rollback plan and are inappropriate when failures or conflicts are dangerous.