Writing

Search Params are State

Every filtered list page already has the state it needs. It lives in useState, survives until refresh, and vanishes when someone copies the URL. Cmd-click to open in a new tab, bookmark for later, share a link with a colleague, hit back after drilling in: every one of these assumes the URL carries intent. When it does not, the app feels subtly broken, and no stack trace points at the cause. Six months in, the same product often has five filter implementations, five incompatible link builders, and nobody can say which query params are canonical.

I think about URL state the way TanStack argues it should be thought about: search params are state. They deserve the same rigor as server state or component state. Where I diverge is narrower: who should own the schema. I’ve shipped one answer, a component-level hook stack, in multiple production apps; the other is the route-first model TanStack Router presents. This essay names the tradeoffs between them.

One note before we start: the examples use React Router, Vitest browser mode with Playwright, and Zod for validation. None of those choices are essential. Any router with reactive access to the browser search params can back the hook stack; any validation library can sit in validateSearch; any test harness that mounts a real router and asserts on location.search will do. The ownership question is the point; the packages are examples.

Put the schema in the hooks, and each call site owns its own parse, format, and cross-param rules. Put it on the route with validateSearch, and typed navigation inherits from there. The URL bar looks the same either way.

Same URL bar, two owners

Component-level vs router-level URL state ownership Two columns: hooks and codecs on the left, route validateSearch and typed navigation on the right. component-level router-level useUrlState(key) parse / format per param primitives → derived hooks setPage(), setStatus() shared UrlStateKey enum validateSearch (Zod) on route definition useSearch / getRouteApi navigate({ search: prev => … }) stripSearchParams middleware
Hooks on the left, route schema on the right.
Concern Component-level ownership Router-level ownership
Where the schema lives Hook definitions + shared key enum Route validateSearch schema
Who enforces write safety Discipline + hook APIs; links built manually <Link> and navigate typed against schema
Serialization Per-hook parse/format Router parseSearch/stringifySearch + middlewares
Cross-param rules (e.g. reset page) Declared on setters (deleteQueryStringsOnUpdate) Bundled in handlers or useEffect; reducer-style
Write API at call site Per-param setters (setPage, setStatus) navigate({ search: (prev) => patch })
Adoption path Incremental useStateuseUrlState Route schema before typed navigation
Schema inheritance Manual; key enum in shared package Hierarchical; child routes extend parent
Framework Any router TanStack Router
Default URL style Human-readable per param (a design choice) JSON-first default; customizable globally

Both respond to the same failures: schema drift across files, untyped <Link> hrefs, and URLSearchParams treating everything as flat strings.

useState-style hooks, instead of route schemas

The hook stack’s entry point is deliberately simple: URL state should feel like local state at the call site.

const [tab, setTab] = useState('overview')

// becomes

const [tab, setTab] = useUrlState(UrlStateKey.Tab, 'overview')

The tuple signature ManagedUrlState<T> intentionally mirrors useState:

type ManagedUrlState<T> = [T, (newValue?: T, replace?: boolean) => void]

The setter takes a value plus an optional replace flag (replace the history entry instead of pushing one, useful for stripping one-time params like auth tokens). Destructuring, passing setters to children, conditional rendering: all unchanged. You migrate one param at a time. A list page can move page and sort into the URL while keeping debounced search text in useState. I leave live search out of the URL on purpose: pushing a history entry on every keystroke pollutes the back stack and produces ugly share links. The page reset still needs wiring to the URL layer once the debounced query commits.

useState to useUrlState migration Three stages: local state lost on refresh, URL-backed state, condensed default removed from URL. useState useUrlState default → omit URL: /posts refresh loses tab URL: /posts?tab=settings shareable URL: /posts back to default (omitted)
Ownership stays at the component; the URL becomes the backing store.

Progressive depth

Instead of a big-bang rewrite, adoption is progressive:

// 1. Optional string param
const [tab] = useUrlState(UrlStateKey.Tab)

