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>
);
}
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
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>;
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.
useSearchNavigatebinds 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:
- You never keep a second copy of a search value in
useState. If you need it, read it from the clipboard. - 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)
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]
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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>
);
}
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
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
The setter identity is stable.
useNavigate({ from: path })runs once per render, anduseCallback(..., [navigate])returns the same function whilenavigatestays the same. You can put the setter in an effect's dependency list without causing a loop.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, runsvalidateSearchon the new URL, and then re-renders subscribed components. CallingsetSearchduring render would start a navigation on every render, so keep it in handlers and effects.The updater receives the validated value.
previs the output ofvalidateSearch, not the raw query string. The mergeObject.assign({}, prev, update)is shallow, so nested objects in the search schema are replaced, not merged.Empty values are removed, and defaults fill them back.
isEmptyValuedropsnull,undefined,"", andNaNbefore the URL is written. Booleans are not in that list, socreateDialog=falsestays in the URL. Ifpagehas a default of1, thensetSearch({ page: undefined })removes the key and reading returns1. One consequence: an effect that callssetSearchon mount with an unchanged value still creates a navigation, so compare against the current value first.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 passreplace: true. Usereplacefor high-frequency updates. Thevoidin front ofnavigatediscards 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 withuseSearchorRoute.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)
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.