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.
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.
The shipping-vs-expert gap on routing and state is the most expensive in the entire stack to get wrong, because the choices ramify. The shipping engineer reaches for Redux for everything, dumps every piece of state into the global store, and writes a useEffect to fetch data on mount. The expert maintains four sharply different state kinds — server state (in TanStack Query, never in Redux), URL state (in useSearchParams, never duplicated to React state), UI state (local useState, never lifted unless two siblings need it), and app state (Zustand or Redux Toolkit when truly global) — and knows which one a given piece of state belongs in within seconds.
You finish this chapter when you can scaffold a routed React app with nested layouts and lazy-loaded routes, model filters as URL search params instead of state, wire a typed TanStack Query data layer with proper key design, and explain to a colleague why their Redux slice for the current user's avatar URL is in the wrong place.
Concepts and depth
React Router — BrowserRouter, Routes, Route
react-router-dom v6+ (now v7) is the de facto router for React SPAs. The pieces:
<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 (rare in 2026).<Routes>is the switch — exactly one matching<Route>renders at any time.<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, but most apps still pair plain routes with TanStack Query for data. Pick one approach and stick with it.
<BrowserRouter>
<Routes>
<Route path="/" element={<Home />} />
<Route path="/dashboards" element={<DashboardList />} />
<Route path="/dashboards/:id" element={<DashboardDetail />} />
<Route path="*" element={<NotFound />} />
</Routes>
</BrowserRouter>
- • Use BrowserRouter for normal URLs
- • Declare all routes in one file
- • Always have a
*not-found route
- • 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.
- • Model layouts as parent routes
- • Use
indexfor the default child - • Reach for nested routes before duplicating layouts
- • 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
Always use React Router's <Link> for in-app navigation — never a raw <a>. A raw <a> triggers a full page reload, losing all React state. <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.
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/${id}`);
navigate('/dashboards', { replace: true });
}}>Delete</button>
);
}
- • Link for navigation, NavLink for active styling
- • useNavigate for programmatic transitions
- • Never use a raw
<a>for in-app links
- • Use
replace: truefor post-action navigation that shouldn't pollute history - • Pass state via
navigate(path, { state })for transient data - • Combine NavLink with aria-current="page" for accessibility
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 the right place to store every piece of state that should survive a refresh, a back button, or a shared link: filters, sorting, pagination, tab selections.
function UsersList() {
const [params, setParams] = useSearchParams();
const q = params.get('q') ?? '';
const page = Number(params.get('page') ?? '1');
const update = (next: Record<string, string>) => {
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;
});
};
return (
<>
<input value={q} onChange={(e) => update({ q: e.target.value, page: '1' })} />
<Pager page={page} onChange={(p) => update({ page: String(p) })} />
</>
);
}
- • useParams for route params, always validate
- • useSearchParams for filters/pagination/tabs
- • useLocation when you need pathname elsewhere
- • 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
State is not one thing. It's at least four:
- 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. The right answer 80% of the time prop drilling is feasible. - 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. The day someone writes a userAvatar slice in Redux for the current user's avatar URL, you have a problem — that value belongs in the auth/user context (or just a query).
- • Default to local state
- • Lift only when two siblings share
- • Reach for store only for genuinely global mutable state
- • Audit existing store usage; demote half to local state
- • 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.
Treating these the same way is the single most common architecture mistake in React apps. Putting server state in Redux means writing thunks/sagas to fetch, manual cache logic, manual loading/error tracking, and no automatic refetch when data goes stale. TanStack Query (and SWR for simpler cases, Relay for GraphQL) handle all of that for free.
The rule: server state in TanStack Query, UI state in useState. Never the twain shall meet. Don't copy server data into local state "in case you need to mutate it" — mutate via the query's useMutation, which invalidates the cache and triggers a refetch.
const { data, isLoading, error } = useQuery({
queryKey: ['users', { search: q, page }],
queryFn: () => fetchUsers({ search: q, page }),
staleTime: 30_000, // consider fresh for 30s
gcTime: 5 * 60_000, // keep in cache 5 min after last consumer unmounts
});
- • Server state in TanStack Query
- • UI state in useState
- • Never copy server data into local state
- • 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, no refetch on remount or focus. Default is 0 (always considered stale, refetches on every consumer mount). 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. Default 5 minutes; usually fine.
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'] }),
});
const renameDashboard = useMutation({
mutationFn: ({ id, name }: { id: string; name: string }) => api.patch(`/dashboards/${id}`, { name }),
onMutate: async ({ id, name }) => {
await queryClient.cancelQueries({ queryKey: ['dashboards', id] });
const prev = queryClient.getQueryData(['dashboards', id]);
queryClient.setQueryData(['dashboards', id], (old: Dashboard) => ({ ...old, name }));
return { prev };
},
onError: (_e, { id }, ctx) => queryClient.setQueryData(['dashboards', id], ctx?.prev),
onSettled: (_d, _e, { id }) => queryClient.invalidateQueries({ queryKey: ['dashboards', id] }),
});
- • useQuery for reads, useMutation for writes
- • Hierarchical array query keys
- • Invalidate or setQueryData in onSuccess
- • Optimistic updates with onMutate rollback
- • Prefetch on hover/focus for instant transitions
- • 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 have a strict audit trail of state changes, 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 zero re-render fan-out. The mental model is Recoil's — explicit but verbose.
The honest take: in 2026, most new apps reach for Zustand + TanStack Query and never need Redux. Reach for Redux only when team size, devtools needs, or existing investment justifies it.
// 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); // re-renders only when this changes
const toggle = useUI((s) => s.toggleSidebar); // stable function reference
- • 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
- • Use equality fn in Zustand selectors to avoid re-renders on object identity changes
- • 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. Each route becomes a separate chunk, downloaded only when the user navigates to it. 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 instant transitions, prefetch chunks on hover/focus. React Router v6.4+ data router does this automatically; with plain <Link>, you can attach onMouseEnter to the link and call the lazy import manually. The first paint of the new route then has the chunk already in cache.
- • Lazy-load every top-level route
- • Wrap in Suspense with a skeleton/spinner
- • Confirm chunks appear separately in the build
- • Prefetch on hover/focus for instant transitions
- • 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 give each route its own chunk.
- 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().default(1),
});
type Filters = z.infer<typeof FiltersSchema>;
export function useFilters(): [Filters, (next: Partial<Filters>) => void] {
const [params, setParams] = useSearchParams();
const filters = FiltersSchema.parse(Object.fromEntries(params));
const update = (next: Partial<Filters>) => {
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;
});
};
return [filters, update];
}
// usage
function Users() {
const [filters, setFilters] = useFilters();
const { data } = useQuery({ queryKey: ['users', filters], queryFn: () => fetchUsers(filters) });
return (
<input 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.- Query key includes the full filters object → cache hits across refresh.
- Refreshing the page restores the exact same state.
Example 3 — TanStack Query with optimistic delete
function useDeleteDashboard() {
const qc = useQueryClient();
return useMutation({
mutationFn: (id: string) => api.delete(`/dashboards/${id}`),
onMutate: async (id) => {
await qc.cancelQueries({ queryKey: ['dashboards'] });
const prev = qc.getQueryData<Dashboard[]>(['dashboards']);
qc.setQueryData<Dashboard[]>(['dashboards'], old => (old ?? []).filter(d => d.id !== id));
return { prev };
},
onError: (_e, _id, ctx) => qc.setQueryData(['dashboards'], ctx?.prev),
onSettled: () => qc.invalidateQueries({ queryKey: ['dashboards'] }),
});
}
// usage
const del = useDeleteDashboard();
<button onClick={() => del.mutate(id)}>Delete</button>
- The UI updates instantly; the network call happens in the background.
- Failure rolls back via
onErrorusing the stashedprev. onSettledtriggers a final invalidate to converge with server truth.- Cancel in-flight queries first so the optimistic write isn't clobbered.
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) => set(s => ({
items: s.items.find(i => i.sku === sku)
? s.items.map(i => i.sku === sku ? { ...i, qty: i.qty + qty } : 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); // re-renders only when count changes
const add = useCart(s => s.add); // stable; never re-renders
- Selector functions ensure components only re-render when their slice changes.
- Action functions are stable references — pass directly to event handlers, no useCallback needed.
- Composable: add a
useShallowselector or a Zustand middleware for persist/devtools. - This entire pattern is ~30 lines and replaces a whole Redux setup.
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).
- Add
onMutatethat snapshots and applies the optimistic change. - Add
onErrorrollback. - 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 for one route. - You're done when: the initial bundle is ≥30% smaller and route transitions still feel instant.
- 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: server state (move to TanStack Query), UI state (move to useState), URL state (move to useSearchParams), genuine global (keep).
- Plan a migration order (start with smallest, riskiest first).
- Migrate one slice as proof of concept.
- You're done when: one slice is gone, the app still works, and the team is convinced the rest is worth doing.
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.
- Why is TanStack Query a better fit for server state than Redux?
- 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? - Why does putting
userAvatarin Redux suggest a design problem? - Difference between
useNavigate('/x')anduseNavigate('/x', { replace: true }).
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, no Redux. 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 — no manual sync, no extra code. 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