Skip to content

URL state

A filtered grid should be a link you can send. useDataGridUrlState keeps the grid’s query (column filters, the search box, the page, optionally the sort) in the URL with nuqs, while persist keeps the view. Under it is a small interface, DataGridStateAdapter, that any state source can implement.

Kavya SilvaActiveTeamSupport$580.00
Noah SmithChurnedEnterpriseGrowth$0.00
Kavya IyerActiveFreeGrowth$0.00
Yuki SmithInvitedProResearch$36.00
Haruto MüllerChurnedTeamFinance$0.00
Omar SilvaActiveProGrowth$36.00
Ananya ReddyInvitedTeamPlatform$1,682.00
Saanvi NguyenActiveEnterpriseData$2,133.00
Amelia PatelChurnedProPlatform$0.00
Idris SilvaActiveProPlatform$24.00
0 of 300 row(s) selected.
Page 1 of 30

Change a filter or the page above and watch the address bar.

Terminal window
npx shadcn@latest add @kadal/data-grid-url-state

This adds hooks/use-data-grid-url-state.ts and installs nuqs@^2.10.2. nuqs needs an adapter around your app, once, for your router:

app/layout.tsx
import { NuqsAdapter } from "nuqs/adapters/next/app"
export default function RootLayout({ children }) {
return <html><body><NuqsAdapter>{children}</NuqsAdapter></body></html>
}
customers/page.tsx
import { DataGrid, useDataGrid } from "@/components/ui/data-grid"
import { useDataGridUrlState } from "@/hooks/use-data-grid-url-state"
export function CustomersPage({ customers }: { customers: Customer[] }) {
const urlState = useDataGridUrlState({ key: "customers" })
const grid = useDataGrid({ data: customers, columns, getRowId: (c) => c.id, state: urlState })
return <DataGrid grid={grid} />
}

The hook returns a DataGridStateAdapter; state hands it to the grid, which treats those slices as controlled and keeps the rest itself.

With key: "customers":

Key Holds Format
customers.filters ColumnFiltersState JSON, e.g. [{"id":"status","value":["active"]}]
customers.q the search text string
customers.page the page integer, 1-based
customers.sort SortingState, only with sorting: true JSON

Keys at their default (no filters, empty search, page 1) are removed from the URL. Changing a filter or the search puts the page back to 1. Writes use history: "replace" by default so the back button is not flooded; "push" makes each change a history entry.

  • Two grids on one page need different names; the default is "grid".
  • The sort stays out of the URL unless you ask (sorting: true), because it is a view preference that persist already remembers. With sorting: true the URL owns the sort entirely: the stored view still records it, but no longer restores it.
  • The page index lives in the URL and the page size in the view; the grid merges them.
  • With server-side data, read the same urlState for your fetch parameters, so the URL, the grid and the request agree.
const urlState = useDataGridUrlState({ key: "orders" })
const { data } = useQuery({
queryKey: ["orders", urlState.columnFilters, urlState.globalFilter, urlState.pageIndex],
queryFn: () => fetchOrders({ filters: urlState.columnFilters, q: urlState.globalFilter, page: urlState.pageIndex }),
})

useDataGridUrlState(options?):

Option Type Default What it does
name string "grid" Prefix of the query keys.
sorting boolean false Put the sort in the URL too.
history "replace" | "push" "replace" nuqs history mode for every key.

Anything can own these slices: React state, a store, a router’s search params, a form. Implement DataGridStateAdapter and pass it as state. Every field is optional; a slice you provide is controlled, a slice you leave out is the grid’s.

interface DataGridStateAdapter {
columnFilters?: ColumnFiltersState
onColumnFiltersChange?: OnChangeFn<ColumnFiltersState>
globalFilter?: string
onGlobalFilterChange?: OnChangeFn<string>
pageIndex?: number // 0-based
onPageIndexChange?: (pageIndex: number) => void
sorting?: SortingState
onSortingChange?: OnChangeFn<SortingState>
grouping?: GroupingState
onGroupingChange?: OnChangeFn<GroupingState>
}

OnChangeFn<T> is TanStack’s updater callback: it receives either a value or (old) => next.

A React-state adapter
import * as React from "react"
import type { ColumnFiltersState, DataGridStateAdapter } from "@/components/ui/data-grid"
function useLocalQueryState(): DataGridStateAdapter {
const [columnFilters, setColumnFilters] = React.useState<ColumnFiltersState>([])
const [globalFilter, setGlobalFilter] = React.useState("")
const [pageIndex, setPageIndex] = React.useState(0)
return {
columnFilters,
onColumnFiltersChange: (u) => { setColumnFilters(u); setPageIndex(0) },
globalFilter,
onGlobalFilterChange: (u) => { setGlobalFilter((old) => (typeof u === "function" ? u(old) : u) ?? ""); setPageIndex(0) },
pageIndex,
onPageIndexChange: setPageIndex,
}
}