// 2. String with default (drop-in useState replacement)
const [status, setStatus] = useUrlState(UrlStateKey.Status, 'active')

// 3. Custom codec for structured values
const [timeframe, setTimeframe] = useUrlState({
  key: UrlStateKey.Timeframe,
  defaultValue: { startDate, endDate },
  format: tf => `${fmt(tf.startDate)}_to_${fmt(tf.endDate)}`,
  parse: parseTimeframe,
})

// 4. Primitives hide boilerplate; derived hooks compose them
const [page, setPage] = useNumberUrlState(UrlStateKey.Page)
const [sort, setSort] = useUrlMultiSort()
const { constraints } = usePostsFetchConstraints()

Each param owns its codec. Multi-sort omits the default ascending direction from the URL, so ?sort=createdAt means ascending and ?sort=createdAt_desc,name means descending by date, then ascending by name:

type Sorting = { field: string; direction: 'ASC' | 'DESC' }

export const format = (value: Sorting[]) =>
  value
    .map(item =>
      item.direction === 'ASC'
        ? item.field // default direction stays out of the URL
        : `${item.field}_${item.direction.toLowerCase()}`,
    )
    .join(',')

Keys live in a shared enum so hooks and route builders agree on spelling:

export enum UrlStateKey {
  Page = 'page',
  PageSize = 'pageSize',
  Sort = 'sort',
  Status = 'status',
  Tab = 'tab',
  // …
}

When a value equals its default, the setter removes it from the URL. That is condensation on write, so defaults never clutter shared links:

const setValue = (newValue?: T, replace?: boolean) => {
  const shouldClear = newValue == null || newValue === defaultValue
  const formatted = shouldClear ? undefined : format(newValue)
  updateQueryString({
    deleteQueryStringsOnUpdate,
    key,
    replace,
    value: normalizeToUndefined(formatted), // blank/absent deletes the param
  })
}

Parsing fails soft: a malformed value from a stale bookmark reverts to the default instead of rendering an error page.

Cross-param rules attach to the setter that triggers them. Changing a filter deletes page in the same navigation (one search-string write, not a second history entry):

const [statuses, setStatuses] = useArrayUrlState({
  key: UrlStateKey.Status,
  deleteQueryStringsOnUpdate: ['page'],
})

Derived hooks compose primitives into reusable behavior. Pagination exposes individual setters, the same ergonomics as two useState calls, and wires its own cross-param rule: changing pageSize deletes page, because page 7 of 100-per-page is not page 7 of 20-per-page:

export const useUrlPagination = (defaultPageSize = 100) => {
  const [page, setPage] = useNumberUrlState(UrlStateKey.Page)
  const [pageSize, setPageSize] = useNumberUrlState({
    key: UrlStateKey.PageSize,
    defaultValue: defaultPageSize,
    deleteQueryStringsOnUpdate: [UrlStateKey.Page],
  })

  return { page, pageSize, setPage, setPageSize }
}

The payoff is incremental adoption on an existing React Router app, per-param URL shaping without a global serializer policy, and cross-param rules colocated with the param that triggers them. Deep components import the same hook with no route context required.

The cost is discipline on the write side. <Link> and programmatic navigations do not inherit hook codecs automatically, so link-building drift is the failure mode I watch for. Setters and hooks agree on encoding; plain to={/posts?page=${n}} strings do not. I address that with small route-builder helpers that call the same format functions the hooks use:

export const postsListHref = (params: {
  page?: number
  status?: string
}) => {
  const search = new URLSearchParams()
  if (params.page != null && params.page !== 1) {
    search.set(UrlStateKey.Page, String(params.page))
  }
  if (params.status != null && params.status !== 'active') {
    search.set(UrlStateKey.Status, params.status)
  }
  const qs = search.toString()
  return qs ? `/posts?${qs}` : '/posts'
}

