Without notes, state yesterday’s main idea and one unresolved question.
Search, filtering, and pagination across boundaries
Connecting the Full Stack
Objective
Decide where filtering belongs and keep URL, API, and database behavior aligned.
This is system integration: individual modules may pass bench tests but the complete signal path must also be verified end to end.
- query parameters
- server-side filtering
- pagination metadata
Why this matters
Your equipment list works because you have eight rows. A real workshop has eight hundred, and the first thing anyone does is search. Today you decide where filtering actually happens — and you move the answer to that question out of React state and into the URL, so that a filtered view can be bookmarked, reloaded, and pasted into a message to a colleague.
That last part is the deliverable, and it is a better test than it looks: state that survives a reload is state you put somewhere real.
Query parameters
A URL has a path and, after a ?, a query string: key–value pairs joined by &.
http://localhost:3000/api/equipment?search=pump&status=active&page=2&limit=20
└──────── origin ────────┘└─ path ─┘└──────────── query string ────────────┘
The path says which resource; the query says which subset of it. GET /api/equipment and
GET /api/equipment?status=active are the same collection, differently narrowed — which is why
filters belong in the query and not in a new path like /api/equipment/active.
Values must be percent-encoded, because a space or an & inside a value would otherwise break
the parsing. Do not build query strings by hand. URLSearchParams does the encoding for you:
const params = new URLSearchParams({ search: 'seal & gasket', status: 'active' });
params.toString();
// 'search=seal+%26+gasket&status=active'
On the server, Express has already parsed it into req.query. Two things about that object catch
people: every value arrives as a string (req.query.page is '2', not 2), and a parameter the
client omitted is undefined. Convert and default explicitly.
The query string is not private
It sits in the address bar, in browser history, in server access logs, and in the Referer header
sent to other sites. Filters and page numbers belong there. Passwords, tokens, and personal data
do not — put those in a request body or a header.
Where filtering belongs
On Day 59 you filtered in React: fetch every row, then .filter() the array as the user types. It
felt instant, and it works fine for eight rows.
It fails for eight hundred, and it fails in a way that is easy to miss. The browser can only filter what it has already downloaded. So either you download everything — a slow first load, growing every week — or you download one page and then "filter" it, in which case you are searching page 1 and confidently telling the user there are no matches that exist on page 4.
The database decides which rows exist; the client only asks. Filtering belongs in the WHERE
clause, where the indexes you built on Day 48 can do their job, and where the answer is computed from
all the data rather than from the slice that happened to arrive.
Filter before the stage that saturates
In a signal chain you put the filter ahead of the amplifier, not after it. Filtering afterwards cannot recover a stage that already clipped — the information was destroyed upstream. Pagination is the same: once the server has sent you 20 of 800 rows, no amount of client-side cleverness recovers the other 780. Narrow at the source.
Asking the librarian
You ask for the books about pumps and the librarian brings a shelf. You do not have the entire library delivered to your desk so you can sort it yourself — and if you did, you would still only be searching the part that fitted in the van.
The failure mode this creates is filtering in both places. The server applies status=active,
the component also applies its own status check, and the two drift apart — the header says "43
results" while eight are visible, and nobody can work out which layer is lying. Pick one. This week
it is the server, every time.
Pagination metadata
Once the server returns a slice, a bare array is no longer enough. The UI cannot render "Page 2 of 7", cannot disable Next on the last page, and cannot say "43 matches" — none of that is derivable from twenty rows. The response needs to describe itself:
{
"data": [ { "id": 12, "name": "Pump 3", "status": "active" } ],
"page": 2,
"limit": 20,
"total": 43
}
This is a breaking change to the contract you wrote on Day 64: code doing data.map(...) on the
response now has to do data.data.map(...). That is normal and fine — but it is exactly the kind of
change that must be made on both sides in one commit, and it is why the API and the frontend having
one agreed shape matters.
In SQL the slice is LIMIT and OFFSET, and the count is a second query over the same WHERE:
SELECT count(*)::int AS total FROM equipment WHERE name ILIKE $1;
SELECT * FROM equipment WHERE name ILIKE $1 ORDER BY name ASC, id ASC LIMIT $2 OFFSET $3;
Two details that are not optional:
ORDER BYmust be deterministic. Without a stable order, PostgreSQL is free to return rows in any order it likes, so a row on page 1 can reappear on page 2 while another is never shown at all. Sorting bynamealone is not enough if two machines share a name — addidas a tiebreaker.- Clamp
limit. A client asking for?limit=1000000should get your maximum, not a query that takes the database down.Math.min(100, ...).
Column names cannot be parameters
$1 placeholders work for values, not identifiers. ORDER BY ${req.query.sort} interpolates
user text straight into SQL — the injection hole from Week 8. If you offer sorting, map the
parameter against a fixed list of allowed columns and reject anything else.
Walkthrough
Build the query on the server from whatever was provided, keeping every user value in a parameter:
export async function listEquipment({ page, limit, search, status }) {
const where = [];
const values = [];
if (search) { values.push(`%${search}%`); where.push(`name ILIKE $${values.length}`); }
if (status) { values.push(status); where.push(`status = $${values.length}`); }
const clause = where.length ? `WHERE ${where.join(' AND ')}` : '';
const counted = await pool.query(`SELECT count(*)::int AS total FROM equipment ${clause}`, values);
values.push(limit, (page - 1) * limit);
const rows = await pool.query(
`SELECT * FROM equipment ${clause}
ORDER BY name ASC, id ASC
LIMIT $${values.length - 1} OFFSET $${values.length}`,
values
);
return { data: rows.rows, page, limit, total: counted.rows[0].total };
}
The route converts and clamps before anything reaches the database:
router.get('/', async (req, res) => {
const page = Math.max(1, Number(req.query.page) || 1);
const limit = Math.min(100, Math.max(1, Number(req.query.limit) || 20));
const search = typeof req.query.search === 'string' ? req.query.search.trim() : '';
const status = typeof req.query.status === 'string' ? req.query.status : '';
res.json(await listEquipment({ page, limit, search, status }));
});
On the frontend, the URL becomes the state. useSearchParams from your Day 62 router reads and
writes the query string, and React re-renders when it changes — so there is no second copy to keep in
sync:
type Paged<T> = { data: T[]; page: number; limit: number; total: number };
const [searchParams, setSearchParams] = useSearchParams();
const search = searchParams.get('search') ?? '';
const page = Number(searchParams.get('page') ?? '1');
function update(key: string, value: string) {
const next = new URLSearchParams(searchParams);
if (value) next.set(key, value);
else next.delete(key);
if (key !== 'page') next.set('page', '1'); // a new filter starts at page 1
setSearchParams(next, { replace: true });
}
useEffect(() => {
const controller = new AbortController();
const timer = setTimeout(() => {
apiFetch<Paged<Equipment>>(`/api/equipment?${searchParams.toString()}`, {
signal: controller.signal
}).then(setResult).catch((e) => { if (e.name !== 'AbortError') setStatus('error'); });
}, 300);
return () => { clearTimeout(timer); controller.abort(); };
}, [searchParams]);
Three deliberate choices in that effect. The 300 ms setTimeout debounces typing, so "pump"
sends one request instead of four. controller.abort() cancels a superseded request, so a slow
answer for "pu" cannot overwrite a fast answer for "pump". And replace: true keeps every keystroke
out of the back-button history.
Prove the URL is the state
Type a search, pick a status, click to page 2. Now copy the address bar, open a new tab, and paste it. The same rows appear, with the controls already set. Reload — same again. That is what "the URL is shareable, reloadable application state" means, and it is today's whole point.
Reviewer mode — once it works
"Review whether filtering is duplicated inconsistently between browser and server." Give it your
list component and your repository function together. Ask for specific findings with evidence: the
exact input where the two disagree, and the line responsible. If it replies that the code looks
good, ask it what ?status=active&page=3 returns when only two active machines exist.
Your turn
- Change
GET /api/equipmentto acceptsearch,status,page, andlimit, and to return the{ data, page, limit, total }envelope. - Build the
WHEREclause from whichever parameters were sent, all values parameterised. Add a deterministicORDER BYwith a tiebreaker, and clamplimitto a maximum. - Test the API alone before touching the UI:
curl 'http://localhost:3000/api/equipment?search=pump&page=2&limit=5'— checktotalis the count of all matches, not the number of rows returned. - Update the frontend type for the new envelope. Fix every compile error it produces.
- Move search, status, and page out of
useStateand intouseSearchParams. Delete the old state; there must be exactly one source of truth. - Remove any client-side
.filter()on the fetched rows. Grep for it. - Add debouncing and request cancellation. Confirm in DevTools → Network that typing "pump" sends one request, not four.
- Reset to page 1 whenever a filter changes, and confirm you cannot get stranded on an empty page 5.
You are done when
Copying the URL into a new tab reproduces the exact list you were looking at, the row count in the header matches the number of matching rows in the database, and no filtering happens in React at all.
Common pitfalls
- Filtering in both places. The count and the rows disagree and nobody can tell which layer is wrong. One filter, on the server.
- Keeping filters in
useStateas well as the URL. Two sources of truth immediately drift; the back button is usually the first thing to expose it. - Changing the filter and staying on page 4. The new result has one page, so the user sees nothing and assumes the search is broken.
ORDER BYwithout a tiebreaker. Rows quietly duplicate or vanish between pages, and it only shows up with real data.
Verify it yourself
Open today's reference, MDN's Overview of HTTP, and find where it describes the parts of a URL and what makes a method safe.
- This lesson put every filter in the query string of a
GET. Find what MDN says aboutGETbeing safe and idempotent, and explain why that makes a filtered URL sharable but a "delete" link a bad idea. - Find where MDN discusses caching. Does adding a query parameter produce a different cache entry? Note what that implies for your paginated responses.
Write both answers into your project notes. Understanding why the platform treats a query string the way it does is what lets you predict behaviour you have not yet tested.
The hour
- 0–5 min Recall
- 5–20 min Learn
Read only the listed concept notes and official reference sections needed today.
- 20–48 min Build
Move search/status/page controls into URL query parameters and API requests.
- 48–55 min Explain and verify
Run the result, inspect evidence, and explain the data/control flow in your own words.
- 55–60 min Quiz and commit
Complete the quiz, record one lesson, and commit the verified change when applicable.
What to hand in
Reloading or sharing the URL preserves the visible list state.
Working with AI today
Provide existing work and ask for concrete defects, risks, missing tests, and unsupported assumptions—not praise.
Review whether filtering is duplicated inconsistently between browser and server.
References
End-of-day quiz
Explain-back gate
Pass the quiz above to unlock completion.
Quiz + explain-back checks required.