Lesson 6 — typed, URL-driven UI state for filters, tabs, modals
Filters, sort order, pagination, "which modal is open" — these belong in the URL, not in useState. TanStack Router makes search params first-class and fully typed: declare a schema per route, read with a hook, write with navigation. Share the URL, refresh the page, hit back — the state survives all of it.
useStateState in useState dies on refresh, can't be shared, can't be deep-linked. URL state survives everything:
If the user would ever want to share or return to a UI state, it goes in the URL.
validateSearch — the schemaEvery route can declare a validateSearch function. It receives raw parsed params and returns a typed object. The returned shape becomes the type of search everywhere downstream — components, child routes, loaders.
// routes/_app/(tabs)/feed/index.tsx
type SortOption = 'newest' | 'oldest' | 'top'
type FeedSearch = {
page: number
filter: string
sort: SortOption
}
export const Route = createFileRoute('/_app/(tabs)/feed')({
validateSearch: (raw: Record<string, unknown>): FeedSearch => ({
page: Number(raw?.page ?? 1),
filter: (raw.filter as string) || '',
sort: (raw.sort as SortOption) || 'newest',
}),
component: FeedList,
})
FeedSearch type becomes the type of search in this route's component and in every child route. One schema, type-safe everywhere below it in the tree.
Zod collapses validation + typing into one declaration. Pass the schema directly:
import { z } from 'zod'
const feedSearchSchema = z.object({
page: z.number().catch(1),
filter: z.string().catch(''),
sort: z.enum(['newest', 'oldest', 'top']).catch('newest'),
})
export const Route = createFileRoute('/_app/(tabs)/feed')({
// Pass the schema object directly — TanStack calls .parse for you
validateSearch: feedSearchSchema,
})
.catch(), not .default()
Malformed search params are common (typos, old shared links). .default() throws on bad input — your user sees an error page. .catch() silently substitutes a fallback. For search params, always .catch(): never halt the UX for a malformed query string.
Three ways, same data:
// 1. Inside this route's component — typed hook on Route
function FeedList() {
const { page, filter, sort } = Route.useSearch()
// ^? { page: number, filter: string, sort: SortOption }
return <>...</>
}
// 2. Outside this route (e.g. a sidebar component) — useSearch with from
import { useSearch } from '@tanstack/react-router'
const { page } = useSearch({ from: '/_app/(tabs)/feed' })
// 3. Loosen typing when route is unknown
const search = useSearch({ strict: false })
// ^? { page?: number, filter?: string, ... }
For deep components in a code-split tree, use getRouteApi('/...') instead of importing the Route object — avoids circular deps.
Writing is a navigation. Use navigate({ search }) or <Link search>. The search prop is a function of previous — never throw away params you didn't touch:
import { useNavigate } from '@tanstack/react-router'
function FeedControls() {
const navigate = useNavigate({ from: Route.fullPath })
return (
<>
{/* Pagination: only touches `page`, preserves filter + sort */}
<button onClick={() => navigate({
search: (prev) => ({ ...prev, page: prev.page + 1 })
})}>
Next page
</button>
{/* Via Link: same function form */}
<Link
to="/_app/(tabs)/feed"
search={(prev) => ({ ...prev, sort: 'top' })}
>
Sort by top
</Link>
</>
)
}
prev
The search updater replaces the entire search object. Spreading ...prev first preserves untouched params. Forgetting it silently wipes filter when you change page.
| Concern | Next.js | TanStack |
|---|---|---|
| Read query | useSearchParams() (untyped URLSearchParams) | Route.useSearch() (typed) |
| Write query | router.push('?page=2') (string concat) | navigate({ search: prev => ... }) (typed fn) |
| Schema | manual parsing, manual types | validateSearch + Zod |
| Type safety | none by default | end-to-end, derived from schema |
The shift: Next.js treats search params as a raw string you parse manually. TanStack treats them as a typed object you declare a schema for, then read and write with hooks — invalid values never reach your component.
The Feed screen needs filter UI: a text filter input, a sort dropdown (newest | top), and pagination. All state in the URL. Build:
validateSearch schema (Zod, all params with .catch() fallbacks)filter, sort, page via Route.useSearch()filter and resets page to 1Sketch mentally, then reveal.
import { z } from 'zod'
import { createFileRoute, useNavigate } from '@tanstack/react-router'
const feedSearch = z.object({
filter: z.string().catch(''),
sort: z.enum(['newest', 'top']).catch('newest'),
page: z.number().catch(1),
})
export const Route = createFileRoute('/_app/(tabs)/feed')({
validateSearch: feedSearch,
component: FeedList,
})
function FeedList() {
const { filter, sort, page } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
return (
<div>
<input
value={filter}
onChange={(e) => navigate({
// Reset page to 1 when filter changes — fresh result set
search: (prev) => ({ ...prev, filter: e.target.value, page: 1 })
})}
/>
<select
value={sort}
onChange={(e) => navigate({
search: (prev) => ({ ...prev, sort: e.target.value })
})}
>
<option value="newest">Newest</option>
<option value="top">Top</option>
</select>
<span>Page {page}</span>
</div>
)
}
Why this works:
.catch() means malformed URLs fall back gracefully — no error page.search: prev => ... form preserves unrelated params (changing sort doesn't wipe filter).page to 1 — old page numbers are meaningless for a new filter.validateSearch is a route option. Its return type becomes the typed search for this route and all children..catch() over .default()?.default() throws on bad input (error page); .catch() substitutes a fallback silently. Never halt UX for a bad query string.Route.useSearch() returns the typed, validated search object. Outside the route, use useSearch({ from }).navigate({ search: prev => ... }) or <Link search>. No separate setter — the URL is the store.prev in the search updater?...prev, untouched params (like filter) get silently wiped when you change page.search.useSearch({ from: '/...' }) reads any route's typed search from anywhere. For code-split trees, getRouteApi avoids circular imports.useSearch({ strict: false }) return?T | undefined. Useful when the origin route is unknown at write time.useSearchParams() returns?URLSearchParams — untyped, manual parsing. TanStack returns a typed object derived from validateSearch.page to 1. Spread ...prev, override filter and page together.useState, how to share a filter state across routes, or how to pipe search params into a loader (coming in Lesson 7).
Primary source:
TanStack Router — Search Params (Validating, Reading, and Writing sections). Read after the quiz to see the full validateSearch + Zod integration.