Nuqs solves the same problem with createSerializer over a shared parser map, and isolates re-renders per key the same way a careful hook stack should. Either way, the rule is the same: one encoding path for interactive updates and for links.

If you wrap useSearchParams naively, any search change re-renders every consumer. The right component-level design subscribes to one key at a time (useSyncExternalStore over the router or history, or Nuqs’s per-key isolation). React then skips the render when that key’s string is unchanged. That is an implementation choice, not a trade you inherit by putting state in hooks.

The route file is the schema

TanStack Router is the most complete expression of router-level ownership. On a greenfield app where deep links matter and many routes share filters, I would rather pay a router migration once than keep fixing link drift by hand.

Router-level search param schema Route validateSearch connects to Link, useSearch, and child route inheritance. validateSearch Zod schema on route Link search= useSearch() child route components consume; route defines
The route owns the schema; components consume it.

With Zod 4, a schema object can be passed directly to validateSearch because Zod implements Standard Schema (validateSearch: productSearchSchema instead of a hand-rolled parse function). .catch() keeps malformed URLs from interrupting the user:

const productSearchSchema = z.object({
  page: z.number().catch(1),
  sort: z.enum(['newest', 'oldest', 'price']).catch('newest'),
})

export const Route = createFileRoute('/shop/products')({
  validateSearch: productSearchSchema,
})

Child routes inherit parent search params and extend them type-safely. A component deep in the tree reads validated state via getRouteApi without importing the route module:

const routeApi = getRouteApi('/shop/products')
const { page, sort } = routeApi.useSearch()

Writes are transactional partial updates. You pass a reducer, not a full object reconstructed by hand:

navigate({
  search: (prev) => ({ ...prev, page: prev.page + 1 }),
})

Link-time URL hygiene uses search middlewares on the route. stripSearchParams removes defaults when hrefs are built; its sibling retainSearchParams carries chosen params across navigations:

export const Route = createFileRoute('/shop/products')({
  validateSearch: productSearchSchema,
  search: {
    middlewares: [stripSearchParams({ sort: 'newest', page: 1 })],
  },
})

Global serialization is swappable: JSON-first by default, or something custom at the router. TanStack’s docs show base64, and libraries like JSURL2 or Zipson slot in the same way:

const router = createRouter({
  routeTree,
  stringifySearch: stringifySearchWith((value) =>
    btoa(JSON.stringify(value)),
  ),
  parseSearch: parseSearchWith((value) => JSON.parse(atob(value))),
})
Caveat: nested params as JSON

Out of the box, nested search params serialize as percent-encoded JSON in the query string (includeCategories=%5B%22electronics%22%5D). First-level keys stay flat and URLSearchParams-compatible; nested structures become JSON blobs. That is a deliberate trade for structure over readability. stripSearchParams, custom stringifySearch, or a hand-tuned per-param codec (component-level style) are the usual ways to clean shared links up.

Schema fragmentation is structurally difficult here: parent and child routes inherit and extend, and read and write safety share one object (<Link search={…}> and navigate are typed against the same schema). Selective re-renders are available via useSearch({ select: ({ page }) => page }), though if the selector returns a fresh object each call you need structuralSharing: true (or router-level defaults) or the component still re-renders on every navigation. Global serialization policy and link middlewares live in one place.

The price is adopting TanStack Router: schemas, middlewares, and typed navigation do not port to another router or to a static site with no client router. Cross-param invalidation still lives in application code, not the schema layer. Default JSON serialization favors structure over hand-tuned readability, though it is customizable. Condensation happens at link generation via middleware rather than on setter write. Upfront investment is higher before navigation is fully typed.

Capability comparison

Question Component-level Router-level (TanStack)
Deep components, type-safe? Import hook anywhere getRouteApi(path).useSearch() or useSearch({ from })
Options without TanStack Router? A hook stack like this one, Nuqs, framework loaders + Zod N/A; adopting the router is the premise
Cross-param delete-on-update? deleteQueryStringsOnUpdate on setter Handler bundling or useEffect (below)
Pagination/sort composition? useUrlPagination, useUrlMultiSort Shared schema + thin hooks over useSearch
Per-param setters (setPage(2))? Yes; each hook returns its own setter Partial update via navigate reducer (below)

