Repository navigation
Search Params as Actual State #4973
Description
We tried to use TanStack Router search params as our primary global state. While the read story is great (useSearch + schema validation), the write and lifecycle aspects are too low-level. We ended up building a fairly involved layer to make search-params reliable, composable, and ergonomic. We’ve read "Search Params Are State" — we agree on the premise; the missing pieces are writes and cross-component coordination.
This issue proposes concrete primitives the router could provide to enable "search params as state" without userland workarounds.
What exists today (and why it's not sufficient)
- Global encode/decode can be customized via
parseSearch/stringifySearch(e.g.,parseSearchWith,stringifySearchWith). This is helpful but applies globally, not per param.
const router = createRouter({
routeTree,
parseSearch: parseSearchWith((v) => v),
stringifySearch: stringifySearchWith(String),
});- Route-level
validateSearchnormalizes/validates incoming search values, but there’s no symmetric per-param encode on write. navigatesupports amaskoption to control visible URL segments, but masking is specified per navigation, not per param, and does not compose across components.
Why the current API falls short
-
Last-write-wins within a render: Multiple
navigate({ search: ... })calls during the same render/commit will override each other; only the last one survives. Real apps routinely update different params from different components in the same render. We need deterministic, atomic batching across the tree.
Repro (navigate last-write-wins vs functional updaters): CodeSandbox demo -
No per-param encode/decode contract:
validateSearchlets us normalize incoming values (decode), but there’s no symmetrical per-param encode path for writes. Apps often need compact encodings, readability encodings, or stable formats. Encode/decode must be defined per param and applied automatically on both read and write.- Note: We are aware of the global
parseSearch/stringifySearchconfiguration (including the helpersparseSearchWith/stringifySearchWith). Those are useful, but they apply globally and cannot express heterogeneous per-param codecs that many apps require.
- Note: We are aware of the global
-
No persistence lifecycle: There is no built-in way to persist param state to
localStorage/sessionStorage, version it, and restore it on navigation/back/forward when the param re-enters scope. This is essential for UX continuity and offline-friendly flows. -
No registration/scope → orphaned params: The router doesn’t know which search params are actively used in the current view subtree. We need a registration mechanism so that when a param is not used anymore, it is pruned from the URL; and when it becomes used again, its stored state can be restored into the URL.
-
No debounce/write policy: Many params (e.g., text filters, time ranges) should be debounced. Today this must be hand-rolled and coordinated across components, or you risk thrashing history and storage.
-
Masking is per-navigate, not per-param: We often need some params hidden/"masked" from the visible URL, but still present in the router’s
searchstate. Managingmaskpernavigatecall is error-prone and does not compose across components. -
Back/forward semantics: On
popstate, the router should cooperatively reconcile URL, in-memory state, and storage using the same encode/decode contracts. Doing this in userland requires lower-level subscriptions and a lot of edge-case handling.
Proposed primitives (API-level)
API: first-class per-param state with batching, codecs, storage, and lifecycle built-in.
Primaries: useSearchParam (main), useSetSearchParam (setter-only for fine-grained writes).
type SearchParamCodec<T> = {
decode: (urlValue: unknown) => T; // decode URL -> T
encode: (value: T) => string | null; // encode T -> string|null (null removes)
};
type SearchParamOptions<T> = {
codec: SearchParamCodec<T>;
replace?: boolean; // default write policy
debounceMs?: number; // debounce for writes
storage?: {
local?: boolean;
session?: boolean;
key?: string; // default: param name
version?: string; // storage version
};
masked?: boolean; // hide from visible URL
fallback?: T; // used when decode fails
pruneWhenUnused?: boolean; // remove when unused in subtree
};
// Hook returns [value, setValue, meta]
declare function useSearchParam<T, K extends keyof Search>(
key: K,
options: SearchParamOptions<T>
): [value: T, setValue: (value: T | ((prev: T) => T), opts?: { replace?: boolean }) => void, meta: { hasSynced: boolean }];
// Setter-only variant
declare function useSetSearchParam<T, K extends keyof Search>(
key: K,
options: SearchParamOptions<T>
): (value: T | ((prev: T) => T), opts?: { replace?: boolean }) => void;
// Router SHOULD implicitly batch same-render search updates (no explicit API)Router responsibilities:
- Collect all search updates scheduled within a commit and apply a single atomic
navigatewith the mergedsearch, honoring anyreplace: falserequest (push outweighs replace). - Apply param-level
codec.encodeon write andcodec.decodeon read. Ifencodereturnsnull, remove from URL and from storage. - Persist to
localStorage/sessionStoragewith versioning when configured, and restore on route transitions andpopstatewhen params re-enter scope. - Track active registrations from
useSearchParamand prunepruneWhenUnusedparams from the URL when not used by the current subtree. When a param becomes used again, restore its persisted value into the URL before consumers read it. - Support param-level
maskedso the router automatically derives the visible URL mask without each call supplyingmask. - Expose
meta.hasSyncedsignaling that the initial URL+storage reconciliation has completed.
Behavior details
-
Atomic batching: Within the same render/commit, multiple components calling
setValuefor different params should result in onenavigatecall with a mergedsearch. This prevents last-write-wins and aligns with React’s update batching. -
Symmetric codec: Reads go URL →
validateSearch→codec.decode→ app value. Writes go app value →codec.encode→ URL. Errors or invalid values usefallbackand optionally trigger a corrective write to clean the URL. -
Storage & versioning: If configured, the router writes
{version, value}to storage after successful merge. On navigation, if a registered param is missing from the URL, the router restores it from storage (version-matched), then applies batching rules. -
Scope-driven pruning: When no
useSearchParam(key)is mounted for apruneWhenUnusedkey, the router removes it from URL/state. If later mounted again, storage restoration runs first, then consumers receive the decoded value. -
Debounce: Debounced params buffer writes; only the debounced value participates in the batch for that commit. Debounce is per-param, not global.
-
Masking: The router constructs the
maskautomatically from registered params withmasked: true. This ensures consistent masking regardless of which component updated the param.
Illustrative usage examples
Compact, masked, persisted filters with fallback and history:
const [filters, setFilters] = useSearchParam("filters", {
codec: { decode: decodeFilters, encode: encodeFilters },
storage: { local: true, version: "2" },
replace: false,
fallback: [],
masked: true,
});Session viewport with debounce and dual storage:
const [from, setFrom] = useSearchParam("viewport_from", {
codec: { decode: dateUrlDecode, encode: dateUrlEncode },
storage: { local: true, session: true },
debounceMs: 200,
replace: true,
fallback: new Date(),
masked: true,
});What we had to build in userland to make this work
We built a context provider and hooks that:
- Aggregate updates and perform one
navigateper tick. - Apply per‑param codecs, masking, and debounced writes.
- Persist to local/session storage with versioning and restore on navigation/back.
This layer works but duplicates concerns that the router could handle more robustly and with better ergonomics.
Requested outcome
- Introduce a first-class
useSearchParam(and/or route-level param schema) that supports codecs, storage, masking, debouncing, pruning, and atomic batching.
I'm happy to iterate on a more detailed design or provide a minimal repro highlighting the last-write-wins and lifecycle pitfalls.
Our userland implementation
This implementation works well for our needs, but it required a custom context and a fair amount of userland code. TanStack Router could likely simplify many of these requirements by reusing the underlying TanStack Store for a param registry, debounced updates, and persistence—reducing app-level boilerplate.
Approval has been granted to share the implementation and example usages if the team is interested. I can provide code excerpts or a minimal repo and help iterate on integrating these primitives.
Activity
sydonia86 commentedon Aug 19, 2025
You can achieve what you want by using the reducer form of search:
Eliav2 commentedon Aug 19, 2025
I know. As the sandbox demonstrates. It's not enough @sydonia86
NitsanCohen770 commentedon Aug 21, 2025
A lot of what you describe is already covered (or enabled) by my PR #5004.
persistSearchParamssaves on change and restores on navigation/SSR hydration, so filters/pagination survive route changes, reloads, and back/forward.navigate, keeping URL + in-memory state consistent.localStorage, but you can drop in session/DB on the server.So #5004 doesn’t solve everything in your proposal, but it lands the persistence + SSR core and the extension points to build the higher-level primitives you outlined.
Eliav2 commentedon Sep 4, 2025
I recently came across nuqs, which seems to address most of the pain points mentioned in this issue. It’s very similar to the custom solution we built internally at my company, but open source and likely far more battle-tested.
At this point, I’d suggest improving the documentation to acknowledge that TanStack Router doesn’t handle search params well enough to be used as reliable state management on its own. The docs should clearly call this out and point to complementary libraries like nuqs that help fill this gap, ideally by including an example of integration (or at least linking to the nuqs docs, which already provide a TanStack Router example).
The documentation should also highlight the areas nuqs doesn’t cover, particularly param masking and persistence to storage (local/session), and give clear recommendations or patterns for handling those cases.
matthiasfeist commentedon Sep 5, 2025
To add to this: nuqs explicitly addressed the issue with debouncing / history thrashing and caters for it: https://nuqs.47ng.com/blog/beware-the-url-type-safety-iceberg#time-safety
(I'm explicitly looking for this right now, that's how I came across this issue and this blog post)
treybrisbane commentedon Dec 12, 2025
I also ran into this trying to migrate an old React Router project with a custom solution for schema'd query parameter management that's quite difficult to shoehorn into TanStack Router as-is.
TanStack could potentially implement what's proposed here while also keeping its query parameter type-safety by doing something akin to this:
I think that'd even let you straight up use
nuqsunder the hood if you wanted (which isn't a bad idea; it's pretty well designed).LadyBluenotes commentedon Jul 15, 2026
I checked this against main. The narrow parallel functional-updater case is now covered: #2057 added the React behavior, and #5797 moved the pending-location handling into shared router-core and enabled the corresponding Solid coverage. I also verified in a production React build that two synchronous functional updaters preserve both values in Router state and the URL.
Two object-form writes still use replacement semantics, so only the last explicit object survives. Main also does not implicitly combine updates into one navigation.
The broader request remains valid: there is still no built-in state-like setter, per-parameter codec, persistence, debounce, registration/pruning, or per-parameter masking contract. #4552 is relevant to the setter and batching portion, but it is currently open, conflicting, and does not cover the rest of this proposal. #5004 was closed (unmerged) and should only be treated as persistence design history.
Keeping this open as the enhancement and design owner. I think the setter ergonomics, codec contract, and persistence/lifecycle behaviour need to be looked at as separate pieces rather than one coupled API
DreierF commentedon Sep 4, 2026
Having this as a primitive in Tanstack Router would be really appreciated! Especially as it is advertized as main philosophy of Tanstack Router: https://tanstack.com/blog/search-params-are-state
We currently use a variation of #70 (comment), but it also stopped working properly after #7805 made
navigateasync.useSearchnow re-renders with the old search param value before settling on the new value. This is especially a problem if that state is used to control a text input, as React has to reset the input value after every keystroke for a moment until the async navigation was processed, which makes it impossible to properly type in the middle of a text input.Reproduction: https://codesandbox.io/p/devbox/objective-dawn-zj6qxh?workspaceId=ws_sRevNACYX2uQF5tLqMZJe (including a workaround in case someone runs into the same issue)
CogitoBurrito commentedon Sep 6, 2026
This is what I have been using, feel free to give a go :).