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 withuseSearchParams. - 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.
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.
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.
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.
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.
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.
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
Share a link
In milestone 4, change
initialEntriesto["/?q=build"]. Predict which cards show after loading. That URL is what you'd paste to a teammate. - 2
Fail the first load
Change
down: falsetodown: truein the fake API. Predict what you see after 300 ms. Untick Server is down and press Retry. - 3
Taller rows
In the archive, set
ROW = 48. Predict the row counts before running: at the top, ceil(320 / 48) + 3 = 10. Then setOVERSCAN = 0and scroll fast: do you see blank gaps?
Code & diagrams
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
Console
Nothing logged yet.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
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-06Move 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
Console
Nothing logged yet.Expected console output
snapshot rollback: a:todo b:todo
undo one move: a:todo b:doingBreak 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.
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.
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
fromundoes 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
startTransitionby default (theuseTransitionsoption), 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: autois a cheaper alternative for moderate lists. - ▸
Moving the server data into TanStack Query (Topic 14.6) would replace
useLoadTasksand the manual rollback withuseQueryanduseMutationwithonMutate/onError(its documented optimistic pattern), plus caching and refetching on focus.
Remember this
- 1
Filters in the URL (Topic 13.3).
useSearchParams()from React Router returns the current query string as aURLSearchParamsobject and a setter. The search box showsparams.get("q") ?? "", and typing calls the setter with a copy of the params withqchanged (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=highcan be shared as a link (a deep link). Board derives the visible tasks from the URL while rendering; nothing is copied into state. - 2
Routers in the preview. A real app uses
BrowserRouter(orcreateBrowserRouter), which reads the address bar. The preview has no real address bar, so the examples useMemoryRouter, which keeps the URL in memory;initialEntries={["/?priority=high"]}simulates opening a deep link. The app shows the current URL fromuseLocation()so you can watch it change. React Router 8 is one package,react-router(there is noreact-router-dom). - 3
A fake API with latency.
fakeApi.jsstands in for a server:listTasks()andmoveTask(id, status)wait 300 ms withsetTimeoutand throw503 Service Unavailablewhile the Server is down box is ticked. Because the UI only talks to these two functions, swapping them for realfetchcalls later changes one file. (For HTTP, status codes and retries, see /system-design.) - 4
Loading, error and retry (Topic 14.2).
useLoadTasks()keeps onestatusvalue:"loading","error"or"ready"(one variable instead of several booleans means impossible combinations like loading-and-error can't happen). The effect callslistTasks()and ignores late answers with anignoreflag set in its cleanup (Topic 14.7). Retry sets the status back to loading and bumps anattemptcounter in the effect's dependencies, which runs the effect again. Loading is shown withrole="status"and errors withrole="alert", so screen readers announce them. - 5
Optimistic updates with rollback. Waiting 300 ms after every click feels broken. So
moveTaskfirst dispatches the move (the card jumps at once), then calls the server. If the server fails, it dispatches the inverse action (move back tofrom) 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'suseOptimistic(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
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 keepsaria-setsizeandaria-posinset, so a screen reader still says "item 2,498 of 5,000".
Explain it without notes
Why put filters in the URL, and how do you update a search param without breaking the Back button?
Walk through an optimistic update with rollback. What can go wrong?
How do you model loading, error and retry for a request in React?
Explain how a fixed-height virtualised list works.
Practice
Add a sort search param (?sort=title) that sorts the visible tasks by title when set.
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
MemoryRouterand a real app usesBrowserRouter.