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 Silva | Active | Team | Support | $580.00 |
| Noah Smith | Churned | Enterprise | Growth | $0.00 |
| Kavya Iyer | Active | Free | Growth | $0.00 |
| Yuki Smith | Invited | Pro | Research | $36.00 |
| Haruto Müller | Churned | Team | Finance | $0.00 |
| Omar Silva | Active | Pro | Growth | $36.00 |
| Ananya Reddy | Invited | Team | Platform | $1,682.00 |
| Saanvi Nguyen | Active | Enterprise | Data | $2,133.00 |
| Amelia Patel | Churned | Pro | Platform | $0.00 |
| Idris Silva | Active | Pro | Platform | $24.00 |
import { NuqsAdapter } from "nuqs/adapters/react";
import { DataGrid, createDataGridColumns, useDataGrid } from "@/components/ui/data-grid";import { useDataGridUrlState } from "@/hooks/use-data-grid-url-state";
import { makePeople, PLAN_OPTIONS, STATUS_OPTIONS, type Person,} from "@/lib/data-grid-demo-data";
const col = createDataGridColumns<Person>();const columns = col.columns([ col.text("name", { header: "Name" }), col.enum("status", { header: "Status", options: STATUS_OPTIONS }), col.enum("plan", { header: "Plan", options: PLAN_OPTIONS }), col.text("team", { header: "Team" }), col.currency("mrr", { header: "MRR", currency: "USD" }),]);const data = makePeople(300, 9);
function UrlStateGrid() { // Filters, the search and the page live in the URL (?people.filters=…&people.q=…&people.page=2). const urlState = useDataGridUrlState({ key: "people" }); const grid = useDataGrid<Person>({ key: "url-state", data, columns, getRowId: (r) => r.id, state: urlState, }); return <DataGrid grid={grid} />;}
/** Share a filtered view by its URL. The adapter comes from nuqs; pick the one for your router. */export default function DataGridUrlState() { return ( <NuqsAdapter> <UrlStateGrid /> </NuqsAdapter> );}Change a filter or the page above and watch the address bar.
Install
Section titled “Install”npx shadcn@latest add @kadal/data-grid-url-stateThis 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:
import { NuqsAdapter } from "nuqs/adapters/next/app"
export default function RootLayout({ children }) { return <html><body><NuqsAdapter>{children}</NuqsAdapter></body></html>}import { NuqsAdapter } from "nuqs/adapters/next/pages"
export default function App({ Component, pageProps }) { return <NuqsAdapter><Component {...pageProps} /></NuqsAdapter>}import { NuqsAdapter } from "nuqs/adapters/react-router/v7" // or /v6
<NuqsAdapter><RouterProvider router={router} /></NuqsAdapter>import { NuqsAdapter } from "nuqs/adapters/remix"
<NuqsAdapter><Outlet /></NuqsAdapter>import { NuqsAdapter } from "nuqs/adapters/tanstack-router"
<NuqsAdapter><RouterProvider router={router} /></NuqsAdapter>import { NuqsAdapter } from "nuqs/adapters/react"
<NuqsAdapter><App /></NuqsAdapter>Use it
Section titled “Use it”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.
The query keys
Section titled “The query keys”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.
How it behaves
Section titled “How it behaves”- 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 thatpersistalready remembers. Withsorting: truethe 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
urlStatefor 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 }),})Options
Section titled “Options”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. |
Your own adapter
Section titled “Your own adapter”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.
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, }}See also
Section titled “See also”- Saved views and persistence: the view half.
- Server-side data
- Filtering and Global search: what ends up in the URL.