MS Stack Ch 10 — Routing and state in SPAs
React Router (nested + lazy), URL as state with useSearchParams, server state vs UI state, TanStack Query, Zustand vs Redux Toolkit. Choose your state strategy deliberately.
Published Updated
Lesson preparation & details
Level: intermediate
By the end, you should be able to
- Build nested router layouts and deliberate history transitions
- Validate non-secret URL state and design tenant-aware query keys
- Separate query results from drafts and avoid stale optimistic rollback
- Choose stores and lazy boundaries from ownership and measured cost
Bring with you
Sources checked: . A source check does not change the publication or update date.
Editorial review: · What review means
In this article · 30 sections
Versions and example contract
This chapter uses React Router 6.30.1 declarative routing, TanStack Query v5, Zustand v5, Zod 4 and React 19. Router v7 framework-only prefetch props are not assumed. The named api, DTOs, page components and backend functions are application seams; api must reject non-success responses, validate payloads and forward cancellation. Examples are source-reviewed and selected pure functions/mock lifecycles are locally checked; no installed-router/browser application integration is claimed. Compact query keys below assume one authorization scope per QueryClient. If one client serves multiple accounts/tenants, include that scope and every response-changing filter in keys, and clear or segregate cached data on account changes; a URL parameter is not trusted tenant authorization.
Chapter 10 of From Novice to Fluent on the Modern Microsoft Web Stack — a 22-chapter self-study plan.
Why this chapter
A single-page app starts as a list of routes and a state strategy. Get those two right and everything downstream becomes easier — back/forward buttons just work, deep links open the right screen with the right filters, the cache layer prevents your backend from getting hammered, and components don't have to know whether their data came from a fresh fetch or a hit cache. Get them wrong and every feature you ship costs a refactor.
A useful design starts with ownership, not a favourite library: remote authoritative data needs a query cache, shareable view state needs a URL, an unfinished draft needs local state, and genuinely shared UI state may need context or a store. TanStack Query is our chosen server-state implementation, not a prohibition on RTK Query. A draft may deliberately fork server data while the form is being edited; define what happens when the server changes underneath it.
You finish when you can build nested layouts and lazy routes, validate URL filters, design cache keys and reconcile failed or overlapping mutations without restoring stale data over someone else's successful operation.
React Router fluency — Nested routes, Outlets, NavLinks, programmatic navigation, dynamic params, layouts.
URL as state — Filters, pagination, tab selection — useSearchParams everywhere they make sense.
Server vs UI state — Separate authoritative query results from local drafts and interaction state.
TanStack Query design — Query keys, staleTime, gcTime, optimistic updates, mutation lifecycle.
When to reach for a store — Zustand for small global state, Redux Toolkit for large with devtools.
Avoid the global-state trap — Most state is local. Default to leaves; lift only when forced.
Concepts and depth
React Router — BrowserRouter, Routes, Route
For the pinned React Router 6.30.1 SPA examples, the pieces are:
<BrowserRouter>wraps your app and uses the History API so URLs look normal (/users/42). Use<HashRouter>only if you must deploy to a host that can't do server-side fallback (choose from your host's actual rewrite capabilities).<Routes>matches a route branch: a parent layout and multiple nested child elements may render together. It is not limited to one route element.<Route path="/users" element={<Users />} />declares a path and its element. Paths support dynamic segments (/users/:id), wildcards (*), and optional segments (/users?).
You typically declare all routes in one place (often routes.tsx or inside the root App.tsx). v6.4+ added a data router mode (createBrowserRouter + loader/action functions) that handles data fetching at the route level — useful when navigation should await data or handle route errors. Data loaders can call a query client; choose one owner for fetching/revalidation rather than maintaining competing caches.
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboards" element={<DashboardList />} />
<Route path="/dashboards/:id" element={<DashboardDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>Good enough to ship
• Use BrowserRouter for normal URLs
• Declare all routes in one file
• Always have a * not-found route
Expert tier
• Use the data-router (createBrowserRouter) when its loaders earn their keep • Type route params with template literal types • Server-side fallback rewrites for non-root paths
Nested routes and <Outlet>
A real app has shared layouts: a header, a sidebar, sometimes a breadcrumb bar — all surrounding the current route's content. React Router nested routes are the right way to model this. A parent route renders a layout; child routes render into the parent's <Outlet />.
<Routes>
<Route path="/" element={<AppShell />}>
<Route index element={<Home />} />
<Route path="dashboards" element={<DashboardList />} />
<Route path="dashboards/:id" element={<DashboardLayout />}>
<Route index element={<Overview />} />
<Route path="charts" element={<Charts />} />
<Route path="settings" element={<Settings />} />
</Route>
</Route>
<Route path="*" element={<NotFound />} />
</Routes>
function AppShell() {
return (
<div className="app">
<Sidebar />
<main><Outlet /></main>
</div>
);
}indexis the child that renders when the parent path matches exactly.- Child paths are relative to the parent (
dashboards/:id/charts, not/dashboards/:id/charts). - Layouts compose naturally —
DashboardLayoutwraps a sub-router foroverview/charts/settings. - Avoid duplicating layout components across routes; nested routes are how you don't.
Good enough to ship
• Model layouts as parent routes
• Use index for the default child
• Reach for nested routes before duplicating layouts
Expert tier
• Nest layouts 3+ deep for complex apps • Use Outlet context to pass data from parent to children without props • Lazy-load layout boundaries to split bundle by section
Link, NavLink, useNavigate
Prefer React Router's <Link> for client-side navigation within this router. An ordinary same-tab anchor to another document URL performs document navigation and resets in-memory React state; same-document fragment links are an exception. <Link to="/users/42"> updates the URL via History API and lets React Router re-render the matching route.
<NavLink> is <Link> with active-state awareness. It accepts a className (or style) function that receives { isActive, isPending } so you can apply active styles. Use for sidebars, tabs, top nav. Pending state requires a data router; the declarative examples below use only isActive. An active NavLink supplies aria-current="page". External links, downloads and deliberate document navigations remain ordinary anchors.
useNavigate() returns an imperative navigation function for programmatic transitions: after a form submits, after a delete, redirecting unauthenticated users. Pass a string path (navigate('/login')), a relative path (navigate('..')), or a number to go back/forward (navigate(-1)).
<NavLink to="/dashboards" className={({ isActive }) => isActive ? 'nav-link active' : 'nav-link'}>
Dashboards
</NavLink>
function DeleteButton({ id }: { id: string }) {
const navigate = useNavigate();
return (
<button onClick={async () => {
await api.delete(`/dashboards/${encodeURIComponent(id)}`);
navigate('/dashboards', { replace: true });
}}>Delete</button>
);
}Good enough to ship
• Link for navigation, NavLink for active styling • useNavigate for programmatic transitions • Use anchors when a document navigation or download is intended
Expert tier
• Use replace: true for post-action navigation that shouldn't pollute history
• Pass state via navigate(path, { state }) for transient data
• Retain NavLink's automatic aria-current and a visible focus style
useParams, useLocation, useSearchParams
useParams() returns the dynamic segments of the current route. For /users/:id, useParams<{ id: string }>() gives you { id }. The values are always string | undefined — convert and validate (don't trust the URL).
useLocation() returns the current Location object — pathname, search, hash, state. Use to read where you are, especially in higher-order components that don't live inside a <Route>.
useSearchParams() is the killer hook. It returns [searchParams, setSearchParams], where searchParams is a URLSearchParams instance. Changing search params via setSearchParams({ q: 'foo' }) updates the URL without leaving the route. This is a good place for non-sensitive, serializable view state that should survive refresh or travel in a shared link: filters, sorting, pagination and tab selections. Private drafts and credentials need a different persistence/ownership policy.
function UsersList() {
const [params, setParams] = useSearchParams();
const q = params.get('q') ?? '';
const rawPage = Number(params.get('page') ?? '1');
const page = Number.isSafeInteger(rawPage) && rawPage > 0 ? rawPage : 1;
const update = (next: Record<string, string>, replace = true) => {
setParams(prev => {
const merged = new URLSearchParams(prev);
for (const [k, v] of Object.entries(next)) {
if (v) merged.set(k, v); else merged.delete(k);
}
return merged;
}, { replace });
};
return (
<>
<input aria-label="Search users" value={q} onChange={(e) => update({ q: e.target.value, page: '1' })} />
<Pager page={page} onChange={(p) => update({ page: String(p) }, false)} />
</>
);
}title: URL > React state for shareable UI
Non-secret view state that a user needs to share belongs in the URL. Never put tokens, private search terms or sensitive identifiers there merely for convenience: URLs enter history, logs and sometimes referrers. Reach for useSearchParams first; reach for useState only when the value is genuinely ephemeral.
Good enough to ship
• useParams for route params, always validate • useSearchParams for filters/pagination/tabs • useLocation when you need pathname elsewhere
Expert tier
• Build typed wrappers for search params (Zod + custom hook) • Debounce param updates that fire fetches • Use replace history mode for filter changes; push for navigation
State strategies — local, lifted, context, store
For state your UI owns, there are four common places to keep it. Choose according to which components need to read and update the value:
- Local state —
useStatein the component that uses it. Default for everything until proven otherwise. A dropdown open/closed, a form's draft value, a hover state. - Lifted state —
useStatein a common ancestor when two siblings need the same value. Pass down as props, pass a setter back up. Use this while the ownership and prop boundary remain clear. - Context — when a value belongs at the top of the tree and is genuinely tree-wide: theme, current user, i18n locale. Re-renders every consumer on change; not a state-management library.
- External store — Zustand for simple cases, Redux Toolkit for larger apps. When state is global, frequently changing, or needs devtools/time-travel.
The decision: start at #1. Move up the list only when forced. Most state lives at level 1; some at level 2; very little at levels 3 and 4. For an avatar URL, decide whether the value is a remote profile field, an unsaved preview or a deliberately normalized shared entity. RTK Query may correctly own the remote field; a redundant copy maintained by unrelated reducers is the problem, not the Redux name.
The grid expands that list rather than adding more rungs to a single ladder: it shows two external-store choices, then a separate server-state category. A modal's open flag can stay local while the dashboard data inside it lives in TanStack Query. More components needing the same API data does not turn that data into UI-owned state; the next section explains the freshness and refetching responsibilities behind the server-state card.
name: Local; tagline: default; bullets: Cheap — Local ownership; descendants can still render — Per-leaf
name: Lifted; tagline: two siblings need it; bullets: Predictable — May prop-drill — Refactor when drilling deep
name: Context; tagline: tree-wide constant-ish; bullets: Theme/user/locale — Every consumer re-renders — Memoise the value
name: Store (Zustand); tagline: frequent global; bullets: Selector cost depends on its computation — Subscribe to slices — DevTools optional
name: Store (Redux Toolkit); tagline: large app, inspectable state history; bullets: Slices + reducers — Time-travel devtools — More ceremony
name: Server (TanStack Query); tagline: anything from API; bullets: Cache + dedupe — Stale-while-revalidate — Mutation lifecycle
Good enough to ship
• Default to local state • Lift only when two siblings share • Reach for store only for genuinely global mutable state
Expert tier
• Audit store ownership; move only values with a clearer local owner • Argue from re-render fan-out, not aesthetics • Treat URL as a first-class state layer
Server state vs UI state — why TanStack Query exists
Server state is anything that originated on a server and may change without you knowing — users, dashboards, charts data, comments. It needs caching, deduping, refetching when stale, optimistic updates on write, garbage collection of unused entries. UI state is everything else — form drafts, modal open/closed, the currently selected tab when it's not in the URL.
Hand-writing remote caching in arbitrary reducers creates extra responsibilities: request ownership, stale responses, freshness, deduplication and invalidation. TanStack Query, RTK Query, SWR and Relay provide different implementations of those responsibilities. A mutation hook does not infer which reads changed: wire invalidation or cache updates explicitly.
Do not mirror a query into local state without a reason. A form draft is a valid reason: initialize it at a defined edit boundary, keep the server version/ETag and handle conflicts on save. Refetching must not silently overwrite the user's draft.
const { data, isLoading, error } = useQuery({
queryKey: ['users', { search: q, page }],
queryFn: ({ signal }) => fetchUsers({ search: q, page }, { signal }),
staleTime: 30_000, // consider fresh for 30s
gcTime: 5 * 60_000, // keep in cache 5 min after last consumer unmounts
});Good enough to ship
• Server state in TanStack Query • UI state in useState • Only fork server data for an explicitly owned draft/snapshot
Expert tier
• Design query keys as hierarchical arrays for selective invalidation • Use placeholderData / keepPreviousData for paginated UIs • Optimistic updates with onMutate + onError rollback
TanStack Query — query keys, stale/cache time, mutations
The core hooks:
useQuery({ queryKey, queryFn, ...options })— fetch + cache. Returns{ data, error, isLoading, isFetching, refetch, ... }.useMutation({ mutationFn, onSuccess, onError, ... })— write operations. Returns{ mutate, mutateAsync, isPending, ... }.useQueryClient()— access the cache directly to invalidate, prefetch, set data.
Query keys are the cache identifier. Use an array: ['users'] for a list, ['users', userId] for a single user, ['users', { search, page }] for a parameterised list. Hierarchical keys let you invalidate a whole subtree (queryClient.invalidateQueries({ queryKey: ['users'] })) or a specific entry.
staleTime is how long the cache is considered fresh. While fresh, the default stale-based remount/focus triggers do not refetch; explicit invalidation, refetch calls, intervals or an "always" trigger can still do so. Default is 0 (immediately stale and eligible for the configured refetch triggers). Staleness alone is not a polling timer; enabled state, in-flight deduplication and refetch options still matter. Bump it to 30_000 (30 seconds) or higher for slow-changing data. gcTime is how long unused entries stay in cache before garbage collection. The browser default is 5 minutes; SSR defaults differ. It starts for inactive entries, not while an observed query is being used.
Mutations for writes. The lifecycle: onMutate (before — return rollback data), mutationFn (the actual call), onSuccess (update cache via setQueryData or invalidate), onError (rollback), onSettled (always). The pattern for optimistic updates is captured in this lifecycle.
const queryClient = useQueryClient();
const addDashboard = useMutation({
mutationFn: (input: NewDashboard) => api.post('/dashboards', input),
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['dashboards'] }),
});
// Conservative rename: do not stage a whole-object optimistic rollback.
const renameDashboard = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) =>
api.patch(`/dashboards/${encodeURIComponent(id)}`, { name }),
onSuccess: (_data, { id }) => Promise.all([
queryClient.invalidateQueries({ queryKey: ['dashboards', id] }),
queryClient.invalidateQueries({ queryKey: ['dashboards'] }),
]),
});
Good enough to ship
• useQuery for reads, useMutation for writes • Hierarchical array query keys • Invalidate or setQueryData in onSuccess
Expert tier
• Optimistic updates with onMutate rollback • Prefetch on hover/focus to reduce latency; keep loading/error paths • Tune staleTime per query type, not globally
Redux Toolkit, Zustand, Jotai — when each
Redux Toolkit (RTK) is the modern Redux. Slices with createSlice (auto-generates actions + reducers from a single object), configureStore, and createAsyncThunk for async actions. RTK Query bundles a TanStack-Query-like data layer if you don't want a separate library. Use Redux when: large app with many teams, you want time-travel devtools, you want an inspectable development history of state changes (not a durable security audit log), you're onboarding a team that already knows Redux.
Zustand is the small one. Define a store with a hook (const useStore = create<State>((set) => ({ count: 0, inc: () => set(s => ({ count: s.count + 1 })) }))); consume by selecting a slice (const count = useStore(s => s.count)). No providers, no boilerplate, automatic fine-grained subscriptions. Use Zustand for: most apps that need some global state but don't want Redux ceremony, library authors who need a store without forcing one on consumers.
Jotai is atom-based — each piece of state is its own atom, and derived atoms compose. Use when state is fragmented into many independent values and you want subscriptions organized by atom; dependent atoms and ordinary parent/context updates can still render. The mental model is Recoil's — explicit but verbose.
For this capstone choose Zustand + TanStack Query to keep the example small. Redux Toolkit with RTK Query is equally legitimate when normalized state, team conventions or existing integration justify it; no popularity benchmark is implied.
name: Redux Toolkit; tagline: large team, inspectable state history; bullets: Slice-based — Time-travel devtools — More boilerplate
name: Zustand; tagline: small, fast, ergonomic; bullets: Hook-based — Fine-grained selectors — Zero boilerplate
name: Jotai; tagline: atomic state graph; bullets: Per-atom subscriptions — Composable derived state — More wiring
// Zustand
import { create } from 'zustand';
type UIState = { sidebarOpen: boolean; toggleSidebar: () => void };
export const useUI = create<UIState>((set) => ({
sidebarOpen: true,
toggleSidebar: () => set((s) => ({ sidebarOpen: !s.sidebarOpen })),
}));
// usage
const open = useUI((s) => s.sidebarOpen); // store-triggered renders track this value; parents/context can still render
const toggle = useUI((s) => s.toggleSidebar); // stable function referenceGood enough to ship
• Default to Zustand for small global UI state • Use Redux Toolkit if you already have Redux • Pair any store with TanStack Query for server state
Expert tier
• In Zustand v5 use useShallow for shallow-stable object selections, or the separate createWithEqualityFn API; create no longer takes a custom equality function • RTK Query for backend-as-types codegen workflows • Jotai for highly granular forms or visual editors
Lazy loading routes and code-splitting boundaries
The cheapest win in any growing React app: lazy-load routes. Dynamic imports establish potential split points; the bundler may share chunks, and preloading may fetch them earlier. Inspect the output rather than assuming one file per route. Pair with <Suspense> for a loading fallback.
import { lazy, Suspense } from 'react';
const Dashboards = lazy(() => import('./routes/Dashboards'));
const Reports = lazy(() => import('./routes/Reports'));
<Routes>
<Route path="/" element={<AppShell />}>
<Route path="dashboards" element={<Suspense fallback={<Spinner />}><Dashboards /></Suspense>} />
<Route path="reports" element={<Suspense fallback={<Spinner />}><Reports /></Suspense>} />
</Route>
</Routes>For lower transition latency, explicitly preload a dynamic import on hover and keyboard focus, with a rejection handler. React Router 6.30.1 does not automatically hover-prefetch every data-router Link. Prefetch is a tradeoff in bytes and may not finish before a click; retain the loading fallback and an error boundary for failed chunk loads.
Good enough to ship
• Lazy-load substantial, infrequently used routes; avoid needless fragmentation • Wrap in Suspense with a skeleton/spinner • Confirm chunks appear separately in the build
Expert tier
• Prefetch on hover/focus to reduce latency; keep loading/error paths • Use route-level data loaders to fetch in parallel with chunk load • Split sub-routes (charts/settings) for finer chunks
Worked examples
Example 1 — full app routing scaffold
// src/routes/index.tsx
import { lazy, Suspense } from 'react';
import { BrowserRouter, Routes, Route, Outlet, NavLink } from 'react-router-dom';
const Dashboards = lazy(() => import('./Dashboards'));
const DashboardDetail = lazy(() => import('./DashboardDetail'));
const Reports = lazy(() => import('./Reports'));
const Settings = lazy(() => import('./Settings'));
function AppShell() {
return (
<div className="grid h-screen grid-cols-[240px_1fr]">
<nav className="border-r p-4">
<NavLink to="/dashboards" className={({isActive}) => isActive ? 'font-semibold' : ''}>Dashboards</NavLink>
<NavLink to="/reports" className={({isActive}) => isActive ? 'font-semibold' : ''}>Reports</NavLink>
<NavLink to="/settings" className={({isActive}) => isActive ? 'font-semibold' : ''}>Settings</NavLink>
</nav>
<main className="p-6 overflow-auto">
<Suspense fallback={<div>Loading…</div>}>
<Outlet />
</Suspense>
</main>
</div>
);
}
export function AppRoutes() {
return (
<BrowserRouter>
<Routes>
<Route path="/" element={<AppShell />}>
<Route index element={<Dashboards />} />
<Route path="dashboards" element={<Dashboards />} />
<Route path="dashboards/:id" element={<DashboardDetail />} />
<Route path="reports" element={<Reports />} />
<Route path="settings" element={<Settings />} />
<Route path="*" element={<div>Not found</div>} />
</Route>
</Routes>
</BrowserRouter>
);
}- Single
<Suspense>inside the layout — fallback shows during any child chunk load. - Lazy imports create split points; inspect actual shared and route chunks.
- NavLink wires active styling for the sidebar.
- A wildcard route inside the shell catches unknown paths.
Example 2 — typed search params via Zod
import { z } from 'zod';
import { useSearchParams } from 'react-router-dom';
const FiltersSchema = z.object({
q: z.string().default(''),
status: z.enum(['all', 'active', 'archived']).default('all'),
page: z.coerce.number().int().positive().max(Number.MAX_SAFE_INTEGER).default(1),
});
type Filters = z.infer<typeof FiltersSchema>;
export function useFilters(): [Filters, (next: Partial<Filters>, replace?: boolean) => void] {
const [params, setParams] = useSearchParams();
const parsed = FiltersSchema.safeParse(Object.fromEntries(params));
// This fallback deliberately resets all filters on an invalid link; a UI may instead show an error.
const filters = parsed.success ? parsed.data : FiltersSchema.parse({});
const update = (next: Partial<Filters>, replace = true) => {
setParams(prev => {
const merged = new URLSearchParams(prev);
for (const [k, v] of Object.entries(next)) {
if (v === undefined || v === '') merged.delete(k);
else merged.set(k, String(v));
}
// changing filters resets page
if (next.page === undefined && (next.q !== undefined || next.status !== undefined)) {
merged.set('page', '1');
}
return merged;
}, { replace });
};
return [filters, update];
}
// usage
function Users() {
const [filters, setFilters] = useFilters();
const { data } = useQuery({ queryKey: ['users', filters], queryFn: ({ signal }) => fetchUsers(filters, { signal }) });
return (
<input aria-label="Search users" value={filters.q} onChange={(e) => setFilters({ q: e.target.value })} />
);
}- Single source of truth: URL → typed object via Zod.
setFiltersdoes partial updates and auto-resetspagewhen other filters change. Typing replaces the current entry; callsetFilters({ page: 2 }, false)to push a deliberate page transition.fetchUsers(input, { signal })is the application seam that must pass the signal to its transport; ignoring it does not cancel network work.- Query key includes the full filters object → cache reuse while that query cache survives; reload persistence is a separate feature.
- Refreshing restores validated URL filters, not a historical server snapshot. Invalid links use the documented all-default fallback. If several controls change together, issue one merged setSearchParams call; it is not React state-update queueing.
Example 3 — TanStack Query with optimistic delete
function useDeleteDashboard() {
const qc = useQueryClient();
return useMutation({
mutationKey: ['delete-dashboard'],
mutationFn: (id: string) => api.delete(`/dashboards/${encodeURIComponent(id)}`),
onSettled: () => qc.invalidateQueries({ queryKey: ['dashboards'] }),
});
}
// A list-level owner uses one mutation instance and only stages one delete.
function DashboardRows({ rows }: { rows: Dashboard[] }) {
const del = useDeleteDashboard();
const visible = rows.filter(row => !(del.isPending && del.variables === row.id));
return <>
{del.isError && <p role="alert">Delete failed; refresh state before retrying.</p>}
{visible.map(row => <button key={row.id} disabled={del.isPending}
onClick={() => del.mutate(row.id)}>Delete {row.name}</button>)}
</>;
}This is an optimistic UI projection, not a whole-cache snapshot. While pending, the list hides the target; other buttons are disabled in this owner. On failure, the server-backed row becomes visible again. Returning the invalidation promise keeps the mutation pending until revalidation settles. Other views may remain unchanged until their query refetches. Other tabs or independent mutation owners still require server versioning and idempotent operation contracts; the disabled button is not an authorization or concurrency lock.
With the original snapshot pattern, A's rollback could resurrect a successfully deleted B. Here no callback writes a historical list into the cache. If you need fully concurrent optimistic edits across views, use mutation-keyed overlays with per-entity versions and test both completion orders; do not assume v5 mutation scope serializes every speculative cache callback.
Example 4 — Zustand store with selectors
import { create } from 'zustand';
type Cart = { items: { sku: string; qty: number }[]; };
type CartActions = {
add: (sku: string, qty: number) => void;
remove: (sku: string) => void;
clear: () => void;
};
export const useCart = create<Cart & CartActions>((set) => ({
items: [],
add: (sku, qty) => {
if (!sku || !Number.isSafeInteger(qty) || qty <= 0) throw new Error("Invalid cart quantity");
set(s => {
const existing = s.items.find(i => i.sku === sku);
const total = (existing?.qty ?? 0) + qty;
if (!Number.isSafeInteger(total)) throw new Error("Cart quantity overflow");
return { items: existing
? s.items.map(i => i.sku === sku ? { ...i, qty: total } : i)
: [...s.items, { sku, qty }] };
});
},
remove: (sku) => set(s => ({ items: s.items.filter(i => i.sku !== sku) })),
clear: () => set({ items: [] }),
}));
// usage with fine-grained selectors
const itemCount = useCart(s => s.items.length); // distinct SKU count, not summed quantity; other render causes remain
const add = useCart(s => s.add); // stable selector result; other causes may still render this component- Selectors avoid store-triggered renders when the selected result remains equal. Props, local state and context can still render the component.
- Action references stay stable while this store definition is retained. Supply domain arguments explicitly:
onClick={() => add('book', 1)}, notonClick={add}(which would receive an event). Validate both the incoming quantity and the accumulated safe-integer total before committing state. - Composable: add a
useShallowselector or a Zustand middleware for persist/devtools. - This small UI-owned cart demonstrates immutable updates and selectors, not inventory authority: the server must validate price, stock and quantity at checkout.
Hands-on exercises
-
Build a routed app from scratch. Goal: end-to-end routing in 30 minutes.
- Scaffold a Vite + React + TS app.
- Add
react-router-dom. Create 4 routes with a shared layout. - Make at least one route dynamic (
/users/:id). - Wire NavLinks with active styling.
- You're done when: every route deep-links correctly and the browser back button works.
-
URL-as-state filter UI. Goal: replace local-state filters with URL search params.
- Take an existing filter UI (e.g., a search input + status dropdown + pagination).
- Move every filter to
useSearchParams. - Implement the typed-filters hook from Worked Example 2.
- Verify: refresh the page → filters restored; share URL with a colleague → same view.
- You're done when: there is no React state storing values that are also in the URL.
-
TanStack Query integration. Goal: wire up a typed query + mutation layer.
- Install
@tanstack/react-queryand wrap your app in<QueryClientProvider>. - Convert one
useEffectfetch touseQuery. - Convert one
fetch + setStatewrite touseMutationwithinvalidateQueries. - Add
staleTime: 30_000and observe fewer requests during navigation. - You're done when: the network panel shows deduped/cached requests and components no longer track
isLoadingmanually.
- Install
-
Optimistic update. Goal: implement a delete that feels instant.
- Pick a destructive mutation (delete, toggle archive).
- Stage the row as hidden while its delete mutation is pending; keep the query cache authoritative.
- On failure, remove the pending overlay and show the error; do not restore a stale whole-list snapshot.
- Add
onSettledinvalidate. - You're done when: clicking delete updates the UI immediately; killing the network causes the row to come back.
-
Lazy-load routes. Goal: ship a smaller initial bundle.
- Replace eager
importof each route withlazy(() => import(...)). - Wrap the layout in
<Suspense>. - Build and compare bundle sizes.
- Bonus: prefetch on
<Link>hover and keyboard focus for one route, handling rejection. - You're done when: you can report measured initial bytes, route-load latency and error/fallback behavior; no fixed percentage reduction is promised.
- Replace eager
-
Audit Redux for misplaced state. Goal: identify state that doesn't belong in the global store.
- List every slice in your Redux store.
- For each, classify: remote cache (TanStack Query or RTK Query), local draft/UI, shareable URL state, or genuine shared app state. Keep an existing correct cache rather than migrating for fashion.
- Plan a migration order (start with a small, low-risk slice whose ownership is actually wrong).
- Migrate one slice as proof of concept.
- You're done when: an ownership-based keep/move decision is documented and regressions are tested; keeping a correct existing slice is a valid result.
Self-check questions
- Why use
<Link>instead of<a>for in-app navigation? - What is
<Outlet>and when do you need it? - When is it correct to model state as URL search params vs React state?
- Difference between server state and UI state. Give an example of each.
- Which remote-cache responsibilities does TanStack Query or RTK Query supply compared with handwritten reducers?
- What is a query key, and why is hierarchy useful?
- Explain
staleTimevsgcTime. What does each control? - Walk through an optimistic-update mutation lifecycle. What does each callback do?
- When would you reach for Zustand over Redux Toolkit?
- Why does a Context provider value re-render every consumer on change?
- What does
lazy + Suspensegive you that an eager import doesn't? - How do you keep
useSearchParamsURL changes from polluting browser history? - When is a userAvatar copy redundant, and when is it a legitimate query field or editing draft?
- Difference between
navigate('/x')andnavigate('/x', { replace: true })after calling useNavigate().
Worked assessment answers
- A Link preserves the router-managed document on normal in-app navigation. An ordinary cross-document anchor resets in-memory React state; external URLs, downloads and deliberate document navigation still use anchors. Neither enforces backend authorization.
- Outlet renders the matched child branch inside a parent:
/dashboards/42/chartsneeds an Outlet in both AppShell and DashboardLayout. The index child renders at the parent path itself. - Put non-sensitive filters/page/tab in the URL; keep private drafts, focus and menu state locally. A bookmark restores a view specification, not an immutable result snapshot.
- A fetched API profile is server-owned and can change elsewhere. An unsaved avatar preview is UI-owned until the save operation commits it.
- TanStack Query and RTK Query supply cache identity, request deduplication, freshness, retries and explicit invalidation APIs. Handwritten reducers must implement those policies themselves; Redux is not forbidden for remote data.
- A query key identifies a cached result and includes every response-changing dependency, including tenant/account scope where applicable. Prefixes such as
['dashboards']allow related detail/list queries to be invalidated together; no key grants authorization. - With
staleTime: 30000, data is fresh at 20 seconds and stale at 40 seconds. Staleness makes default triggers eligible; it is not a polling timer.gcTimeremoves inactive entries after their retention interval, not live observed queries. Explicit refetch/invalidation policies can bypass ordinary freshness behavior. - For cache optimism, onMutate cancels relevant reads and records an operation-specific patch; mutationFn writes; onSuccess reconciles; onError rolls back only that operation; onSettled revalidates. Return the invalidation promise when pending should include refresh. Example 3 instead projects mutation variables into the UI and never restores a whole historical list.
- Choose Zustand for a small shared UI store with selectors. Choose Redux Toolkit when its conventions, normalized state or tooling fit the team. Store selection is an ownership/tooling decision, not a performance guarantee.
- Context compares provider values with Object.is and propagates changed values to consumers. Memoizing an unchanged object avoids identity-only changes; it cannot suppress genuine changes or other render causes.
- lazy defers module loading; Suspense provides a loading fallback. An eager import joins the initial dependency graph. A failed import needs an error boundary; an ordinary effect fetch does not automatically activate Suspense.
- Use
setParams(merged, { replace: true })for continuous typing and push for intentional navigation such as pagination. One combined call changes several filters; do not rely on React-style queued functional updates. - A separately synchronized avatar copy creates two authorities. A cached profile field or an explicitly owned edit draft is legitimate; define conflict behavior when remote data changes during editing.
navigate('/x')pushes a history entry;{ replace: true }changes the current entry.navigate(-1)can leave the app if the prior entry is external, so do not treat it as a guaranteed in-app Back destination.
Exercise 1 solution: Use the four-route scaffold, an Outlet in every nesting layout and a /users/:id detail route that validates id. Configure the host to serve the SPA shell for document routes only, leaving API/static paths alone. Test direct entry at /users/42, refresh, unknown paths and Back/Forward; a working client click alone does not prove deep-link deployment.
Exercise 2 solution: Use the typed hook, one merged update and replace for typing; push pagination deliberately. Test absent values, page=-1, page=NaN, an unsafe integer and invalid status: malformed input selects all defaults without crashing. Object.fromEntries keeps the last duplicate key here; document that policy. Compare parsed filters after reload/share, not changing server results.
Exercise 3 solution: Create a stable QueryClient under QueryClientProvider and use identical scoped keys for two readers of the same resource. Pass the supplied AbortSignal through fetchUsers. Invalidate the relevant list/detail prefix after a successful write. With default triggers, remount at 20 seconds should reuse 30-second-fresh data; after 40 seconds it may refetch. Inspect actual requests, including retries and explicit refetches, rather than assuming every render causes one.
Exercise 4 solution: Keep one mutation owner as in Example 3; hide only the pending id, disable this owner's other buttons, and return invalidateQueries from onSettled. On failure remove the overlay and show the error, preserving newer server-backed rows. Test A failing while another client deletes B, plus reversed completion order. If the server applied a write but the response was lost, revalidation/idempotency—not blind retry—resolves uncertainty.
Exercise 5 solution: Keep the shell eager and lazily import a substantial route with Suspense and an error boundary. Preload on hover and focus with a catch path. Compare measured initial compressed bytes and cold/warm route latency under the same throttling; test failed chunk loads. Shared chunks and early preloads may reduce or erase the expected saving.
Exercise 6 solution: Build an ownership table for each slice: remote cache, private draft, shareable view state or genuinely shared UI. Keep correctly owned RTK Query data. Move one low-risk misplaced filter to the URL or a local draft to its form, testing refresh/history/draft conflict behavior. No migration is needed when the audit finds no ownership defect.
High-signal resources
Official docs
- React Router docs — covers both classic and data-router modes.
- TanStack Query docs — read the "Guides" section in order.
- Redux Toolkit docs.
- Zustand README — short, complete.
Books or courses
- TkDodo's Practical React Query series — the canonical reference, free.
- Epic React — Advanced State Management.
Practitioner posts
- Kent C. Dodds — Application State Management.
- Mark Erikson — Why Redux Toolkit Is How To Use Redux Today.
- Daishi Kato — Zustand vs Jotai vs Recoil (various posts).
- Dominik Dorfmeister (TkDodo) — Don't Over useState.
Weekly milestones
- Day 1 — Read React Router Tutorial. Do Exercise 1.
- Day 2 — Read "URL is state" patterns. Do Exercise 2.
- Day 3 — Read TanStack Query "Important Defaults" and "Query Keys". Do Exercise 3.
- Day 4–5 — Read TkDodo's mutation series. Do Exercise 4. Answer self-check 1-7.
- Day 6–7 — Read Zustand or Redux Toolkit docs depending on your project. Do Exercises 5 and 6. Answer self-check 8-14.
How it shows up in the capstone
The capstone uses React Router with a layout shell (AppShell → sidebar + outlet), nested routes for each dashboard (/dashboards/:id/{overview,charts,settings}), and lazy-loaded route chunks for every top-level section. Filters across the app live in useSearchParams via a typed useFilters hook backed by Zod, so deep links share exact dashboard configurations.
Data lives in TanStack Query: every backend call is a useQuery or useMutation with a hierarchical key, conservative staleTime, and an optimistic-update pattern for destructive ops. The only global UI state is the sidebar collapsed/expanded boolean and the theme — both in a single Zustand store for this chosen design; an existing correct Redux implementation need not be replaced. Server state never enters that store.
You will feel this chapter most the first time a user shares a URL of a filtered dashboard with a colleague and the colleague's page opens to the exact same view — provided the URL parser, authorization checks and backend query agree. After three months of disciplined state placement, the categories (server/URL/UI/global) become instinctive and the "where does this belong?" question becomes a five-second answer.
Next chapter → Ch 11 — Data visualisation with Highcharts Previous chapter → Ch 9 — React
Corrected contracts and failure analysis
URL parameters are untrusted strings. safeParse lets this example reset malformed links to defaults instead of crashing rendering; default values apply to missing input, not arbitrary invalid input. Include tenant, filters and pagination in query keys, but do not put credentials there. A full reload restores the URL state, not an in-memory query cache unless persistence is deliberately configured.
Optimistic rollback from a whole-list snapshot assumes no concurrent mutation that must survive it. If delete A starts, delete B succeeds, and A fails, restoring A's old list can resurrect B. Serialize conflicting mutations, roll back only the affected entity with a version check, or refetch authoritative state. Await cancellation before snapshotting and return the invalidation promise so completion represents the intended refresh. Router guards improve navigation; the API must enforce authorization independently.
Boundary exercise with solution
Two optimistic deletes overlap and the first fails. Why is setQueryData(previousList) unsafe?
Solution and reasoning
The snapshot predates the second deletion. A rollback can undo a successful independent operation. Use per-entity patches/version checks or serialize mutations and revalidate; test the two completion orders rather than just the single-request happy path.
Source-backed review notes
- Optimistic Updates | TanStack Query React Docs — accessed 2026-10-07. Bounded supporting passage: “useMutation's onMutate handler option allows you to return a value”. Callback argument positions vary across v5 revisions; the worked delete uses mutation variables as a UI overlay rather than relying on an older callback signature.
- TypeScript: Documentation - Everyday Types — accessed 2026-10-07. Exact supporting passage: “Like a type annotation, type assertions are removed by the compiler and won’t affect the runtime behavior of your code.”
Versioned source contracts
Official passages were checked on 7 October 2026; immutable tags are used where available, while service docs remain dated snapshots. The saved evidence records include exact passages, URL, source date/revision and snapshot hash. These references support the stated contracts, not an unperformed service deployment.
- router — Nested URL segments render matching layout/child branches, not just one element.
- query-defaults — gcTime governs inactive-cache removal; staleTime is separate.
- query-ui — Optimistic UI can use mutation variables without overwriting the query cache.
- zustand5 — Zustand v5 create has a different equality-function contract.
- zod4 — URL values need runtime parsing; missing defaults differ from invalid input.
Fresh checks on 9 October 2026 also used the 6.30.1 search-parameter contract, query cancellation guide and important defaults. The local tests exercise the exact filter hook with real installed Zod and mocked router/query/store boundaries; they do not prove React Router or TanStack integration in a browser.
Pause / Recall / Apply
Can you explain it without the page?
Close the example. Reconstruct the core idea, then change one assumption. Mark complete when you’re ready; you can always undo it.
Stored in this browser only. No account, no sync. Clearing browser data removes your record.