DEV Community

Cover image for useSearchNavigate: Type-Safe URL State Without the Drift
Saiful Islam
Saiful Islam

Posted on

useSearchNavigate: Type-Safe URL State Without the Drift

A small hook over TanStack Router's navigate that keeps tabs, dialogs, and pages in the URL with types intact, and why the write lands after the commit instead of during render.

The Problem

You are building a company page with three tabs and a button that opens a "New purchase" dialog. The page works on the first click.

import { useState } from "react";

function CompanyPage() {
  const [tab, setTab] = useState<"details" | "purchase" | "payment">("details");
  const [dialogOpen, setDialogOpen] = useState(false);

  return (
    <section>
      <button onClick={() => setTab("purchase")}>Purchases</button>
      {tab === "purchase" && (
        <button onClick={() => setDialogOpen(true)}>Add new</button>
      )}
      {dialogOpen && <div role="dialog">New purchase</div>}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Then the user refreshes the page. The tab is back on "details" and the dialog is closed. The same happens when they copy the link and send it to a colleague, or press the browser back button after switching tabs.

The first fix most people try is to seed useState from the URL:

const search = useSearch({ from: "/companies/$companyId" });
const [tab, setTab] = useState(search.tab); // frozen at mount
Enter fullscreen mode Exit fullscreen mode

This survives a refresh, but it breaks on back and forward. The URL changes, search.tab changes, and tab keeps the value it had on mount. Now the URL and the screen disagree.

The second fix is to write the query string by hand with window.history.pushState. The URL now updates, but the router does not know about the change, so nothing re-renders from the new value. Reading the value back gives you a raw string that you have to trust, so "2" and 2 look the same to your code until something breaks.

The third fix is to use <Link search={{ tab: "purchase" }}> everywhere. This is closer, but the object you pass replaces the whole search object. Switching to the payments tab drops page and q from the URL, so the user loses their place every time they change tabs.

The tension: the component needs the value to be the source of truth for what it renders, and the URL needs to be the source of truth for what survives a reload, and no single naive tool gives you both without a copy that can drift.

The resolution is to declare the search schema on the route, read it with a typed hook, and write it through one setter:

const search = Route.useSearch();
const setSearch = useSearchNavigate(Route.fullPath);

<button onClick={() => setSearch({ tab: "purchase" })}>Purchases</button>;
Enter fullscreen mode Exit fullscreen mode

The rest of this tutorial shows how the pieces fit.

The Mental Model

Think of the URL as a shared clipboard passed between two desk clerks. The clipboard holds the current form for the page. The first clerk, validateSearch, checks every entry on the clipboard when it arrives and fills in blanks with defaults. The second clerk, useSearchNavigate, is the only person allowed to write a new entry, and does it by handing the clipboard back to the router.

  • Identity: the clipboard belongs to one route. useSearchNavigate binds to that route's path once, so every write goes to the same clipboard.
  • Update timing: a write does not change state directly. It asks the router for a new clipboard. The new entry is checked and the page re-renders only after the router accepts it.
  • Read timing: components read the checked entry through useSearch. A read always returns the validated, typed value, never the raw string from the address bar.

Two consequences follow from this:

  1. You never keep a second copy of a search value in useState. If you need it, read it from the clipboard.
  2. Writes are navigations, so they are history entries by default. Treat each write as a page change, not a state tweak.

The clipboard is the state, and the clerks only ever touch it through the front desk.

How to Use It

Setup

Swap @/ for whatever path alias your project uses. The hook imports the route types generated by TanStack Router, so run the router generator (the Vite plugin or the TanStack Router CLI) at least once.

src/
├── hooks/
│   └── use-search-navigate.ts
└── routes/
    ├── companies/
    │   └── $companyId.tsx
    └── routeTree.gen.ts   (generated by TanStack Router)
Enter fullscreen mode Exit fullscreen mode

The hook, in full:

import { useCallback } from "react";

import { useNavigate } from "@tanstack/react-router";

import type { FileRoutesByFullPath, FileRouteTypes } from "@/routeTree.gen";

/** All full route paths from the generated route tree (API routes excluded). */
export type RoutePath = Exclude<FileRouteTypes["fullPaths"], `/api/${string}`>;

/** Fully-resolved search schema (own + parent routes) for a given route path */
export type SearchOf<TPath extends RoutePath> =
  FileRoutesByFullPath[TPath]["types"]["fullSearchSchema"];

/**
 * Either a partial object (merged into the current search params)
 * or an updater function receiving the previous search params.
 */
export type SearchUpdate<TPath extends RoutePath> =
  Partial<SearchOf<TPath>> | ((prev: SearchOf<TPath>) => SearchOf<TPath>);

type SearchNavigateOptions = {
  /** Replace the current history entry instead of pushing a new one */
  replace?: boolean;
  /** Reset scroll position after navigating */
  resetScroll?: boolean;
  /** Hash to set on the URL */
  hash?: string;
};

/** null | undefined | '' | NaN */
function isEmptyValue(value: unknown): boolean {
  return (
    value === null ||
    value === undefined ||
    value === "" ||
    (typeof value === "number" && Number.isNaN(value))
  );
}

/**
 * Type-safe helper to navigate by changing only the search params of a route.
 * Keys whose value is null, undefined, '' or NaN are removed from the URL.
 */
export function useSearchNavigate<const TPath extends RoutePath>(path: TPath) {
  const navigate = useNavigate({ from: path });

  return useCallback(
    (update: SearchUpdate<TPath>, options: SearchNavigateOptions = {}) => {
      const searchValue = (prev: SearchOf<TPath>) => {
        const next =
          typeof update === "function"
            ? update(prev)
            : (Object.assign({}, prev, update) as SearchOf<TPath>);

        return Object.fromEntries(
          Object.entries(next as Record<string, unknown>).filter(
            ([, value]) => !isEmptyValue(value)
          )
        );
      };

      void (navigate as (opts: unknown) => Promise<void>)({
        to: ".",
        search: searchValue,
        ...options,
      });
    },
    [navigate]
  );
}
Enter fullscreen mode Exit fullscreen mode

The route declares its search schema with validateSearch, then reads and writes it. The file, in full:

import { createFileRoute, useSearch } from "@tanstack/react-router";
import { z } from "zod";

import { useSearchNavigate } from "@/hooks/use-search-navigate";

const tabs = ["details", "purchase", "payment"] as const;

const searchSchema = z.object({
  tab: z.enum(tabs).catch("details"),
  page: z.number().int().min(1).catch(1),
  q: z.string().catch(""),
  createDialog: z.boolean().catch(false),
});

export const Route = createFileRoute("/companies/$companyId")({
  validateSearch: searchSchema,
  component: CompanyPage,
});

function CompanyPage() {
  const search = Route.useSearch();
  const setSearch = useSearchNavigate(Route.fullPath);

  return (
    <section>
      <nav>
        {tabs.map((tab) => (
          <button
            key={tab}
            aria-pressed={search.tab === tab}
            onClick={() => setSearch({ tab, page: 1 })}
          >
            {tab}
          </button>
        ))}
      </nav>
      {search.tab === "purchase" && <PurchasesPanel />}
    </section>
  );
}

function PurchasesPanel() {
  const search = useSearch({ from: "/companies/$companyId" });
  const setSearch = useSearchNavigate("/companies/$companyId");

  const handleOpenDialog = (value: boolean) => {
    setSearch((prev) => ({ ...prev, createDialog: value }));
  };

  return (
    <section>
      <button onClick={() => handleOpenDialog(true)}>Add new</button>
      {search.createDialog && (
        <div role="dialog">
          <p>New purchase</p>
          <button onClick={() => handleOpenDialog(false)}>Close</button>
        </div>
      )}
    </section>
  );
}
Enter fullscreen mode Exit fullscreen mode

Why .catch() and not .default()? .default(x) only fills a missing key. A malformed value such as ?page=abc still fails validation and throws. .catch(x) fills both cases, so a hand-edited or stale link falls back to a safe value instead of breaking the page. Use .default() only when you want bad input to surface as an error.

The import line for the hook is import { useSearchNavigate } from "@/hooks/use-search-navigate";. Your route file must live where TanStack Router expects it, and the string passed to createFileRoute must match its full path.

Pattern 1: Read the value in any child

A component under the route reads typed values without props.

import { useSearch } from "@tanstack/react-router";

function TabHeading() {
  const { tab, page } = useSearch({ from: "/companies/$companyId" });

  return (
    <h2>
      {tab} (page {page})
    </h2>
  );
}
Enter fullscreen mode Exit fullscreen mode

The payoff: tab is a union of three strings and page is a number. Your editor and the compiler both know it.

Pattern 2: Paginate with an updater

Use the function form when the next value depends on the previous one.

import { useSearch } from "@tanstack/react-router";

import { useSearchNavigate } from "@/hooks/use-search-navigate";

function Pager() {
  const { page } = useSearch({ from: "/companies/$companyId" });
  const setSearch = useSearchNavigate("/companies/$companyId");

  return (
    <div>
      <button
        disabled={page <= 1}
        onClick={() =>
          setSearch((prev) => ({ ...prev, page: prev.page - 1 }))
        }
      >
        Previous
      </button>
      <span>Page {page}</span>
      <button
        onClick={() =>
          setSearch((prev) => ({ ...prev, page: prev.page + 1 }))
        }
      >
        Next
      </button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The payoff: prev is the validated search, so prev.page + 1 is a number. Refreshing keeps the page, and the back button walks through pages.

Pattern 3: Reset paging and skip history

When a change invalidates the current page, write both keys in one call. Use replace when the user is only adjusting the view and back should not step through it.

import { useSearchNavigate } from "@/hooks/use-search-navigate";

type Tab = "details" | "purchase" | "payment";

function TabSwitcher() {
  const setSearch = useSearchNavigate("/companies/$companyId");

  const switchTab = (tab: Tab) => {
    setSearch({ tab, page: 1 }, { replace: true });
  };

  return (
    <div>
      <button onClick={() => switchTab("details")}>Details</button>
      <button onClick={() => switchTab("payment")}>Payments</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The payoff: switching tabs always starts at page 1, and the history stack does not fill with one entry per click.

Pattern 4: Clearing a value removes it from the URL

Empty values are dropped from the address bar. The read side then falls back to the schema default.

import { useSearch } from "@tanstack/react-router";

import { useSearchNavigate } from "@/hooks/use-search-navigate";

function SearchBox() {
  const { q } = useSearch({ from: "/companies/$companyId" });
  const setSearch = useSearchNavigate("/companies/$companyId");

  return (
    <div>
      <input
        defaultValue={q}
        onBlur={(event) => setSearch({ q: event.target.value, page: 1 })}
      />
      <button onClick={() => setSearch({ q: "" })}>Clear</button>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

The payoff: after "Clear", the URL has no q key and q reads as "" again. The URL stays short, and the schema stays the single definition of the default.

Anti-pattern

❌ Copying a search value into state. The copy freezes at mount, and the back button changes the URL but not the tab.

const search = useSearch({ from: "/companies/$companyId" });
const [tab, setTab] = useState(search.tab); // ❌ never updates from the URL
Enter fullscreen mode Exit fullscreen mode

Read search.tab directly every render, and write through setSearch.

When to Use It

Reach for URL search state only when all three are true:

  • Someone should be able to bookmark, share, or refresh into this exact view.
  • The value can be expressed as a small serializable value such as a string, number, boolean, or short array.
  • You set it from event handlers or effects, never from render.

Do not reach for it for these situations:

Situation Better tool
Text being typed into a form before save react-hook-form or local useState
Open or closed state for a tooltip Local useState in the component
Server lists and details TanStack Query, keyed by the search values you read
User theme or language preference Cookie, localStorage, or a context provider
Large nested objects or whole records Store an ID in the URL and load the record
Tokens, secrets, or personal data Never in the URL. Use a session cookie or server state.

The smell test: if a copied link to the page would be wrong without this state, the state belongs in search params. If the link would be noise, keep it in useState.

How It Works Under the Hood

  1. The setter identity is stable. useNavigate({ from: path }) runs once per render, and useCallback(..., [navigate]) returns the same function while navigate stays the same. You can put the setter in an effect's dependency list without causing a loop.

  2. Writes land after the commit, not during render. The setter calls navigate, which asks the router for a new location. The router matches the route, runs validateSearch on the new URL, and then re-renders subscribed components. Calling setSearch during render would start a navigation on every render, so keep it in handlers and effects.

  3. The updater receives the validated value. prev is the output of validateSearch, not the raw query string. The merge Object.assign({}, prev, update) is shallow, so nested objects in the search schema are replaced, not merged.

  4. Empty values are removed, and defaults fill them back. isEmptyValue drops null, undefined, "", and NaN before the URL is written. Booleans are not in that list, so createDialog=false stays in the URL. If page has a default of 1, then setSearch({ page: undefined }) removes the key and reading returns 1. One consequence: an effect that calls setSearch on mount with an unchanged value still creates a navigation, so compare against the current value first.

  5. Every write costs an allocation and a history entry. Each call creates a copy of the search object, builds entries with Object.fromEntries, and pushes a history entry unless you pass replace: true. Use replace for high-frequency updates. The void in front of navigate discards the returned promise, so a rejected navigation becomes an unhandled rejection in the console.

Timeline for one click on "Payments" in the tab navigation, starting from ?tab=details:

Phase search.tab the component sees URL
Before click details ?tab=details
Click handler calls setSearch details unchanged
Router runs validateSearch on the new location payment is a candidate only ?tab=payment
Commit: subscribed components re-render payment ?tab=payment
A concurrent render that React abandons details (the committed value) not visible

The abandoned render has no side effect, because the setter never runs during render. It only ever sees the committed value.

Server rendering. validateSearch runs on the server when the initial URL is matched, and the same schema runs again in the browser. As long as the schema is deterministic, the server HTML and the client's first render agree. Setters do not run during server rendering, because event handlers and effects do not run there.

Comparison with built-in options. TanStack Router's <Link search={(prev) => ({ ...prev, tab: "payment" })}> covers the same ground for links. It is the right choice when the control is a link. The hook is for imperative updates from handlers and effects, and it adds the empty-value cleanup and a fixed route path. It does not replace Link, and it does not remove the need for a route-level schema.

Conclusion

Search params trade a little ceremony for state that a refresh, a shared link, and the back button all respect. The core tradeoff is that the URL becomes a second store, so you pay for validation once in the schema and keep every write on one typed path.

  • Declare the state in validateSearch, and read it with useSearch or Route.useSearch().
  • Write through one setter, with the updater form when the next value depends on the previous one.
  • Never copy a search value into useState, because the copy will drift from the URL.

Put state in the URL when someone else should be able to land on the same view. When the boring answer is a local useState that nobody will ever share, use that and move on.

Top comments (1)

Collapse
 
101beardo profile image
Tarun Sharma •

The SearchBox in Pattern 4 uses defaultValue={q}, and I think that goes stale on back and forward. The URL q changes but an uncontrolled input keeps what it had, so the box and the list disagree again, the same drift you describe for useState. Giving the input key={q} or making it controlled with a local draft would fix it.