URL state & persistence
Make a table's sort, page, and filters shareable via URL and durable across reloads.
The problem
A table's sort/filter/page state normally lives in React state — refresh the
page, or send the link to a teammate, and it's gone. You want two related but
distinct things: the current view shareable (encoded in the URL so a
pasted link reproduces it) and durable (remembered on this device even
without a URL, e.g. plain localhost:3000/users should reopen to whatever
view you left it in).
The quick path
For the common case, useTable (and useServerTable / useQueryTable /
useInfiniteTable) do both automatically via the persist* and syncUrl
options:
useTable({
data,
columns,
persist: 'localStorage',
persistKey: 'users-table', // unique per table
syncUrl: true,
})See useTable and
Persistence & URL sync for the full option
shapes. If that covers your case, stop there — the rest of this guide is for
when you need manual control over how state round-trips: custom routers,
combining a URL-first / storage-fallback strategy, or state you're managing
outside useTable (e.g. the useServerTable pattern from
Server-side data).
The solution: manual control
Four utilities back the options above, exported for direct use:
parseURLState(keys)— reads the current URL search params, returns only the state slices present in the URL.writeURLState(state, keys, mode?)— writes state back to the URL viahistory.replaceState(default) orpushState.loadPersistedState(storage, key, options?)— reads previously saved state fromlocalStorage/sessionStorage.savePersistedState(storage, key, state, options?)— writes state to storage.
Note the argument order: both persistence functions take storage and key
first, then the state/options.
Walkthrough: URL first, storage as fallback
The URL should win when present (that's what makes a link shareable); when there's no URL state, fall back to whatever was last persisted on this device. Read both once on mount, merge them, and write to both on every change:
'use client'
import { useMemo, useState } from 'react'
import { useTable } from '@marvinackerman/tablecraft'
import {
resolveURLKeys,
parseURLState,
writeURLState,
loadPersistedState,
savePersistedState,
} from '@marvinackerman/tablecraft'
import type { SortingState, PaginationState } from '@tanstack/react-table'
const STORAGE_KEY = 'users-table'
function UsersTable({ data, columns }) {
const keys = useMemo(() => resolveURLKeys(), [])
// URL wins over persisted storage; persisted storage wins over defaults.
const [initial] = useState(() => ({
...loadPersistedState('localStorage', STORAGE_KEY),
...parseURLState(keys),
}))
const [sort, setSort] = useState<SortingState>(initial.sorting ?? [])
const [pagination, setPagination] = useState<PaginationState>(
initial.pagination ?? { pageIndex: 0, pageSize: 20 },
)
const persistAndSync = (next: { sorting?: SortingState; pagination?: PaginationState }) => {
const state = { sorting: sort, pagination, ...next }
writeURLState(state, keys, 'replace')
savePersistedState('localStorage', STORAGE_KEY, state, { pagination: true })
}
const { table } = useTable({
data,
columns,
sorting: { defaultSort: sort },
pagination: { pageIndex: pagination.pageIndex, pageSize: pagination.pageSize },
onSortingChange: (updater) => {
const next = typeof updater === 'function' ? updater(sort) : updater
setSort(next)
persistAndSync({ sorting: next })
},
onPaginationChange: (updater) => {
const next = typeof updater === 'function' ? updater(pagination) : updater
setPagination(next)
persistAndSync({ pagination: next })
},
})
return <table>{/* render as usual */}</table>
}Reloading the page (no URL params) restores the last persisted sort and
page. Sharing the URL sends the exact view — the recipient's parseURLState
call picks up the same sort and page from the query string, taking priority
over anything already in their own storage.
Notes
parseURLStateandwriteURLStateread/writewindow.locationdirectly — they only run client-side ('use client'in Next.js App Router, and guard against SSR if you call them outside an effect/event handler).resolveURLKeys({ page: 'p', sort: 's' })lets you shorten or rename the query params; pass the same resolvedkeysobject to bothparseURLStateandwriteURLState.PersistOptions(the last argument to both persistence functions) controls which state slices are read/written — it defaults to everything exceptpagination, which is why the example above passes{ pagination: true }explicitly tosavePersistedState.writeURLStateomits values that match their defaults, so the URL stays clean instead of accumulating every possible param.- These same utilities work identically alongside
useServerTableanduseQueryTable— persist/sync whatever state you're already tracking for the refetch, as shown in Server-side data.