Tables · Open source
Table URL State
Keep a TanStack Table v9 table's sorting, column filters, global filter, pagination and column visibility in the URL search params, in both directions, and export the current view to CSV or, from a separate entry point, to XLSX.
What it solves
TanStack Table v9 keeps its state in memory.
Reload the page, share the link or press back, and the sorting, filters and page are gone. Wiring each slice to the search params by hand means writing a URL format, leaving defaults out, debouncing filter typing, choosing push or replace, and stopping Table's automatic return to page 1 from overwriting a page that came from a link. Exporting what the user filtered down to is a second job on top of that.
useTableUrlState() returns tableOptions to spread into useTable: the state and onChange handlers for sorting, column filters, global filter, pagination and column visibility. Table changes are written to the URL and URL changes (back/forward, links) are read back into the table. Params are short (sort=-total,customer, f.status=paid, q=acme, page=3, size=50, hide=email), values equal to the defaults are left out, filter typing waits 300 ms before it reaches the URL, and history entries are replaced unless you ask for push. Without a router it uses window.history and URLSearchParams; the /router entry adds a TanStack Router adapter that writes through router.navigate, plus a validateSearch helper. toCsv and downloadCsv export the filtered and sorted rows of every page in the visible data columns by default, with RFC 4180 quoting and formula escaping. The /xlsx entry writes a typed .xlsx file with its own small writer and fflate; load it with a dynamic import() so it stays out of the main bundle. Tested with React, TanStack Table v9 and TanStack Router 1.170 on Vite 8, in a real browser and from a fresh npm install. An independent package, not made or endorsed by TanStack.
- useTableUrlState(): two-way sync of sorting, column filters, global filter, pagination and column visibility with the URL search params
- Short, readable params (sort, q, f.<columnId>, page, size, hide); names can be renamed with keys or prefixed with prefix for two tables on one page
- Values equal to the defaults are left out of the URL, a cleared non-empty default filter is written empty (q=, f.status=) so it stays cleared, and malformed params fall back to the defaults instead of throwing
- Filter edits are debounced (300 ms by default); sorting, paging and visibility are written at once; replace by default, push on request
- Keeps a linked page on back/forward and late data loads: turns off Table's autoResetPageIndex and returns to page 1 itself when the user sorts or filters
- No-router history adapter (window.history + URLSearchParams), a TanStack Router adapter with validateTableSearch() in /router, and a UrlAdapter interface for anything else
- CSV export of the current view: filtered and sorted rows across all pages, visible data columns, formatters, RFC 4180 quoting and formula escaping
- XLSX export in a separate /xlsx entry: numbers, booleans and dates as typed cells, bold frozen header row, column widths; zipped with fflate
- ESM only, marked side-effect free; /core has no React import; one runtime dependency (fflate); Node 20 or later
Install
Requires TanStack Table v9 (React).
npm install @amjadiqbal/tanstack-table-url-state
Usage
import { useTable } from '@tanstack/react-table';
import { useTableUrlState, downloadCsv } from '@amjadiqbal/tanstack-table-url-state';
function Orders({ data }: { data: Order[] }) {
// ?sort=-total&f.status=paid&q=acme&page=3&hide=placed
const { tableOptions, reset } = useTableUrlState({
defaults: { pagination: { pageIndex: 0, pageSize: 25 } },
});
const table = useTable({ features, columns, data, ...tableOptions });
const exportXlsx = async () => {
const { downloadXlsx } = await import('@amjadiqbal/tanstack-table-url-state/xlsx');
downloadXlsx(table, { filename: 'orders.xlsx', sheetName: 'Orders' });
};
// <button onClick={() => downloadCsv(table, { filename: 'orders.csv' })}>CSV</button>
// <button onClick={exportXlsx}>XLSX</button>
// <button onClick={reset}>Reset</button>
}
Setup
Peer dependencies are react >=18 and @tanstack/react-table >=9.0.0 <10. @tanstack/react-router >=1.120 is an optional peer, needed only if you import the /router entry. The one runtime dependency, fflate, is installed with the package and is imported only by the /xlsx entry; the XLSX code is loaded on demand when your app imports that entry with a dynamic import(), which bundlers split into its own chunk. The package is ESM only and needs Node 20 or later.
- 1 npm install @amjadiqbal/tanstack-table-url-state
- 2 Optional, for the Router adapter: npm install @tanstack/react-router
- 3 Register the v9 features you need (rowSortingFeature, columnFilteringFeature, globalFilteringFeature, rowPaginationFeature, columnVisibilityFeature) and their row models as usual.
- 4 Call useTableUrlState() and spread tableOptions into useTable({ features, columns, data, ...tableOptions }).
- 5 If the table has state of its own, merge it: state: { ...tableOptions.state, rowSelection }.
URL format
Each slice has its own search param. The default names are sort, q, page, size, hide and f.<columnId> for column filters. keys renames any of them, including the column filter prefix, and prefix puts a string in front of every name, so prefix: 'orders.' gives orders.sort, orders.page and orders.f.status. Params that belong to other code are left alone.
- Sorting: sort=-total,customer, comma-separated column ids in order, - for descending.
- Global filter: q=acme. Column filter: f.status=paid, one param per column.
- Filter values that are plain strings are written as they are. Anything else is JSON (f.total=[100,5000]), and a string that would read back as another type is quoted (f.code="42").
- Pagination: page is 1-based (page=3 is pageIndex 2); size is the page size.
- Column visibility: hide lists the hidden column ids, sorted alphabetically. Commas and % in column ids are escaped in sort and hide.
- Defaults: a slice equal to its default is removed from the URL. Built-in defaults are no sorting, no filters, page 1, page size 10 and every column visible; defaults overrides them and is also the starting state, so set pagination.pageSize there if your table uses another size.
- If globalFilter or a column filter has a non-empty default and the user clears it, the param is written empty (q=, f.status=), so a reload or a shared link keeps it cleared instead of bringing the default back.
- Invalid params do not throw. page and size must be whole numbers of 1 or more or they are ignored. Values read from the URL are capped by maxPage (default 100000) and maxPageSize (default 1000), both hook options. The page is not clamped to the page count, since the URL does not know how many rows there are: a page past the end shows no rows unless your code moves the user back once the data has loaded. Empty sort entries are dropped. A filter value that starts like JSON but does not parse is kept as a plain string. Column ids in the URL are not checked against the table's columns.
How syncing works
The hook keeps the five slices in React state, passes them to Table as controlled state, and compares that state with the URL after every change. Only the params that changed are patched.
- History: replace by default, so the back button is not flooded; history: 'push' adds an entry per change. The history adapter skips the write when the URL would not change.
- Debounce: a change that touches only the global or column filters (plus the return to page 1 it causes) waits debounceMs, 300 by default, and each keystroke restarts the wait. Sorting, paging, page size and visibility changes are written at once, together with any filter still waiting. debounceMs: 0 writes every change at once.
- A filter still waiting when the component unmounts is dropped, so it is not written onto the next route.
- Back/forward and links: the history adapter listens for popstate, its own writes and notifyUrlChange(); the Router adapter listens for the router's onResolved event. The URL is then decoded back into table state.
- Page resets: when pagination is synced, tableOptions sets autoResetPageIndex: false and the hook returns to page 1 itself when the user changes sorting or a filter. A page that comes from a link, back/forward or data that loads after mount is kept. Pass your own autoResetPageIndex after the spread to get Table's behaviour back.
- slices: ['sorting', 'pagination'] syncs only some slices; the others are left out of tableOptions so Table manages them.
- reset() returns every slice to the defaults and removes this table's params, leaving other params in place.
useTableUrlState({
prefix: 'orders.',
keys: { globalFilter: 'search' },
defaults: {
pagination: { pageIndex: 0, pageSize: 25 },
columnVisibility: { internalNotes: false },
},
debounceMs: 300,
history: 'replace',
});
TanStack Router
useTanStackRouterAdapter() from the /router entry reads router.state.location.search and writes with router.navigate({ to: '.', search, replace, resetScroll: false }), so useSearch() and anything else reading the router's search sees every change. It starts from the raw location search, so params a strict validateSearch drops are not lost, and other params are kept. The router parses search values as JSON, so ?page=3 arrives in useSearch() as the number 3, and the router writes the query string with its own search serialiser.
- validateTableSearch(options) returns a validateSearch function that keeps only this table's params. Compose it with your own schema, and pass it the same prefix, keys and slices as the hook.
- For server-side data, read the same params in your loader, or decode them with decodeState from the /core entry.
import { useTanStackRouterAdapter, validateTableSearch } from '@amjadiqbal/tanstack-table-url-state/router';
const tableSearch = validateTableSearch();
export const Route = createFileRoute('/orders')({
validateSearch: (search) => ({ ...tableSearch(search), ...mySchema.parse(search) }),
component: Orders,
});
function Orders() {
const adapter = useTanStackRouterAdapter();
const { tableOptions } = useTableUrlState({ adapter });
// ...
}
Without a router, and other routers
With no adapter the hook uses createHistoryAdapter(): it reads window.location.search with URLSearchParams and writes with history.replaceState or history.pushState, keeping history.state and the hash. It reacts to popstate and to its own writes. history.pushState and replaceState calls from other code fire no event, so call notifyUrlChange() after them and the tables read the URL again. On the server it reads nothing and writes nothing, so the first server render uses the defaults.
- Any other router can be plugged in with a UrlAdapter: read() returns the params as strings, write(patch, { history }) sets or deletes each key (undefined means delete), subscribe(listener) returns an unsubscribe function.
- The /core entry (encodeState, decodeState, diffParams, resolveCodec, the history adapter, notifyUrlChange and the UrlAdapter type) has no React import. The main entry re-exports all of it.
CSV export
toCsv(table, options) returns the text and downloadCsv(table, options) saves it in the browser (default file name export.csv). Values come from row.getValue(columnId), the accessor value, not the rendered cell; use format or formatters to change them.
- rows: 'filtered' (default) is every row that passes the filters, in sorted order, across all pages. 'page' is only the rows on the current page. 'all' is the core rows, ignoring filters and sorting.
- columns: by default the visible leaf columns that have an accessor, so display columns such as checkboxes and actions are skipped. Passing column ids exports those columns in that order, hidden ones included; unknown ids are skipped.
- Header: a string header, otherwise the column id; header: (column) => string overrides it.
- format applies to every column; formatters[columnId] applies to one and wins over format.
- Cell text: null and undefined are empty, dates are ISO strings in UTC, objects and arrays are JSON.
- Quoting: a field is quoted when it contains a double quote, CR, LF or the delimiter, and quotes are doubled (RFC 4180). delimiter defaults to , and newline to CRLF.
- escapeFormulas (on by default) puts a leading ' in front of text that starts with =, +, -, @, a tab or a CR, so spreadsheets do not run it as a formula. Values of type number are left alone, so negative numbers stay numbers; a numeric string such as "-5" is prefixed.
- bom: true starts the file with a UTF-8 byte-order mark for Excel; it is off by default.
downloadCsv(table, {
filename: 'orders.csv',
rows: 'filtered',
formatters: { total: (v) => (v as number).toFixed(2) },
bom: true,
});
XLSX export
toXlsx(table, options) returns the file bytes and downloadXlsx(table, options) saves it (default file name export.xlsx). It takes the same rows, columns, header, format and formatters options as CSV, plus sheetName. The workbook XML is written by the package itself and zipped with fflate, a regular dependency imported only by this entry. The main entry does not import it; it is loaded lazily when your app imports /xlsx with a dynamic import().
- One worksheet. sheetName defaults to Sheet1; the characters [ ] : * ? / \ are replaced with spaces and the name is cut to 31 characters.
- Finite numbers and bigints become number cells, booleans boolean cells, dates date-time cells (yyyy-mm-dd hh:mm:ss) computed from the UTC time.
- Strings and JSON for objects are written as inline text cells, never as formulas, so no formula escaping is applied. Control characters that XML cannot hold are removed.
- Empty strings, null, undefined, NaN, Infinity and invalid dates leave the cell empty.
- The header row is bold and frozen, and column widths follow the longest value, from 8 to 60 characters.
const { downloadXlsx } = await import('@amjadiqbal/tanstack-table-url-state/xlsx');
downloadXlsx(table, { filename: 'orders.xlsx', sheetName: 'Orders', rows: 'page' });
Moving from tanstack-table-search-params
tanstack-table-search-params was written for TanStack Table v8. The options map across as follows.
- useTableSearchParams(router) becomes useTableUrlState({ adapter }); spread tableOptions into useTable.
- paramNames becomes keys (or prefix for several tables), defaultValues becomes defaults, enabled becomes slices, debounceMilliseconds becomes debounceMs (filters only).
- The URL format is different (sort=-total instead of JSON), so old bookmarks fall back to the defaults.
Compatibility and testing
Tested with React, TanStack Table v9 and TanStack Router 1.170 on Vite 8, in a real browser and from a fresh npm install. Not tested: TanStack Start / SSR, React Router, Next.js, non-React frameworks, and opening the XLSX file in Excel itself. Table URL State is an independent package and is not made or endorsed by TanStack.
- Peer ranges: react >=18, @tanstack/react-table >=9.0.0 <10, and optionally @tanstack/react-router >=1.120.0.
- 56 Vitest tests in jsdom cover the codec (round trips, defaults, prefixes, malformed input), the hook with a real useTable from @tanstack/react-table 9.3.1 and window.history, the Router adapter with a real TanStack Router 1.170 router, back/forward and deep links that keep the page, clearing non-empty defaults (also through the Router adapter), the page and page-size caps, notifyUrlChange() after an outside pushState, CSV quoting and formula escaping, and the XLSX file structure.
- In the browser: sorting, paging, debounced search, column filters, visibility, page size, reload, deep links, back/forward, reset(), useSearch() values, two prefixed tables with push history, and CSV and XLSX downloads, with the XLSX chunk loading only on click.
- The exported XLSX was opened with openpyxl, not with Microsoft Excel.
- TanStack Start and server rendering, React Router, Next.js and Solid, Vue or Svelte have no tests. Without the Router adapter the history adapter reads nothing on the server, so the first render uses the defaults.
Hire me
Need this built into your app, or something custom?
I wrote Table URL State. I can wire it into your TanStack app, extend it for your case, or build the feature around it. Tell me what you need and you'll get a plan and a quote, free.
Support
Found a bug or want a feature? Open an issue on GitHub. For help adding it to your own project, use the quote form above.
More TanStack packages
All packages
Query Key Kit
Declare your TanStack Query v5 keys once as a typed tree, get queryOptions()-shaped objects back, and invalidate or update whole branches of the cache with typed helpers.
Start SEO Kit
Typed SEO helpers for TanStack Start: one head() call for title, canonical, Open Graph, Twitter, robots and hreflang tags, a sitemap and robots.txt served from server routes, typed JSON-LD with Product schema, and an optional Open Graph image route.
AI SDK Bridge
Use Vercel AI SDK provider models (@ai-sdk/* and community providers, provider spec v2, v3 and v4) as TanStack AI 1.x adapters for chat, embeddings, transcription, speech, image generation and rerank.
shopify-app-tanstack-start
Build embedded Shopify apps on TanStack Start with the server API of Shopify's React Router package: session tokens and token exchange, webhook and app proxy verification, the mandatory compliance webhooks and the Billing API, as Start middleware, server functions and server routes.