Skip to content

Search Params as Actual State #4973

Open

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 → validateSearch → codec.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.

Activity

  1. sydonia86 commented on Aug 19, 2025

    @sydonia86

    Last-write-wins within a render: Multiple navigate({ search: ... }) calls during the same render/commit will override each other; only the last one survives.

    You can achieve what you want by using the reducer form of search:

    router.navigate({ search: (prev) => ({ ...prev, sort: "asc" }), replace: true });
    router.navigate({ search: (prev) => ({ ...prev, filter: "active" }), replace: true });
    
  2. Eliav2 commented on Aug 19, 2025

    @Eliav2
    ContributorAuthor

    I know. As the sandbox demonstrates. It's not enough @sydonia86

    Last-write-wins within a render: Multiple navigate({ search: ... }) calls during the same render/commit will override each other; only the last one survives.

    You can achieve what you want by using the reducer form of search:

    router.navigate({ search: (prev) => ({ ...prev, sort: "asc" }), replace: true });
    router.navigate({ search: (prev) => ({ ...prev, filter: "active" }), replace: true });
    
  3. NitsanCohen770 commented on Aug 21, 2025

    @NitsanCohen770

    A lot of what you describe is already covered (or enabled) by my PR #5004.

    • Writes & lifecycle – persistSearchParams saves on change and restores on navigation/SSR hydration, so filters/pagination survive route changes, reloads, and back/forward.
    • Selective per-param control – explicit allow-list + optional exclude prevent cross-route leakage.
    • Back/forward reconciliation – restores from the typed store and merges into the next navigate, keeping URL + in-memory state consistent.
    • SSR-safe – storage-agnostic per-request store; example shows localStorage, but you can drop in session/DB on the server.
    • Foundation for masking/policies – centralized typed store makes it trivial to add masking (persisted but hidden), debounce, codecs, etc. as thin adapters.

    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.

  4. Eliav2 commented on Sep 4, 2025

    @Eliav2
    ContributorAuthor

    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.

  5. matthiasfeist commented on Sep 5, 2025

    @matthiasfeist

    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)

  6. treybrisbane commented on Dec 12, 2025

    @treybrisbane

    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:

    // Route definition
    export const Route = createFileRoute('/person')({
      // Query parameter configuration defined on each route
      searchParams: {
        name: {
          codec: {
            decode: String,
            encode: String,
          },
          // ...any other options
        },
        dob: {
          codec: {
            decode: (v) => DateTime.fromISO(v),
            encode: (v) => v.toISO(),
          },
          // ...any other options
        },
      },
      component: BarPage,
    });
    // Component
    export const BarPage = (): ReactNode => {
      // Route-specific `useSearchParam` (types inferred from route configuration)
      // `name` is type `string`
      // `setName` is type `(valueOrFn: string | ((oldValue: string) => string)) => void`
      const [name, setName] = Route.useSearchParam({ name: 'name' });
    
      // Global `useSearchParam` (types inferred from route configuration)
      // `dob` is type `DateTime`
      // `setDOB` is type `(valueOrFn: DateTime | ((oldValue: DateTime) => DateTime)) => void`
      const [dob, setDOB] = useSearchParam({ from: '/person', name: 'dob' });
    
      // ...rest of component
    };
    // Links
    // (Types inferred from route configuration)
    <Link to="/person" search={{ name: 'Bob', dob: DateTime.fromISO('2000-01-01T00:00:00.000Z') }} />

    I think that'd even let you straight up use nuqs under the hood if you wanted (which isn't a bad idea; it's pretty well designed).

  7. LadyBluenotes commented on Jul 15, 2026

    @LadyBluenotes
    Member

    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

  8. DreierF commented on Sep 4, 2026

    @DreierF

    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 navigate async. useSearch now 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)

  9. CogitoBurrito commented on Sep 6, 2026

    @CogitoBurrito
    Contributor

    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 navigate async. useSearch now 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)

    This is what I have been using, feel free to give a go :).

    import { useMemo } from 'react'
    import { useRouter, useSearch } from '@tanstack/react-router'
    import type {
      AnyRouter,
      NavigateOptions,
      RegisteredRouter,
      ValidateId,
    } from '@tanstack/react-router'
    
    type ConstrainLiteral<T, TConstraint, TDefault = TConstraint> =
      (T & TConstraint) | TDefault
    
    type SearchSchema<
      TRouter extends AnyRouter,
      TFrom extends keyof TRouter['routesById'],
    > = keyof TRouter['routesById'][TFrom]['types']['searchSchema']
    
    type AnyKey<TRouter extends AnyRouter, TKey extends string> = ConstrainLiteral<
      TKey,
      string &
      {
        [K in keyof TRouter['routesById']]: SearchSchema<TRouter, K>
      }[keyof TRouter['routesById']]
    >
    
    type FromKey<
      TRouter extends AnyRouter,
      TFrom extends string,
      TKey extends string,
    > = ConstrainLiteral<
      TKey,
      string & SearchSchema<TRouter, TFrom & keyof TRouter['routesById']>
    >
    
    type UseSearchStateExperimentalMicrotaskOptions<
      TRouter extends AnyRouter,
      TFrom extends string,
      TStrict extends boolean,
      TKey extends string,
      TSelected,
    > = (
      | {
        from?: never
        strict: TStrict & false
        key: AnyKey<TRouter, TKey>
      }
      | {
        from: ValidateId<TRouter, TFrom>
        strict?: TStrict & true
        key: FromKey<TRouter, TFrom, TKey>
      }
    ) & {
      select?: (value: ValueFrom<TRouter, TFrom, TStrict, TKey>) => TSelected
    }
    
    type ValueFrom<
      TRouter extends AnyRouter,
      TFrom,
      TStrict extends boolean,
      TKey extends string,
    > =
      TStrict extends false ?
      | undefined
      | {
        [K in keyof TRouter['routesById']]: TKey extends (
          keyof TRouter['routesById'][K]['types']['searchSchema']
        ) ?
        TRouter['routesById'][K]['types']['searchSchema'][TKey]
        : never
      }[keyof TRouter['routesById']]
      : TRouter['routesById'][TFrom &
      keyof TRouter['routesById']]['types']['searchSchema'][TKey]
    
    export type SetSearchStateExperimentalMicrotaskOptions = Pick<
      NavigateOptions,
      | 'hashScrollIntoView'
      | 'reloadDocument'
      | 'replace'
      | 'ignoreBlocker'
      | 'resetScroll'
      | 'viewTransition'
    >
    
    export type SetSearchStateExperimentalMicrotask<TValue> = (
      value: TValue | ((previous: TValue) => TValue),
      options?: SetSearchStateExperimentalMicrotaskOptions,
    ) => void
    
    type StrictValue<
      TRouter extends AnyRouter,
      TFrom extends string,
      TKey extends string,
    > = ValueFrom<TRouter, TFrom, true, TKey>
    
    type LooseValue<TRouter extends AnyRouter, TKey extends string> = ValueFrom<
      TRouter,
      string,
      false,
      TKey
    >
    
    export function useSearchStateExperimentalMicrotask<
      TRouter extends AnyRouter = RegisteredRouter,
      TKey extends string = string,
      TSelected = LooseValue<TRouter, TKey>,
    >(
      options: UseSearchStateExperimentalMicrotaskOptions<
        TRouter,
        string,
        false,
        TKey,
        TSelected
      >,
    ): readonly [
      state: TSelected,
      setState: SetSearchStateExperimentalMicrotask<LooseValue<TRouter, TKey>>,
    ]
    export function useSearchStateExperimentalMicrotask<
      TRouter extends AnyRouter = RegisteredRouter,
      TFrom extends string = string,
      TKey extends string = string,
      TSelected = StrictValue<TRouter, TFrom, TKey>,
    >(
      options: UseSearchStateExperimentalMicrotaskOptions<
        TRouter,
        TFrom,
        true,
        TKey,
        TSelected
      >,
    ): readonly [
      state: TSelected,
      setState: SetSearchStateExperimentalMicrotask<
        StrictValue<TRouter, TFrom, TKey>
      >,
    ]
    export function useSearchStateExperimentalMicrotask({
      from,
      strict,
      key,
      select,
    }: {
      from?: string
      strict?: boolean
      key: string
      select?: (value: never) => unknown
    }): readonly [state: unknown, setState: unknown] {
      const searchOptions = useMemo(
        () => ({
          from,
          select: (search: Record<string, unknown>) => {
            const value = search[key]
            return select ? select(value as never) : value
          },
          strict,
        }),
        [from, key, select, strict],
      )
      const state = useSearch<AnyRouter, string, boolean, true, unknown>(
        searchOptions as never,
      )
    
      const router = useRouter()
      const setState = useMemo(
        () =>
          (
            value: unknown | ((previous: unknown) => unknown),
            options?: SetSearchStateExperimentalMicrotaskOptions,
          ) =>
            setSearchValue(router, key, value, options),
        [router, key],
      )
    
      return [state, setState]
    }
    
    type RouterStore = {
      search: object | null
      options: SetSearchStateExperimentalMicrotaskOptions | null
      scheduled: boolean
    }
    
    const routerStores = new WeakMap<AnyRouter, RouterStore>()
    
    /**
     * Router can chain synchronous navigations through its pending location, but
     * each changed location still reaches history and unchanged locations reload.
     * This coordinator keeps a burst to one navigation and skips equal values.
     */
    function setSearchValue<TValue>(
      router: AnyRouter,
      key: string,
      value: TValue | ((previous: TValue) => TValue),
      options?: SetSearchStateExperimentalMicrotaskOptions,
    ) {
      let store = routerStores.get(router)
      const previousSearch = store?.search ?? router.state.location.search
      const previousValue = previousSearch[
        key as keyof typeof previousSearch
      ] as TValue
      const nextValue =
        typeof value === 'function' ?
          (value as (previous: TValue) => TValue)(previousValue)
          : value
    
      if (Object.is(nextValue, previousValue)) return
    
      const nextSearch = {
        ...previousSearch,
        [key]: nextValue,
      }
    
      if (!store) {
        store = {
          search: null,
          options: null,
          scheduled: false,
        }
        routerStores.set(router, store)
      }
    
      store.search = nextSearch
      mergeOptions(store, options)
    
      if (store.scheduled) return
      store.scheduled = true
    
      const unsubscribe = router.subscribe('onBeforeNavigate', () => {
        store.search = null
        store.options = null
        store.scheduled = false
      })
    
      queueMicrotask(() => {
        unsubscribe()
        const search = store.search
        if (!store.scheduled || !search) return
    
        const navigationOptions = store.options
    
        void router.navigate({
          ...navigationOptions,
          hash: router.state.location.hash,
          search,
          to: router.state.location.pathname,
          replace: navigationOptions?.replace !== false,
        })
    
        store.search = null
        store.options = null
        store.scheduled = false
      })
    }
    
    function mergeOptions(
      store: RouterStore,
      options: SetSearchStateExperimentalMicrotaskOptions | undefined,
    ) {
      if (!options) return
      if (!store.options) {
        store.options = { ...options }
        return
      }
    
      if (options.hashScrollIntoView) {
        store.options.hashScrollIntoView = options.hashScrollIntoView
      }
      if (options.reloadDocument) store.options.reloadDocument = true
      if (options.replace !== undefined) {
        store.options.replace =
          store.options.replace === false ? false : options.replace
      }
      if (options.ignoreBlocker) store.options.ignoreBlocker = true
      if (options.resetScroll) store.options.resetScroll = true
      if (options.viewTransition) {
        store.options.viewTransition = options.viewTransition
      }
    }
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions