Params Filter Table
A data table whose filter state is synced to URL search params. The table itself is filter-state agnostic — it takes search/role as controlled props, so you can back it with useNuqsFilters (nuqs) or useNativeFilters (plain URLSearchParams/History API), whichever fits your app.
npx shadcn add https://shad-table.dev/r/params-filter-table.json| Name | Role | |
|---|---|---|
| Amelia Frost | amelia@acme.dev | Engineering |
| Noah Park | noah@acme.dev | Design |
| Sofia Reyes | sofia@acme.dev | Product |
| Liam Chen | liam@acme.dev | Engineering |
| Maya Okafor | maya@acme.dev | Support |
How it works
1.The table takes filter state as controlled props
ParamsDataTable never reaches for a router or a URL-state library itself. It receives search, role, onSearchChange, and onRoleChange as props and just filters the in-memory data against them — where that state actually lives is entirely up to the caller.
interface DataTableProps<TData extends { role: string }, TValue> {columns: ColumnDef<TData, TValue>[]data: TData[]roleOptions: string[]search: stringonSearchChange: (value: string) => voidrole: stringonRoleChange: (value: string) => void}
2.Option A — sync it to the URL with nuqs
useNuqsFilters wraps two useQueryState calls (one per param) behind the same { search, role, setSearch, setRole } shape the table expects. Passing null to a nuqs setter removes that key from the URL entirely, so "cleared" filters stay out of the query string.
export function useNuqsFilters() {const [search, setSearch] = useQueryState('q', { defaultValue: '' })const [role, setRole] = useQueryState('role', { defaultValue: '' })return {search,role,setSearch: (value: string) => setSearch(value || null),setRole: (value: string) => setRole(value || null),}}
3.Option B — or skip the dependency with native URLSearchParams
useNativeFilters implements the exact same shape using only browser APIs: it reads the current query string on mount, writes back via history.replaceState (no full navigation), and re-syncs on popstate so browser back/forward still works. Nothing here is router-specific.
function writeParam(key: string, value: string) {const url = new URL(window.location.href)if (value) {url.searchParams.set(key, value)} else {url.searchParams.delete(key)}window.history.replaceState(null, '', url)}
4.Swapping strategies is a one-line change
ParamsFilterTableDemo only imports whichever hook it wants and destructures the same four values. Since both hooks return an identical shape, switching from nuqs to native URLSearchParams (or a custom implementation of your own) never touches ParamsDataTable.
import { useNuqsFilters } from './use-nuqs-filters'// import { useNativeFilters as useNuqsFilters } from './use-native-filters'const { search, role, setSearch, setRole } = useNuqsFilters()
5.Filtering runs against the full in-memory dataset
A useMemo recomputes the visible rows whenever data, search, or role change: search does a case-insensitive substring match across every field on the row, and role does an exact match. Both conditions must pass for a row to stay visible.
const filteredData = useMemo(() => {return data.filter((row) => {const matchesSearch = search? Object.values(row).some((value) =>String(value).toLowerCase().includes(search.toLowerCase()),): trueconst matchesRole = role ? row.role === role : truereturn matchesSearch && matchesRole})}, [data, search, role])