Skip to content

Search Params as Actual State #4973

@Eliav2

Description

@Eliav2

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 validateSearch normalizes/validates incoming search values, but there’s no symmetric per-param encode on write.
  • navigate supports a mask option 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: validateSearch lets 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/stringifySearch configuration (including the helpers parseSearchWith/stringifySearchWith). Those are useful, but they apply globally and cannot express heterogeneous per-param codecs that many apps require.
  • 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 search state. Managing mask per navigate call 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 navigate with the merged search, honoring any replace: false request (push outweighs replace).
  • Apply param-level codec.encode on write and codec.decode on read. If encode returns null, remove from URL and from storage.
  • Persist to localStorage/sessionStorage with versioning when configured, and restore on route transitions and popstate when params re-enter scope.
  • Track active registrations from useSearchParam and prune pruneWhenUnused params 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 masked so the router automatically derives the visible URL mask without each call supplying mask.
  • Expose meta.hasSynced signaling that the initial URL+storage reconciliation has completed.

Behavior details

  • Atomic batching: Within the same render/commit, multiple components calling setValue for different params should result in one navigate call with a merged search. This prevents last-write-wins and aligns with React’s update batching.

  • Symmetric codec: Reads go URL → validateSearchcodec.decode → app value. Writes go app value → codec.encode → URL. Errors or invalid values use fallback and 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 a pruneWhenUnused key, 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 mask automatically from registered params with masked: 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 navigate per 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.

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions