# 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 1. **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`. 2. **Everything declared exactly once.** Codec, default and loader-relevance live in a single definition shared by server and client. No `?? default` at call sites. 3. **Canonical URLs.** Encoding a value equal to its default removes the param from the URL. Non-canonical incoming URLs are tolerated, never redirected. 4. **Round-trip guarantee.** `decode(encode(x))` deep-equals `x` for every param, enforced by a round-trip test per definition. 5. **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 `-search-params.ts` at the feature root: ```ts // 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 to `null`, 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 as `null` and are resolved downstream in the loader/component. - `loader` (required) — whether changing this param must run loaders. `loader: true` params write through react-router navigation; `loader: false` params write through `history.replaceState` and never trigger loaders, revalidation or full-page rerenders. - `resets` — param keys reset to their defaults whenever this param is written (the "filter change resets `page`" idiom, declared once). - `compress` (default `false`) — 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. 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 ```ts 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 ```ts 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: true` the whole batch goes through one navigation, otherwise `history.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 ```ts buildsSearchParams.href(buildsPage(slug), { f: filters }); // → "/builds/splattershot?f=%5B…%5D" (defaults omitted) ``` Used by `` 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: ```ts 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.