Command Palette

Search for a command to run...

Hectal
PHASE 27Advanced ~36 min· topic 4 of 6

Topic 27.4

Filters, Routing and Async

In one line

Milestone 4: search and priority filters live in the URL, tasks load from a slow fake API with loading, error and retry states, moves are optimistic and roll back when the server fails, and a 5,000-task archive renders only the rows you can see.

Think of it like this

Ordering at a busy café. The waiter writes your order and says "coming up" straight away (optimistic): you don't stare at an empty table while the kitchen works. If the kitchen has run out, the waiter comes back, takes the item off your bill and tells you (rollback with a message). The order slip on the table (the URL) says exactly what you asked for, so anyone can read it or repeat it.

Words you'll meet

New words in this topic, in plain English. Come back here whenever one feels fuzzy.

Search params
The ?key=value&... part of a URL. React Router reads and writes it with useSearchParams.
Deep link
A URL that opens the app in a particular state, such as /?priority=high.
MemoryRouter
A React Router router that keeps the URL in memory instead of the address bar. Used in tests and previews.
Latency
The delay between asking a server for something and getting the answer.
Optimistic update
Showing a change on screen before the server confirms it, assuming it will succeed.
Rollback
Undoing an optimistic change when the server says it failed.
Retry
Trying a failed request again, here by re-running the loading effect.
Virtualised list
A list that renders only the rows you can see, however long the data is.
Overscan
A few extra rows rendered above and below the visible ones, so fast scrolling doesn't show gaps.

Step by step

01Put the router at the top

Wrap the app in a router in main.jsx. Every component below can now read and change the URL. In a real project: BrowserRouter and an SPA fallback on the host (Topic 23.3), so a reload of /?q=bug still serves index.html.

src/main.jsxwhole filejsx
import { BrowserRouter } from "react-router";

createRoot(document.getElementById("root")).render(
  <StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </StrictMode>,
);

02Filters write the URL, Board reads it

Filters is a controlled form whose value is the URL. Board reads the same params and filters while rendering. They never talk to each other directly: the URL is the shared state.

Filters write the URL, Board reads itdiagram
Rendering diagram…

03Load the tasks: loading, error, retry

One status value drives what you see. The effect's cleanup sets ignore, so an answer that arrives after unmount or after a newer attempt is dropped.

Load the tasks: loading, error, retrydiagram
Rendering diagram…

04Move optimistically, roll back on failure

Remember where the card came from, move it now, then ask the server. On failure, move only that card back and say why. The user sees the card jump instantly and, rarely, jump back with an explanation.

Move optimistically, roll back on failurediagram
Rendering diagram…

05Virtualise the archive

The archive is a separate route (/archive) with a VirtualList. It keeps scrollTop in state, works out the first and last visible rows, and renders only those inside a spacer as tall as all rows together.

Virtualise the archivediagram
Rendering diagram…

06Swap the fake API for a real one later

Keep the function names and replace the bodies. The components and the store don't change. A data library such as TanStack Query (Topic 14.6) would add caching, retries and deduplication on top.

src/api/api.jswhole filejavascript
export async function listTasks() {
  const res = await fetch("/api/tasks");
  if (!res.ok) throw new Error(res.status + " " + res.statusText);
  return res.json();
}

export async function moveTask(id, status) {
  const res = await fetch("/api/tasks/" + id, {
    method: "PATCH",
    headers: { "Content-Type": "application/json" },
    body: JSON.stringify({ status }),
  });
  if (!res.ok) throw new Error(res.status + " " + res.statusText);
}

Try it yourself

  1. 1

    Share a link

    In milestone 4, change initialEntries to ["/?q=build"]. Predict which cards show after loading. That URL is what you'd paste to a teammate.

  2. 2

    Fail the first load

    Change down: false to down: true in the fake API. Predict what you see after 300 ms. Untick Server is down and press Retry.

  3. 3

    Taller rows

    In the archive, set ROW = 48. Predict the row counts before running: at the top, ceil(320 / 48) + 3 = 10. Then set OVERSCAN = 0 and scroll fast: do you see blank gaps?

