Skip to content

React table URL state — filters, sort, page

See it working: the live demo — filter, sort and page it, then copy the URL: every bit of that state is in the address bar (the kit switcher too).

AdaptTable keeps the table’s state in the URL query string: search (q), pagination (page, limit), sorting (sortBy/sortDir, or sort for a multi-sort chain), row grouping (groupBy) and session aggregation choices (groupAgg), and every filter value (f_<key>). Column layout (colHide, colPin, colOrder, colW, colName) joins in when you wire useColumnLayoutUrlState, and saved views capture all of it under a name. Reloading, sharing the link, or pressing back lands on the exact same slice.

Two conventions keep URLs clean: default values are omitted, and changing search, sort, or a filter resets the page to 1.

Every table namespace AdaptTable has written carries an atv=1 marker (people.atv=1 when urlKey="people"). A link without the marker is also version 1, so links created before the marker existed keep their exact meaning. The marker remains when the other values are cleared, so an explicitly empty state is versioned too. It changes only when a future release needs a real migration; adding an optional parameter does not reinterpret existing ones.

URL state is untrusted input and recovers by policy:

  • Unknown parameters are ignored by the table and preserved on writes.
  • Malformed values fall back or are dropped independently, so one bad field cannot erase valid state.
  • When a parameter is duplicated, the first value wins.
  • An unsupported or malformed atv ignores AdaptTable’s recognized parameters for that table only. Other table namespaces and application parameters remain intact.
  • One table may carry at most 8,192 encoded characters of recognized state. Oversized incoming state is ignored for that table; a write that would cross the limit keeps the previous valid state instead.

Every recovery path produces the normal default table rather than throwing or rendering a blank result.

Two tables on one page would clobber each other’s params. Give each a namespace and every param is prefixed (people.q, orders.f_totalMin, …):

import { DataTable } from "@adapttable/mantine";
type Person = { id: string; name: string; status: string };
type Order = { id: string; ref: string; total: number };
export function Dashboard({
people,
orders,
}: {
people: Person[];
orders: Order[];
}) {
return (
<>
<DataTable
data={people}
columns={[
{ key: "name", sortable: true },
{ key: "status", filter: { type: "select", options: "auto" } },
]}
rowKey={(r) => r.id}
urlKey="people"
/>
<DataTable
data={orders}
columns={[{ key: "ref" }, { key: "total", filter: "numberRange" }]}
rowKey={(o) => o.id}
urlKey="orders"
/>
</>
);
}
// → ?people.q=avery&people.page=2&people.atv=1&orders.f_totalMin=100&orders.atv=1

The same urlKey option exists on useFrontendData, useQuerySource, useTableUrlState, useColumnLayoutUrlState, and useSavedViews for headless consumers. Omitting distinct urlKeys on shared-URL tables logs a development warning.

The URL layer is decoupled from any router via a tiny UrlStateAdapter:

interface UrlStateAdapter {
getSearch(): string; // current query string (no "?")
setSearch(search: string, opts?: { push?: boolean }): void;
subscribe(onChange: () => void): () => void;
}
  • createHistoryAdapter() — browser History API; the default (one shared instance per window via getHistoryAdapter()).
  • createMemoryAdapter(initial?) — in-memory; used for SSR, tests, and when URL sync is disabled (the table still gets fully working local state).

Pass a custom adapter as urlAdapter on any <DataTable> (the headless hooks call the option adapter).

Every router recipe is the same shape: read the current query string, and navigate to a new one. routerUrlAdapter is that shape — its RouterUrlAdapterOptions are the router’s current search and a navigate — so each router takes two lines instead of twelve — and it depends on no router, which is why it can ship at all.

Memoize it on the search: the adapter is a value, and rebuilding it is how the table learns the route changed.

import { useMemo } from "react";
import { useNavigate, useSearchParams } from "react-router-dom";
import { routerUrlAdapter } from "@adapttable/core";
export function useReactRouterAdapter() {
const [params] = useSearchParams();
const navigate = useNavigate();
return useMemo(
() =>
routerUrlAdapter({
search: params.toString(),
navigate: (search, { push }) =>
navigate({ search }, { replace: !push }),
}),
[params, navigate]
);
}
import { useMemo } from "react";
import { useNavigate, useRouterState } from "@tanstack/react-router";
import { routerUrlAdapter } from "@adapttable/core";
export function useTanStackAdapter() {
const search = useRouterState({ select: (s) => s.location.searchStr });
const navigate = useNavigate();
return useMemo(
() =>
routerUrlAdapter({
search,
navigate: (next, { push }) =>
navigate({ to: ".", search: next, replace: !push }),
}),
[search, navigate]
);
}
"use client";
import { useMemo } from "react";
import { usePathname, useRouter, useSearchParams } from "next/navigation";
import { routerUrlAdapter } from "@adapttable/core";
export function useNextAdapter() {
const searchParams = useSearchParams();
const pathname = usePathname();
const router = useRouter();
return useMemo(
() =>
routerUrlAdapter({
search: searchParams.toString(),
navigate: (search, { push }) => {
const url = search ? `${pathname}?${search}` : pathname;
if (push) router.push(url, { scroll: false });
else router.replace(url, { scroll: false });
},
}),
[searchParams, pathname, router]
);
}
<DataTable
data={data}
columns={columns}
rowKey={(r) => r.id}
urlAdapter={useNextAdapter()}
/>

