8.2 KiB
Search params
All URL search param handling goes through app/modules/search-params/. One declaration per param drives loader parsing, client state, href building and revalidation. Raw useSearchParams, searchParams.get(...) and hand-rolled query strings are not used outside the module.
Principles
- Decoding is total — it never fails. Every param has a default. Malformed or missing values silently resolve to the default. Users never see an error, redirect or crash because of a search param. Decoded types are the plain value type, never
T | undefined. - Everything declared exactly once. Codec, default and loader-relevance live in a single definition shared by server and client. No
?? defaultat call sites. - Canonical URLs. Encoding a value equal to its default removes the param from the URL. Non-canonical incoming URLs are tolerated, never redirected.
- Round-trip guarantee.
decode(encode(x))deep-equalsxfor every param, enforced by a round-trip test per definition. - Context-free codecs. A codec may only look at its own string. Validation that depends on other params or loaded data stays in app code after parsing.
Defining params
One definition per route (or feature, when several routes share params), in a shared non-.server file named <feature>-search-params.ts at the feature root:
// app/features/builds/builds-search-params.ts
import { z } from "zod";
import * as SearchParams from "~/modules/search-params/search-params";
import { SP } from "~/modules/search-params/search-params";
export const buildsSearchParams = SearchParams.define({
limit: SP.param(z.number().int().min(1).max(100), { default: 24, loader: true }),
f: SP.json(buildFiltersSchema, { default: [], resets: ["limit"], loader: true }),
focused: SP.param(z.enum(["1", "2", "3"]), { default: "1", loader: false }),
tournament: SP.param(z.string().max(100).nullable(), { loader: true }),
});
Options accepted by every declaration:
default(required, except for.nullable()schemas — those default tonull, the only default they are allowed, so declaring it is noise) — used when the param is missing or fails to decode. Must be a static value; data-dependent defaults ("latest season the user played") use a static sentinel such asnulland are resolved downstream in the loader/component.loader(required) — whether changing this param must run loaders.loader: trueparams write through react-router navigation;loader: falseparams write throughhistory.replaceStateand never trigger loaders, revalidation or full-page rerenders.resets— param keys reset to their defaults whenever this param is written (the "filter change resetspage" idiom, declared once).compress(defaultfalse) — the canonical encoding is the compressed form. Only for params whose values are inherently large.
SP.param and the derivation table
SP.param(valueSchema, opts) is the canonical declaration. The value schema is plain zod — all validation lives there, and shared schemas from app/utils/zod.ts plug in directly. The URL encoding is derived from the schema's type:
| Schema base type | URL encoding |
|---|---|
z.string(), string enums/literals |
as-is |
z.number(), number enums/literals (incl. numericEnum) |
String(n) |
z.boolean() |
"true" / "false" only |
z.array(item) |
repeated keys (?id=1&id=2); invalid members are dropped, not the whole array |
.nullable() wrapper |
unwrapped; null encodes as param absent. default is omitted (it is always null; passing anything else throws). .optional() is rejected — .nullable() is the project-wide convention |
refinements (.min, .max, .refine, …) |
validation only; a failing value resolves to the default |
Derivation is closed, not best-effort: shapes outside this table (objects, mixed-type unions, transforms, z.preprocess) are a define()-time error. Those use the explicit helpers:
| Helper | Encoding |
|---|---|
SP.json(schema, opts) |
JSON.stringify in a single value — for objects and whole-array-as-one-param values |
SP.custom(codec, opts) |
anything — pass a z.codec(z.string(), valueSchema, { decode, encode }) directly |
Note: schemas built with z.preprocess (like weaponSplId, stageId in app/utils/zod.ts) are pipes and rejected — use the inner schema (numericEnum(mainWeaponIds), numericEnum(stageIds)) since string→number conversion is the codec's job.
Compression
Any param can arrive compressed (an lz~ prefix followed by a deflate + base64url payload) without declaring anything — decode transparently decompresses first. Encoding stays human-readable except for compress: true params and on-demand compact links via definition.href(path, values, { compress: true }) (QR codes, share links). A value is only compressed when that actually shortens it, compared as percent-encoded since that is what ends up in the URL.
Since decoding happens before the value schema ever runs, a compressed arrival that inflates past 256 KiB is rejected mid-inflate and resolves to the default, so a hand-crafted URL cannot inflate to an arbitrarily large string on the server. The limit is an order of magnitude above the largest state the app produces (a tier list holding the entire item pool serializes to ~15 KB) while capping how far a URL that fits in the request line can expand.
When the href is already built and the definition behind it is not known (e.g. the QR code of ImageExportDialog, which compacts whatever path it is given), SearchParams.compactHref(href) re-encodes every param of an existing href the same way.
Loader API
export const loader = async ({ request }: LoaderFunctionArgs) => {
const { limit, f } = buildsSearchParams.parse(request);
// fully typed, all defaults resolved, never throws
};
parse also accepts a URL or URLSearchParams.
Client API
const [params, setParams] = useSearchParamsTyped(buildsSearchParams);
setParams({ f: newFilters }); // merge write: other params preserved; `resets` applied
setParams({ focused: "2" }); // loader:false only → history.replaceState, no navigation
setParams({ f: x, focused: "2" }); // mixed batch → one navigation carrying both changes
- Writes are merges: params not mentioned are preserved.
- The write channel is decided per batch: if any written param is
loader: truethe whole batch goes through one navigation, otherwisehistory.replaceState. - Values equal to their default are removed from the URL on write.
- Default navigation options are
{ replace: true, preventScrollReset: true }, overridable per call:setParams(values, { replace: false, preventScrollReset: false }). - For a focused subscription to one param:
const [weapon, setWeapon] = useSearchParam(buildsSearchParams, "weapon")— rerenders only when that param changes. - Setter identities are stable; safe in dependency arrays.
Pagination goes through useSearchParamPagination({ definition, currentPage, pagesCount }) where the definition declares a numeric page param.
Href building
buildsSearchParams.href(buildsPage(slug), { f: filters });
// → "/builds/splattershot?f=%5B…%5D" (defaults omitted)
Used by <Link to> and by the query-building helpers in app/utils/urls.ts.
Revalidation
loader: false params bypass the router entirely, which removes the need for most custom shouldRevalidate implementations. For routes where loader params should be compared by value:
export const shouldRevalidate = buildsSearchParams.shouldRevalidate;
// revalidates only when a loader:true param's decoded canonical value changed
Submissions, revalidator calls, pathname changes and unknown-param changes defer to the router default.
Enforcement
The no-raw-search-params Biome plugin fails the lint on useSearchParams() and on …searchParams.get/getAll/has(…) anywhere in app/ outside this module.
The escape hatch is // biome-ignore lint/plugin: <reason>, for the cases the convention genuinely does not cover: URLs this app did not route to (an OAuth provider's callback params, a URL pasted by a user), and reads where the total-decoding guarantee is wrong — a param whose absence must fail the request rather than resolve to a default.