DEV Community

Daniel Pertu
Daniel Pertu

Posted on

Our filter state lives in the URL, the serialized filters are also the paging key, and nulls sort last in both directions

A Nakodo campaign ends up with a long list of creators, each scored and annotated, and the brand needs to cut it down: by platform, by size, by whether there is an email to write to, by the fit line of 55 that our how it works page publishes. That is an ordinary filter UI, and the ordinary way to build it is a pile of useState in the list component.

We put it in the URL instead, behind one pure module (filters.ts, 222 lines) whose comment says why:

// Filters and sorting for a campaign's creators, kept in the URL so a view can
// be shared, survives a refresh, and the creator page can step through the
// same list. Pure, so the results list and the creator page use the same code.
Enter fullscreen mode Exit fullscreen mode

Three consumers, and the third one is the reason this was worth extracting.

Parsing is a whitelist, so a bad URL is not an error

const oneOf = <T extends string>(list: readonly T[], value: string | undefined, fallback: T): T =>
  list.includes(value as T) ? (value as T) : fallback;
Enter fullscreen mode Exit fullscreen mode

Every enum-shaped param goes through that, and a number gets range-checked:

minScore: fit ? FIT_MIN_SCORE : Number.isFinite(min) && min > 0 && min <= 100 ? Math.round(min) : 0,
Enter fullscreen mode Exit fullscreen mode

No validation errors, no thrown 400, no empty state saying your link is malformed. ?sort=bogus&min=-5 opens the default list, and the test asserts exactly that. This matters more than it sounds: these URLs get mailed, bookmarked and truncated by chat clients, and a brand following a six-week-old link from a notification email should land on their creators rather than on an error.

Two old views are deliberately silently remapped:

assert.equal(parseFilters({ view: "rejected" }).view, "qualified");
assert.equal(parseFilters(new URLSearchParams("view=shortlisted")).view, "qualified");
Enter fullscreen mode Exit fullscreen mode

We removed the rejected view and renamed the shortlist. The whitelist means those links keep working without a redirect table.

parseFilters also takes either shape of params, because the client has URLSearchParams from useSearchParams and the server page gets Next's record of string-or-array:

function getter(params: ParamSource) {
  return (key: string): string | undefined => {
    if (params instanceof URLSearchParams) return params.get(key) ?? undefined;
    const v = params[key];
    return Array.isArray(v) ? v[0] : v;
  };
}
Enter fullscreen mode Exit fullscreen mode

Serializing writes only what differs

export function filtersToParams(f: Filters): URLSearchParams {
  const p = new URLSearchParams();
  if (f.view !== DEFAULT_FILTERS.view) p.set("view", f.view);
  if (f.q.trim()) p.set("q", f.q.trim());
  if (f.minScore > 0) p.set("min", String(f.minScore));
  if (!f.creatorsOnly) p.set("all", "1");
  // ...
}
Enter fullscreen mode Exit fullscreen mode

filtersToParams(DEFAULT_FILTERS).toString() === "". The default view has a clean URL, and every round trip through parse and serialize is stable, which the test checks in both directions.

Note all=1 for creatorsOnly: false. Where a default is "on", the param has to be its negation, otherwise the clean-URL rule and the default fight each other. The naming is the price: creatorsOnly in the type, all in the URL.

One param is not a view at all

const rawView = get("view");
// "fit" is the link in notification emails: checked creators scoring Good or better.
const fit = rawView === "fit";
Enter fullscreen mode Exit fullscreen mode

Our notification emails need to link to "the creators worth looking at", which is not a stored view, it is a view plus a minimum score. view=fit expands to view=qualified and minScore=55 on the way in and never comes back out, so a brand who then changes a filter gets an honest URL describing what they are actually looking at. One alias in, zero aliases out.

The comparator that is not a sign flip

const sign = f.dir === "asc" ? 1 : -1;
return list
  .map((r, i) => ({ r, i, v: sortValue(r, f.sort) }))
  .sort((a, b) => {
    if (a.v === null || b.v === null) return a.v === b.v ? a.i - b.i : a.v === null ? 1 : -1;
    return (a.v - b.v) * sign || a.i - b.i;
  })
  .map((x) => x.r);
Enter fullscreen mode Exit fullscreen mode

Two things here that a plain (a, b) => a.v - b.v version gets wrong.

Missing values sort last either way. Sort by median views descending and the creators whose views we could not read are at the bottom. Reverse it and they are still at the bottom. That is not consistent in the mathematical sense, and it is obviously right in the product sense: ascending order means "smallest first", not "unknowns first". Null is not a small number, it is the absence of one.

The original index is the tiebreak. Array.prototype.sort has been stable since ES2019, so this is not about stability. It is about the three places that reorder the list, and keeping the server's ordering as the explicit final key means the tie-break survives the map into wrapper objects. Decorating with the index also means sortValue runs once per row instead of on every comparison.

The list, the row link, and prev/next

The list is a client component that writes filters with replaceState:

const qs = filtersToParams(next).toString();
window.history.replaceState(null, "", qs ? `?${qs}` : window.location.pathname);
Enter fullscreen mode Exit fullscreen mode

No router push, so changing a filter does not add a history entry and does not ask the server for anything: all the rows are already here, and filtering is a pure function of them. The Back button means "leave this list", which is what a brand expects after ticking four checkboxes.

The serialized filters are then used for two more jobs. They are the key for incremental paging:

const limit = page.key === fromParam ? page.limit : PAGE;
Enter fullscreen mode Exit fullscreen mode

A "show 50 more" that stays expanded when you change the filters is a bug, and comparing the key to the current query string fixes it without an effect or a reset.

And they ride along to the creator page:

const creatorHref = (r: ResultRow) =>
  `/campaigns/${campaignId}/creators/${r.channelId}${fromParam ? `?from=${encodeURIComponent(fromParam)}` : ""}`;
Enter fullscreen mode Exit fullscreen mode

That page parses from, applies the same filters to the same rows server side, and finds the creator's neighbours:

export function neighbours<T extends { channelId: string }>(list: T[], channelId: string) {
  const i = list.findIndex((r) => r.channelId === channelId);
  if (i < 0) return { index: -1, total: list.length, prev: null, next: null };
  return { index: i, total: list.length, prev: list[i - 1] ?? null, next: list[i + 1] ?? null };
}
Enter fullscreen mode Exit fullscreen mode

So "next creator" on a detail page means the next one in the filtered, sorted list the brand was actually looking at, including "number 7 of 41", and it works on a cold page load from a shared link. Doing that with component state would mean either passing the whole list through navigation or recomputing it differently in two places.

channelId is also where the platform filter gets its answer, because the id carries its own prefix (UCabc, tiktok:brewer, instagram:brewer), so filtering by platform needs no extra column and no join.

One small embarrassment

Nine tests cover all of this, and running them by their literal path finds nothing at all:

$ npx tsx --test 'src/app/(app)/campaigns/[id]/filters.test.ts'
ℹ tests 0

$ npx tsx --test "src/app/**/filters.test.ts"
ℹ pass 9
Enter fullscreen mode Exit fullscreen mode

Next's route groups and dynamic segments are (app) and [id], which are extglob and character-class syntax to the test runner's matcher. The path that works is the glob, not the real filename. Worth knowing before you spend ten minutes convinced your test file has no tests in it.

If you want to see what the filtered list is filtering, the five parts of the fit score and the 55 line are both on the how it works page, and the pricing page explains why the Free plan's list is capped at the best 20 per campaign while paid plans see everything found.

Top comments (0)