Search Tech Journey

Find topics, journeys and posts

back to blog
system designintermediate 30m2026-06-10

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>
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>
  );
}
  • index is 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 — DashboardLayout wraps a sub-router for overview/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

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>
  );
}
Good enough to ship
  • • Link for navigation, NavLink for active styling
  • • useNavigate for programmatic transitions
  • • Never use a raw <a> for in-app links
Expert tier
  • • Use replace: true for 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) })} />
    </>
  );
}
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

State is not one thing. It's at least four:

  1. Local stateuseState in the component that uses it. Default for everything until proven otherwise. A dropdown open/closed, a form's draft value, a hover state.
  2. Lifted stateuseState in 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.
  3. 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.
  4. 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).

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 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
});
Good enough to ship
  • • Server state in TanStack Query
  • • UI state in useState
  • • Never copy server data into local state
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, 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] }),
});
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 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
Good 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
  • • 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.

Good enough to ship
  • • Lazy-load every top-level route
  • • Wrap in Suspense with a skeleton/spinner
  • • Confirm chunks appear separately in the build
Expert tier
  • • 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.
  • setFilters does partial updates and auto-resets page when 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 onError using the stashed prev.
  • onSettled triggers 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 useShallow selector or a Zustand middleware for persist/devtools.
  • This entire pattern is ~30 lines and replaces a whole Redux setup.

Hands-on exercises

  1. Build a routed app from scratch. Goal: end-to-end routing in 30 minutes.

    1. Scaffold a Vite + React + TS app.
    2. Add react-router-dom. Create 4 routes with a shared layout.
    3. Make at least one route dynamic (/users/:id).
    4. Wire NavLinks with active styling.
    5. You're done when: every route deep-links correctly and the browser back button works.
  2. URL-as-state filter UI. Goal: replace local-state filters with URL search params.

    1. Take an existing filter UI (e.g., a search input + status dropdown + pagination).
    2. Move every filter to useSearchParams.
    3. Implement the typed-filters hook from Worked Example 2.
    4. Verify: refresh the page → filters restored; share URL with a colleague → same view.
    5. You're done when: there is no React state storing values that are also in the URL.
  3. TanStack Query integration. Goal: wire up a typed query + mutation layer.

    1. Install @tanstack/react-query and wrap your app in <QueryClientProvider>.
    2. Convert one useEffect fetch to useQuery.
    3. Convert one fetch + setState write to useMutation with invalidateQueries.
    4. Add staleTime: 30_000 and observe fewer requests during navigation.
    5. You're done when: the network panel shows deduped/cached requests and components no longer track isLoading manually.
  4. Optimistic update. Goal: implement a delete that feels instant.

    1. Pick a destructive mutation (delete, toggle archive).
    2. Add onMutate that snapshots and applies the optimistic change.
    3. Add onError rollback.
    4. Add onSettled invalidate.
    5. You're done when: clicking delete updates the UI immediately; killing the network causes the row to come back.
  5. Lazy-load routes. Goal: ship a smaller initial bundle.

    1. Replace eager import of each route with lazy(() => import(...)).
    2. Wrap the layout in <Suspense>.
    3. Build and compare bundle sizes.
    4. Bonus: prefetch on <Link> hover for one route.
    5. You're done when: the initial bundle is ≥30% smaller and route transitions still feel instant.
  6. Audit Redux for misplaced state. Goal: identify state that doesn't belong in the global store.

    1. List every slice in your Redux store.
    2. For each, classify: server state (move to TanStack Query), UI state (move to useState), URL state (move to useSearchParams), genuine global (keep).
    3. Plan a migration order (start with smallest, riskiest first).
    4. Migrate one slice as proof of concept.
    5. 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

  1. Why use <Link> instead of <a> for in-app navigation?
  2. What is <Outlet> and when do you need it?
  3. When is it correct to model state as URL search params vs React state?
  4. Difference between server state and UI state. Give an example of each.
  5. Why is TanStack Query a better fit for server state than Redux?
  6. What is a query key, and why is hierarchy useful?
  7. Explain staleTime vs gcTime. What does each control?
  8. Walk through an optimistic-update mutation lifecycle. What does each callback do?
  9. When would you reach for Zustand over Redux Toolkit?
  10. Why does a Context provider value re-render every consumer on change?
  11. What does lazy + Suspense give you that an eager import doesn't?
  12. How do you keep useSearchParams URL changes from polluting browser history?
  13. Why does putting userAvatar in Redux suggest a design problem?
  14. Difference between useNavigate('/x') and useNavigate('/x', { replace: true }).

High-signal resources

Official docs

Books or courses

Practitioner posts

Weekly milestones

  1. Day 1 — Read React Router Tutorial. Do Exercise 1.
  2. Day 2 — Read "URL is state" patterns. Do Exercise 2.
  3. Day 3 — Read TanStack Query "Important Defaults" and "Query Keys". Do Exercise 3.
  4. Day 4–5 — Read TkDodo's mutation series. Do Exercise 4. Answer self-check 1-7.
  5. 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