Syncing TanStack Table Filters & Sorting to URL Search Params — URL State Sync Patterns in Practice
If you've built admin dashboards, you've probably run into this situation. The operations team asks, "We need to check this filter combination every morning — can you make it bookmarkable?" The QA team messages you on Slack saying "Here's how to reproduce the bug," then sends five screenshots. I started out managing filter state with useState and backing it up to localStorage, but I kept running into the same problems: filters couldn't be shared, and opening a new tab would reset everything.
The real solution is to make the URL itself the state store. Copy one URL, send it to a colleague, and they open it with the exact same filters, sorting, and pagination intact. Whether you refresh, open it in a new tab, or share it on Slack — the same view is restored every time.
This article is aimed at readers with basic experience using TanStack Table v8 and TanStack Router v1. It skips installation and initial setup, focusing instead on the URL sync pattern. Topics covered include the full flow for connecting filter, sorting, and pagination state to URL search params; server-side integration with TanStack Query; and the pitfalls you'll commonly encounter in production.
Core Concepts
The URL Is the Owner, Components Are Guests
In the traditional approach, useState or useReducer owns the table state. It starts with an initial value when the component mounts, and disappears when it unmounts. The URL sync pattern inverts this relationship.
The flow of events proceeds in this order:
- User changes a filter
onColumnFiltersChangecallback firesnavigate()updates the URLuseSearch()returns the new value- Table re-renders
Once the URL becomes the sole state store, bookmarking, sharing, refresh-persistence, and back-button behavior all follow naturally.
Single Source of Truth: A design principle where a given piece of state is managed in exactly one place. Having multiple copies leads to sync problems; using the URL as the SSOT means the URL itself becomes the application state.
Why TanStack Router's Search Params Are Different
Traditional React Router or Next.js useSearchParams treat search params as plain strings. You have to parse ?page=2&sort=name yourself, and to store complex objects you'd have to call JSON.stringify manually.
TanStack Router treats search params as first-class citizens. You declare types with a Zod schema at route definition time, and the useSearch() hook returns an already-parsed, typed object. Even if someone accesses a malformed URL, it's handled safely using a fallback value.
// Based on @tanstack/zod-adapter v1 (package name may differ by version)
import { z } from 'zod'
import { createFileRoute } from '@tanstack/react-router'
import { zodValidator, fallback } from '@tanstack/zod-adapter'
const tableSearchSchema = z.object({
columnFilters: z
.array(z.object({ id: z.string(), value: z.unknown() }))
.default([]),
sorting: z
.array(z.object({ id: z.string(), desc: z.boolean() }))
.default([]),
pageIndex: fallback(z.number().int().nonnegative(), 0),
pageSize: fallback(z.number().int().positive(), 20),
})
export const Route = createFileRoute('/products')({
validateSearch: zodValidator(tableSearchSchema),
})fallback(): When a URL parameter fails schema validation, this prevents the app from crashing and uses the specified default value instead. It's a safety net against arbitrary URL manipulation from outside the app.
Controlled State and manualFiltering
By default, TanStack Table manages its own state internally (uncontrolled). To sync with the URL, you need to switch to controlled mode, where state is driven externally. You inject external values via the state option and update the URL inside the on*Change callbacks.
There are three key patterns:
import {
useReactTable,
getCoreRowModel,
functionalUpdate,
} from '@tanstack/react-table'
import { useNavigate } from '@tanstack/react-router'
// Common pattern for on*Change handlers
onColumnFiltersChange: (updater) => {
const next = functionalUpdate(updater, columnFilters)
navigate({
search: (prev) => ({ ...prev, columnFilters: next, pageIndex: 0 }),
replace: true,
})
},functionalUpdate: The
updaterin anon*Changecallback can be either a new value directly, or a function that takes the previous state and returns the new state.functionalUpdatehandles both cases. Without it, you'll occasionally get a bug where the previous state overwrites the current one.
There's an important choice here. Whether or not you use manualFiltering: true affects the entire architecture.
manualFiltering: true(server-side filtering): The table only stores filter state in the URL; actual filtering is delegated to the server, which reads the filter params from the URL and runs the query.manualFilteringnot set (client-side filtering): The table filters thedataarray directly. URL sync still works, but combining this with server data causes a double-filtering problem — the server filters once, then the client filters again. Filtering already-filtered server data a second time on the client can produce unexpected results, so be careful.
Practical Application
Example 1: Server-Side Filtering Combined with TanStack Query
When your filter and sorting state lives in the URL, you can use it directly to drive server data fetching. Include the search params in TanStack Query's queryKey, and every URL change automatically triggers a refetch.
import { useQuery } from '@tanstack/react-query'
import { useNavigate } from '@tanstack/react-router'
import {
useReactTable,
getCoreRowModel,
functionalUpdate,
type ColumnDef,
} from '@tanstack/react-table'
// Separate data fetching logic into a custom hook
function useProductsQuery() {
const { columnFilters, sorting, pageIndex, pageSize } = Route.useSearch()
return useQuery({
// Include search params in queryKey — URL changes trigger automatic refetch
queryKey: ['products', { columnFilters, sorting, pageIndex, pageSize }],
queryFn: () =>
fetchProducts({
filters: columnFilters,
sorting,
page: pageIndex,
limit: pageSize,
}),
placeholderData: (prev) => prev, // Show previous data instead of a blank screen during page transitions
})
}
interface Product {
id: string
name: string
status: string
}
const columns: ColumnDef<Product>[] = [
// ... column definitions
]
function ProductTable() {
const { data, isLoading, isFetching } = useProductsQuery()
const { columnFilters, sorting, pageIndex, pageSize } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
const table = useReactTable({
data: data?.items ?? [],
columns,
rowCount: data?.total ?? 0,
getCoreRowModel: getCoreRowModel(),
state: {
columnFilters,
sorting,
pagination: { pageIndex, pageSize },
},
manualFiltering: true,
manualSorting: true,
manualPagination: true,
onColumnFiltersChange: (updater) => {
const next = functionalUpdate(updater, columnFilters)
navigate({
search: (prev) => ({ ...prev, columnFilters: next, pageIndex: 0 }),
replace: true,
})
},
onSortingChange: (updater) => {
const next = functionalUpdate(updater, sorting)
navigate({
search: (prev) => ({ ...prev, sorting: next }),
replace: true,
})
},
onPaginationChange: (updater) => {
const next = functionalUpdate(updater, { pageIndex, pageSize })
navigate({
search: (prev) => ({
...prev,
pageIndex: next.pageIndex,
pageSize: next.pageSize,
}),
replace: true,
})
},
})
return (
<div>
{isFetching && <div className="loading-indicator">Loading...</div>}
{/* Table rendering */}
</div>
)
}| Code Point | Description |
|---|---|
queryKey: ['products', { ... }] |
Creates a separate cache entry per filter combination — navigating back instantly restores from cache |
placeholderData: (prev) => prev |
Shows previous data during page transitions instead of a blank screen |
pageIndex: 0 reset |
A natural UX pattern that returns to page 1 whenever filters change |
replace: true |
Avoids polluting the history stack so the back button behaves as expected |
Example 2: Applying Debouncing to Text Filters
If you tie a text input filter directly to the URL, every keystroke changes the URL and history accumulates too quickly. Honestly, I did this myself the first time and ended up with a completely useless back button. The natural approach is to manage only the displayed value in local state, and write the actual filter state to the URL after debouncing.
import { useState, useEffect } from 'react'
import { useDebounce } from 'use-debounce' // or implement your own
function DebouncedFilterInput({
columnId,
placeholder,
}: {
columnId: string
placeholder: string
}) {
const { columnFilters } = Route.useSearch()
const navigate = useNavigate({ from: Route.fullPath })
// Read this column's current filter value from the URL
const currentValue =
(columnFilters.find((f) => f.id === columnId)?.value as string) ?? ''
// Manage input value in local state (responds immediately to typing)
const [inputValue, setInputValue] = useState(currentValue)
const [debouncedValue] = useDebounce(inputValue, 300)
// Only update the URL when the debounced value changes
useEffect(() => {
if (debouncedValue === currentValue) return
navigate({
search: (prev) => ({
...prev,
columnFilters: debouncedValue
? [
...prev.columnFilters.filter((f) => f.id !== columnId),
{ id: columnId, value: debouncedValue },
]
: prev.columnFilters.filter((f) => f.id !== columnId),
pageIndex: 0,
}),
replace: true,
})
}, [debouncedValue, currentValue, navigate, columnId])
// ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
// satisfies exhaustive-deps + debouncedValue === currentValue guard prevents loops
return (
<input
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder={placeholder}
/>
)
}Why keep a separate local state? Tying the input field entirely to the URL means it won't respond to input during the 300ms debounce window, making for an awkward UX. The separation of concerns is: local state handles what the input displays, the URL handles what filter is actually applied.
Pros and Cons
Advantages
| Item | Details |
|---|---|
| Bookmarking & sharing | A single URL fully reproduces the filter and sorting state — share a Slack link to give anyone the exact same view |
| Refresh durability | State is preserved without localStorage or sessions |
| Back button support | In push mode, the browser history can restore previous filter states |
| SSR-friendly | The server can prefetch initial data from URL parameters, improving first-load performance |
| Analytics tracking | Tools like GA and Amplitude can track user filter behavior from the URL alone |
| Type safety | TanStack Router + Zod enables compile-time type validation |
Disadvantages and Caveats
| Item | Details | Mitigation |
|---|---|---|
| Infinite loop risk | A URL change triggers useSearch to re-run; if the on*Change handler then navigates with a new reference to the same value, a loop occurs |
Use replace: true + functionalUpdate pattern; add a guard condition (if debouncedValue === currentValue) |
| URL length limit | Browsers impose limits of ~2,000–8,000 characters — complex filter objects can hit this ceiling | Consider storing complex filters server-side and keeping only a filter ID in the URL |
| Serialization limitations | Date, RegExp, and nested objects lose type information during JSON serialization |
Use Zod's z.coerce.date() or transform, and defend with fallback |
| History stack | In push mode, history accumulates with every keystroke |
Text filters require the debouncing + replace: true combination |
To be more specific about when infinite loops occur: when the on*Change handler calls navigate, creating a new array or object causes useSearch to detect a different reference, triggering a re-render, which fires the handler again — completing the cycle. The functionalUpdate + replace: true combination breaks this cycle.
replace vs push:
navigate({ replace: true })replaces the current history entry; the default (push) adds a new one. For state that changes rapidly — like filters and sorting —replace: trueis natural. For navigation where the back button should be meaningful — like moving between pages — push mode makes more sense.
The Most Common Mistakes in Practice
-
The temptation to put every piece of state in the URL — UI state like column widths, modal open/close, or drag-in-progress row indices generally shouldn't go in the URL. The URL should only hold state where bookmarking and sharing are meaningful.
-
Using the updater directly without
functionalUpdate— If you don't check whether theon*Changecallback argument is a value or a function before using it, you'll occasionally get a bug where the previous state overwrites the current one. It's always safer to go through TanStack Table'sfunctionalUpdate. -
Not resetting the page index when filters change — If a user changes filters on page 3 and still sees page 3 results, it's confusing. You need the pattern of also updating
pageIndex: 0insideonColumnFiltersChange.
Closing Thoughts
After applying this pattern, what struck me most was how much functionalUpdate and fallback() quietly prevent edge cases. At first I wondered, "Do we really need this?" — but after experiencing URL manipulation from outside the app, and cases where the updater arrived as a function, I immediately understood why they exist. The core of this pattern is designing the URL to act as the single state store, and functionalUpdate and fallback are the mechanisms that keep that design safe.
If you're starting right now, this order has the lowest friction:
-
Start with the route schema — Add a single
validateSearch: zodValidator(...)line to an existing route file, and begin with a small schema that only holdssortingandpageIndex/pageSize. You can attach a more complex filter schema after that. -
Wire up just one sort — Inject the value from
useSearch()intostate.sortinginuseReactTable, and changeonSortingChangeto callnavigate(). You'll immediately see the URL change when you click a column header. -
Connect TanStack Query — Include the search params object in
queryKey, and the flow of automatically re-fetching server data whenever the URL changes is complete. From this point on, you can share filter state via a single URL.
If you prefer a library over a hand-rolled implementation, tanstack-table-search-params abstracts this pattern into a hook. It also supports Next.js Pages/App Router and React Router.
Next article: Using TanStack Router's
loaderDepsandloaderto prefetch server-side initial data from URL search params, combined with TanStack Query'sstaleTimeto build an SSR-friendly data grid
References
- Search Params Guide | TanStack Router
- Validate Search Params with Schemas | TanStack Router
- Navigate with Search Parameters | TanStack Router
- Column Filtering Guide | TanStack Table
- Sorting Guide | TanStack Table
- Query Router Search Params Example | TanStack Table
- Search Params Are State | TanStack Blog
- tanstack-table-search-params | GitHub — an option if you'd rather use a library than build it yourself
- Building a Library to Sync TanStack Table State with URL Parameters | DEV Community — a write-up on turning this same approach into a library
- Advanced React state management using URL parameters | LogRocket
- TanStack Router: Query Parameters & Validators | Leonardo Montini