Code & diagrams

Milestone 4: URL filters, a slow API and optimistic moves New tab

It opens on the deep link `/?priority=high` after a 300 ms load. Type in Search and pick a priority: watch the URL. Move a card: it jumps at once. Tick **Server is down** and move again: the card jumps, then returns with a message. Add/edit/delete and saving from 27.3 are left out to keep the file readable; they plug in unchanged.

Preview

Press Run to see the page.

Console

Nothing logged yet.
The archive: 5,000 tasks, about 15 rows in the DOM New tab

At the top, 10 visible rows + 3 overscan below = 13. In the middle, 3 above + about 10 visible + 3 below = 16. Scroll it yourself and inspect the list: rows are reused, never 5,000 of them.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

rows in the DOM: 13
after scrolling, rows in the DOM: 16
first row shown: Archived task #2498 done 2024-01-06
Rollback done right: undo one move, not the whole list New tab

Move a was started first and failed last. Restoring the list saved before it also wiped out b's move, which the server had accepted: now the screen and the server disagree.

Preview

Press Run to see the page.

Console

Nothing logged yet.

Expected console output

snapshot rollback: a:todo b:todo
undo one move:     a:todo b:doing

Break it on purpose

Errors are the best teachers. Make each change, read the error, guess what went wrong, then reveal the answer.

Break #1

Roll back by restoring a snapshot of the whole list

In moveTask, save const before = tasksStore.getSnapshot() and on failure dispatch { type: "loaded", tasks: before }. Then tick Server is down, move one card, untick it and quickly move another.

terminal
$ Console of the third example
── what you'll see ──
snapshot rollback: a:todo b:todo
undo one move: a:todo b:doing

Break #2

Copy the URL into state

In Filters, write const [q, setQ] = useState(params.get("q") ?? "") and use q for the input, while Board keeps reading the URL.

terminal
$ Preview
── what you'll see ──
Typing in Search changes the box but the cards and the URL bar never change.

Myth vs fact

Myth

Optimistic updates are risky because the UI lies.

Fact

The UI shows the most likely outcome and corrects itself, with a message, in the rare failure. For actions that usually succeed and are easy to undo (moving a card, liking a post) it is the better experience. For payments, wait for the server.

Myth

Search params are only for page URLs, not app state.

Fact

Any state a user might want to bookmark, share or keep after reload (filters, sort order, the open tab, the page number) belongs in the URL.

Myth

A virtualised list is only needed for 100,000 rows.

Fact

A few thousand rows with real content already make the first render and scrolling noticeably slow on a mid-range phone. Measure (Topic 16.6), then virtualise.

Pro corner

Extra depth for experienced readers. New to this? Skip it for now and come back later.

  • ▸

    Two quick moves of the same card can still go wrong: if the first fails after the second succeeded, rolling back to its from undoes the second. Production code tags each request (a version or counter per task) and ignores rollbacks for stale requests, or refetches the task.

  • ▸

    React Router wraps navigations and URL changes in startTransition by default (the useTransitions option), so a URL change that suspends keeps the old screen visible instead of flashing a fallback (Topic 14.3).

  • ▸

    Virtual lists need care beyond the maths: keep keys stable (the item id, not the visible index), restore scroll position when returning to the route, keep the focused row rendered, and use a library such as TanStack Virtual for variable heights (Topic 24.1). CSS content-visibility: auto is a cheaper alternative for moderate lists.

  • ▸

    Moving the server data into TanStack Query (Topic 14.6) would replace useLoadTasks and the manual rollback with useQuery and useMutation with onMutate/onError (its documented optimistic pattern), plus caching and refetching on focus.

