0 / 91
Week 10 · Day 68 of 91

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 BY must 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 by name alone is not enough if two machines share a name — add id as a tiebreaker.
  • Clamp limit. A client asking for ?limit=1000000 should 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

  1. Change GET /api/equipment to accept search, status, page, and limit, and to return the { data, page, limit, total } envelope.
  2. Build the WHERE clause from whichever parameters were sent, all values parameterised. Add a deterministic ORDER BY with a tiebreaker, and clamp limit to a maximum.
  3. Test the API alone before touching the UI: curl 'http://localhost:3000/api/equipment?search=pump&page=2&limit=5' — check total is the count of all matches, not the number of rows returned.
  4. Update the frontend type for the new envelope. Fix every compile error it produces.
  5. Move search, status, and page out of useState and into useSearchParams. Delete the old state; there must be exactly one source of truth.
  6. Remove any client-side .filter() on the fetched rows. Grep for it.
  7. Add debouncing and request cancellation. Confirm in DevTools → Network that typing "pump" sends one request, not four.
  8. 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 useState as 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 BY without 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.

  1. This lesson put every filter in the query string of a GET. Find what MDN says about GET being safe and idempotent, and explain why that makes a filtered URL sharable but a "delete" link a bad idea.
  2. 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

  1. 0–5 min Recall

    Without notes, state yesterday’s main idea and one unresolved question.

  2. 5–20 min Learn

    Read only the listed concept notes and official reference sections needed today.

  3. 20–48 min Build

    Move search/status/page controls into URL query parameters and API requests.

  4. 48–55 min Explain and verify

    Run the result, inspect evidence, and explain the data/control flow in your own words.

  5. 55–60 min Quiz and commit

    Complete the quiz, record one lesson, and commit the verified change when applicable.

What to hand in

Deliverable

Reloading or sharing the URL preserves the visible list state.

Working with AI today

AI as skeptical reviewer

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

Q1 Why place list filters in the URL?
Q2 Which result best proves today’s work is complete?
Q3 What should an AI code review primarily produce?

Explain-back gate

Pass the quiz above to unlock completion.

Quiz + explain-back checks required.