Search docs

Jump to any table example

Discord
ShadTable

A collection of composable table components built on shadcn/ui and TanStack Table.

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
NameEmailRole
Amelia Frostamelia@acme.devEngineering
Noah Parknoah@acme.devDesign
Sofia Reyessofia@acme.devProduct
Liam Chenliam@acme.devEngineering
Maya Okaformaya@acme.devSupport

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.

src/components/params-filter/data-table.tsx
interface DataTableProps<TData extends { role: string }, TValue> {
columns: ColumnDef<TData, TValue>[]
data: TData[]
roleOptions: string[]
search: string
onSearchChange: (value: string) => void
role: string
onRoleChange: (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.

src/components/params-filter/use-nuqs-filters.ts
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.

src/components/params-filter/use-native-filters.ts
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.

src/components/params-filter/index.tsx
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.

src/components/params-filter/data-table.tsx
const filteredData = useMemo(() => {
return data.filter((row) => {
const matchesSearch = search
? Object.values(row).some((value) =>
String(value).toLowerCase().includes(search.toLowerCase()),
)
: true
const matchesRole = role ? row.role === role : true
return matchesSearch && matchesRole
})
}, [data, search, role])