push is opt-in throughout: the default is a replace, because a table’s every keystroke is not a page anyone wants to walk back through.

The adapter reports no external changes on purpose. A router re-renders its tree on navigation, so the hook holding the adapter runs again and reads the new search itself — subscribing would deliver the same change twice. The one way to hold it wrong is to pass a search that does not update, which is why it takes a value rather than a getter.

One prop: urlSync={false}. Search, sort, filters and pagination keep working identically — state just lives in memory, the address bar never changes, and any urlAdapter is ignored.

<DataTable data={data} columns={columns} rowKey={(r) => r.id} urlSync={false} />

Headless equivalent: useTableUrlState({ urlSync: false }) — handy inside modals or drawers where the address bar shouldn’t change.

Param Example Meaning
atv atv=1 AdaptTable URL-state format. Missing also means version 1; unsupported versions recover to defaults for this table only.
q q=avery Committed search term.
find find=Ada Find-bar query (in-table walk). Empty or closed deletes the param; the current-match index is never stored.
page page=3 1-based page; omitted at 1.
limit limit=50 Page size, clamped to 1–500; omitted at the default (25).
sortBy + sortDir sortBy=name&sortDir=desc Single-column sort (sortDir falls back to asc).
sort sort=name:asc,age:desc Multi-sort chain; supersedes sortBy/sortDir while present.
groupBy groupBy=team,status Ordered row-grouping column keys, outermost first. Written by groupingPanel() and omitted when no grouping is active.
groupAgg groupAgg=budget:sum,age:none Per-column session aggregation overrides: a built-in name, a host operation id, or none (explicit suppression). A missing column preserves the developer’s groupAggregates / aggregatable.default / original query.aggregates.
f_<key> f_status=active One filter value; multiSelect arrays are comma-separated with each entry percent-encoded.
f_<key>From / f_<key>To f_hiredAtFrom=2026-01-01 dateRange bounds (inclusive; the end bound keeps that whole day). A relative operator stores the token here (today, last:7) instead of a resolved day.
f_<key>Min / f_<key>Max f_salaryMin=50000 numberRange bounds (inclusive; parsed as numbers).
ft ft=1.{"combinator":"and"} Versioned AND/OR filter tree. Unknown versions are dropped, never reinterpreted.
colHide colHide=email,phone Hidden columns (keys percent-encoded).
colPin colPin=name:left Pinned columns and their side.
colOrder colOrder=name,role,salary Explicit column order.
colW colW=name:220 Per-column pixel widths.
colName colName=name:Account%20owner User display names by stable column key.

With a urlKey every param is prefixed: people.q, people.f_status, people.groupBy, people.groupAgg, people.colHide, ….

groupAgg is intentionally an override map, not a replacement aggregate configuration. Adding or changing an operation writes that column’s id; removing a developer-declared aggregate writes none so the default does not come back; removing a reader-added aggregate deletes the entry. Restore defaults clears the whole map. Keys are percent-encoded, host-defined ids survive the round trip, and equivalent maps serialize in stable column-key order. Row grouping documents the panel, column-menu, mobile, and keyboard routes that write this state.

defaults (search, sort, extra filter values) apply only while the URL is silent about a key. When the user explicitly clears a defaulted value, the hook records it as an empty-valued param (q=, f_status=) so the default does not instantly resurrect — a missing param means “default applies”, an empty one means “explicitly cleared”.

The codecs the table uses are published, so a route handler, a saved-view store, or a test can read and write the same URL without mounting a table.

parseTableUrlState(search) reads a whole query string into table state; updateTableUrlState(search, patch) returns the next query string; applyTableUrlState and captureTableUrlState move that state on and off a live table.

Each param has a named constant and, where the value is not a plain string, a reader and a writer:

Constant Reader / writer
PARAM_PAGE, PARAM_LIMIT readPage, readLimit
PARAM_SEARCH, PARAM_FIND plain strings
PARAM_SORT_BY, PARAM_SORT_DIR readSortDir; the chain is readSortLevels / writeSortLevels
PARAM_GROUP_BY, PARAM_GROUP_AGGREGATES ordered keys and the per-column overrides
PARAM_COL_HIDDEN readColumnLayout / writeColumnLayout cover the whole column layout
PARAM_DENSITY, PARAM_FORMULA, PARAM_PIVOT the density, formula-column and pivot codecs
f_<key> readExtra / writeExtra
ft readFilterTreeParam / writeFilterTreeParam
row pins readRowPins / writeRowPins

Writing a value that equals the default deletes the param instead of spelling it out, which is what keeps a shared link short.

The default History-API adapter hydrates from an empty query string (the server has no window; the real URL applies right after hydration). To server-render the exact requested slice, pass an explicit router adapter — it knows the request URL, and the hooks trust an explicit adapter to be hydration-consistent. getHistoryAdapter() itself returns a memory adapter when there is no window, so nothing crashes under SSR either way.