Resetting page when a filter changes

The table rows on cross-param coordination are the interesting ones. Component-level ownership puts invalidation on the setter infrastructure:

const [statuses, setStatuses] = useArrayUrlState({
  key: UrlStateKey.Status,
  deleteQueryStringsOnUpdate: ['page'],
})
setStatuses(['DRAFT']) // page cleared in same URL write

Router-level ownership keeps the schema pure. The idiomatic TanStack pattern bundles page in the same navigation:

navigate({ search: (prev) => ({ ...prev, sort: sortBy, page: 1 }) })

When params can change independently (back/forward, deep links), TanStack’s navigation guide also shows a reactive useEffect:

useEffect(() => {
  if (search.query && search.page > 1) {
    navigate({ search: (prev) => ({ ...prev, page: 1 }), replace: true })
  }
}, [search.query, search.page, navigate])

The effect covers paths the handlers miss, at the cost of a second navigation tick: the filter change lands, a render happens, then the effect corrects the page. replace: true keeps the intermediate state off the back stack.

Setter-level Handler bundling useEffect
Single URL write Yes Yes Often two
Wired once per param/hook Yes Per handler Per effect
Clears page when filter changes via UI On setter write If that handler includes it After the fact
Covers back/forward / deep-link edge cases No (URL restored as-is) No Yes
Lives in schema layer No No No

Individual setters vs reducer updates

TanStack gives you Route.useSearch() with optional select on the read side. On the write side everything routes through navigation rather than per-param setters:

const { page, pageSize } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })

const setPage = (next: number) => {
  navigate({ search: (prev) => ({ ...prev, page: next }) })
}

const setPageSize = (next: number) => {
  navigate({ search: (prev) => ({ ...prev, pageSize: next, page: 1 }) })
}

prev is the validated state; you return a patch. The call site is still navigate, not a tuple setter. A proposed useSetSearch API was closed in favor of this pattern. Component-level bakes those wrappers into derived hooks:

const PaginationControls = () => {
  const { page, setPage } = useUrlPagination()
  return <button onClick={() => setPage(page + 1)}>Next</button>
}

The reducer style has one edge the tuple style lacks: touching several params in one interaction is naturally atomic. With per-param setters, multi-param updates lean on deleteQueryStringsOnUpdate or a compound codec; calling two setters in the same event handler would race on the same query string. Each side papers over its gap with a thin wrapper.

Testing the URL bar

My testing strategy for URL state follows the ownership boundary. Both sides are testable; the style of the test is what changes.

Testing URL state at two layers Component-level integration tests vs router-level schema unit tests plus router integration. component-level router-level hook + real router click → assert location.search export parse/format for units Zod .parse() in isolation memory history + RouterProvider errorComponent on bad input
Test the URL bar, not just React state.

Component-level: hook + real router

Production tests mount components with a real React Router wrapper (Vitest browser mode + Playwright). A helper seeds the initial URL; the render helper returns the router instance so assertions target router.state.location.search after interaction:

export const queryStringHistory = (params: Record<string, string>) =>
  [`/?${new URLSearchParams(params).toString()}`]

it('clears page from URL when a filter updates', async () => {
  const { router } = renderComponent({
    routerInitialEntries: queryStringHistory({
      [UrlStateKey.Status]: 'DRAFT',
      [UrlStateKey.Page]: '3',
    }),
  })
  await userEvent.click(await screen.findByText('Update Status'))
  expect(router.state.location.search).not.toContain(UrlStateKey.Page)
})

