Search docs

Jump to any table example

Discord—

Shadcn 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.

Use this when filtered views must be shareable or bookmarkable, or should survive a page reload and the browser back button.

$ npx shadcn add https://www.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])

Using TanStack Table v9? See the v9 version of Params Filter Table.