Remember this

  1. 1

    Filters in the URL (Topic 13.3). useSearchParams() from React Router returns the current query string as a URLSearchParams object and a setter. The search box shows params.get("q") ?? "", and typing calls the setter with a copy of the params with q changed (or deleted when empty). { replace: true } replaces the current history entry instead of adding one per letter, so Back leaves the page rather than undoing one keystroke. Because the filter is in the URL, a reload keeps it and ?priority=high can be shared as a link (a deep link). Board derives the visible tasks from the URL while rendering; nothing is copied into state.

  2. 2

    Routers in the preview. A real app uses BrowserRouter (or createBrowserRouter), which reads the address bar. The preview has no real address bar, so the examples use MemoryRouter, which keeps the URL in memory; initialEntries={["/?priority=high"]} simulates opening a deep link. The app shows the current URL from useLocation() so you can watch it change. React Router 8 is one package, react-router (there is no react-router-dom).

  3. 3

    A fake API with latency. fakeApi.js stands in for a server: listTasks() and moveTask(id, status) wait 300 ms with setTimeout and throw 503 Service Unavailable while the Server is down box is ticked. Because the UI only talks to these two functions, swapping them for real fetch calls later changes one file. (For HTTP, status codes and retries, see /system-design.)

  4. 4

    Loading, error and retry (Topic 14.2). useLoadTasks() keeps one status value: "loading", "error" or "ready" (one variable instead of several booleans means impossible combinations like loading-and-error can't happen). The effect calls listTasks() and ignores late answers with an ignore flag set in its cleanup (Topic 14.7). Retry sets the status back to loading and bumps an attempt counter in the effect's dependencies, which runs the effect again. Loading is shown with role="status" and errors with role="alert", so screen readers announce them.

  5. 5

    Optimistic updates with rollback. Waiting 300 ms after every click feels broken. So moveTask first dispatches the move (the card jumps at once), then calls the server. If the server fails, it dispatches the inverse action (move back to from) and shows a message. Undo only the failed change: restoring a snapshot of the whole list would also undo other moves that succeeded meanwhile (the third example proves it). React 19's useOptimistic (Topic 19.x) does the same for state that lives in a component and an Action; here the tasks live in a store, so the store does it.

  6. 6

    The archive: a virtualised list (Topic 24.1 and 16.9). 5,000 rows of DOM are slow to create and to scroll. A virtualised (windowed) list renders only the rows inside the scroll box plus a few extra (overscan), positioned absolutely inside one tall spacer that gives the scrollbar its full height. With fixed 32 px rows the maths is simple: first = floor(scrollTop / 32) - overscan, last = ceil((scrollTop + height) / 32) + overscan. The DOM holds about 15 rows whatever the list length. Each row keeps aria-setsize and aria-posinset, so a screen reader still says "item 2,498 of 5,000".

Explain it without notes

01

Why put filters in the URL, and how do you update a search param without breaking the Back button?

02

Walk through an optimistic update with rollback. What can go wrong?

03

How do you model loading, error and retry for a request in React?

04

Explain how a fixed-height virtualised list works.

Practice

01

Add a sort search param (?sort=title) that sorts the visible tasks by title when set.

02

Change useLoadTasks to also expose the time of the last successful load as a fixed-format string.

Trade-offs

  • ↔

    Optimistic updates feel instant but need rollback code and a clear error message; for rare, high-stakes actions, a pending state and waiting is simpler and more honest.

  • ↔

    Filters in the URL are shareable and survive reloads but every keystroke goes through the router; debounce if filtering is expensive (Topic 16.7).

  • ↔

    A hand-written virtual list is small and fixed-height only; libraries handle variable heights, horizontal lists and grids at the cost of a dependency.

Done when you can

  • I can keep filters and search in the URL with useSearchParams.

  • I can model loading, error and retry with one status value and an ignore flag.

  • I can write an optimistic update that rolls back only the failed change.

  • I can build a fixed-height virtualised list and explain its maths.

  • I know why the preview uses MemoryRouter and a real app uses BrowserRouter.