it('removes the param when set back to its default', async () => {
  const { router } = renderComponent({
    routerInitialEntries: queryStringHistory({}),
  })
  await userEvent.click(await screen.findByText('Reset to default'))
  expect(router.state.location.search).not.toContain(UrlStateKey.Status)
})

Codec logic can be unit-tested separately by exporting parse and format from derived hooks. Invalid bookmarks fail soft by design: malformed values revert to defaults so the page stays usable. The same harness covers URL-backed UI state such as ?focused=issue-42 for a deep-linked detail panel: seed the param, open the panel, assert location.search still carries it after close and reopen.

Router-level: schema unit + router integration

TanStack supports two layers, both documented.

Fast schema tests without a DOM. These use .default() to assert shape; runtime schemas more often use .catch() so a bad bookmark falls back instead of throwing into errorComponent:

const searchSchema = z.object({
  page: z.number().int().min(1).default(1),
  sort: z.enum(['newest', 'oldest', 'price']).default('newest'),
})

it('applies defaults when params are omitted', () => {
  expect(searchSchema.parse({}).page).toBe(1)
})

it('rejects invalid sort', () => {
  expect(() => searchSchema.parse({ sort: 'invalid' })).toThrow()
})

Integration tests mount the route tree with memory history:

const router = createRouter({
  routeTree,
  history: createMemoryHistory({
    initialEntries: ['/products?sort=price&page=3'],
  }),
})
render(<RouterProvider router={router} />)
// assert validated search renders; assert errorComponent on invalid input

Invalid URL handling is configurable: Zod .catch() for silent fallbacks, or errorComponent when you want to surface malformed input. Cross-param coordination is application logic; you test the handler or effect explicitly.

Component-level Router-level
Fastest unit tests Exported parse/format Zod/schema .parse()
Integration harness Real router render helper + queryStringHistory createMemoryHistory + RouterProvider
Invalid URL behavior Fail-soft → default (by design) .catch() fallback or errorComponent
Link write safety Route-builder helpers tested alongside hooks Typed <Link> / navigate assertions

Nuqs, a third approach

Nuqs sits between the two models: hook-level ownership with shared parser maps and createSerializer for link building. It is closer to component-level ergonomics, with less route-file ceremony than TanStack. useQueryState('page', parseAsInteger) returns [page, setPage] per param. Custom codecs use the same parse/serialize type as useUrlState:

const parseAsBase64Json = createParser({
  parse: (query) => JSON.parse(atob(query)),
  serialize: (value) => btoa(JSON.stringify(value)),
})

const serialize = createSerializer({
  page: parseAsInteger,
  status: parseAsString,
})
// serialize({ page: 2, status: 'active' }) → '?page=2&status=active'

It addresses hook-scoped fragmentation without requiring a router migration. It does not provide hierarchical route schemas.

Fits when

If you are greenfield or willing to migrate, and you want hierarchical search schemas plus typed <Link> writes, adopt TanStack Router and put the schema on the route. If you are staying on React Router (or similar) and want incremental useState migration, choose between a custom hook stack and Nuqs: roll your own when per-param encodings and cross-param rules are product-specific; reach for Nuqs when shared parsers and a link serializer are enough.

Fits-when decision for URL state ownership Decision tree: adopting TanStack Router leads to router-level ownership; otherwise choose between a custom hook stack and Nuqs. url state schema Adopting TanStack Router? router-level validateSearch on route Roll your own codecs? hook stack useUrlState + helpers Nuqs library parsers + serializer yes no yes no
Three leaves, three homes for the schema.

Closing

Search params are state. The choice is where you want the schema to live, and what you are willing to enforce at that layer: typed navigation at the router, or useState-style hooks at the component, with everything else composed on top.

I would pick based on the router already in the repo, how central shareable URLs are to the product, and whether the team writes more features or more routes. You can mix approaches (route-level validation for loaders, hook-level ownership for interactive filters), but then you own the tension between two schemas and it needs documenting.

The URL bar is the same either way. The owner of the schema is not.

← All writing