Skip to main contentSkip to docs navigation

Filterable Table

A complete example of per-column filter controls filtering a Table's rows client-side.

On this page

Table already accepts any array via items, so filtering is just: keep the full data set in state, derive the visible subset from one or more filter values, and pass that to TableBody. No dedicated filter prop is needed — and since it's plain state, each column can use whichever control fits its data instead of a single one-size-fits-all search box.

NameRoleStatusJoined
Alice MartinAdminActiveJan 10, 2022
Bob ChenEditorActiveJun 22, 2022
Carol WhiteViewerInactiveFeb 14, 2023
David KimEditorActiveSep 1, 2023
Eve TorresViewerActiveMar 30, 2024
Frank LeeAdminInactiveNov 5, 2024
JSX
import { useMemo, useState, type ChangeEvent } from 'react'
import {
  TextInput,
  Select,
  DatePicker,
  Table,
  Badge,
  TableHeader,
  TableColumn,
  TableBody,
  TableRow,
  TableCell
} from '@chassis-ui/react'
import { parseDate, type DateValue } from '@internationalized/date'

const allUsers = [
  { id: 1, name: 'Alice Martin', role: 'Admin', status: 'Active', joined: '2022-01-10' },
  { id: 2, name: 'Bob Chen', role: 'Editor', status: 'Active', joined: '2022-06-22' },
  { id: 3, name: 'Carol White', role: 'Viewer', status: 'Inactive', joined: '2023-02-14' },
  { id: 4, name: 'David Kim', role: 'Editor', status: 'Active', joined: '2023-09-01' },
  { id: 5, name: 'Eve Torres', role: 'Viewer', status: 'Active', joined: '2024-03-30' },
  { id: 6, name: 'Frank Lee', role: 'Admin', status: 'Inactive', joined: '2024-11-05' }
]

const roles = ['Admin', 'Editor', 'Viewer']
const statuses = ['Active', 'Inactive']
const statusColor = { Active: 'success', Inactive: 'secondary' } as const

const dateFormatter = new Intl.DateTimeFormat('en-US', { dateStyle: 'medium' })

const columns = [
  { key: 'name', label: 'Name' },
  { key: 'role', label: 'Role' },
  {
    key: 'status',
    label: 'Status',
    render: (v: string) => <Badge color={statusColor[v as keyof typeof statusColor]}>{v}</Badge>
  },
  { key: 'joined', label: 'Joined', render: (v: string) => dateFormatter.format(new Date(v)) }
]

export const Example = () => {
  const [name, setName] = useState('')
  const [role, setRole] = useState('')
  const [status, setStatus] = useState('')
  const [joinedAfter, setJoinedAfter] = useState<DateValue | null>(null)

  const rows = useMemo(() => {
    const q = name.trim().toLowerCase()
    return allUsers.filter((user) => {
      if (q && !user.name.toLowerCase().includes(q)) return false
      if (role && user.role !== role) return false
      if (status && user.status !== status) return false
      if (joinedAfter && parseDate(user.joined).compare(joinedAfter) < 0) return false
      return true
    })
  }, [name, role, status, joinedAfter])

  const handleSelectChange =
    (setter: (value: string) => void) => (e: ChangeEvent<HTMLSelectElement>) =>
      setter(e.target.value)

  return (
    <div>
      <div className="d-flex flex-wrap gap-md mb-md">
        <TextInput
          type="search"
          label="Name"
          size="sm"
          placeholder="Search by name"
          value={name}
          onChange={setName}
          style={{ width: 200 }}
        />
        <Select
          label="Role"
          size="sm"
          value={role}
          onChange={handleSelectChange(setRole)}
          style={{ width: 160 }}
          options={[
            { label: 'All roles', value: '' },
            ...roles.map((r) => ({ label: r, value: r }))
          ]}
        />
        <Select
          label="Status"
          size="sm"
          value={status}
          onChange={handleSelectChange(setStatus)}
          style={{ width: 160 }}
          options={[
            { label: 'All statuses', value: '' },
            ...statuses.map((s) => ({ label: s, value: s }))
          ]}
        />
        <DatePicker
          label="Joined after"
          size="sm"
          value={joinedAfter}
          onChange={setJoinedAfter}
          style={{ width: 200 }}
        />
      </div>
      <Table aria-label="Users" hover>
        <TableHeader columns={columns}>
          {(column) => <TableColumn key={column.key}>{column.label}</TableColumn>}
        </TableHeader>
        <TableBody items={rows}>
          {(row) => (
            <TableRow key={row.id}>
              {(columnKey) => {
                const column = columns.find((c) => c.key === columnKey)
                const value = row[columnKey as keyof typeof row]
                return (
                  <TableCell>{column?.render ? column.render(String(value)) : value}</TableCell>
                )
              }}
            </TableRow>
          )}
        </TableBody>
      </Table>
      {rows.length === 0 && <p className="fg-subtle mt-md mb-0">No users match these filters.</p>}
    </div>
  )
}

Key points

  • Each filter is its own piece of state, matched to what the column actually holds: TextInput (type="search") for the free-text name match, Select for the closed sets of role/status values, DatePicker for the joined date threshold.
  • All four combine in one allUsers.filter(...) pass — swap in an API call (passing the filter state as query params) for server-side filtering, the same way paginated-table swaps in page/pageSize.
  • Date comparison uses @internationalized/date's parseDate + .compare() rather than Date, matching the CalendarDate value DatePicker itself works with.
  • An empty-string Select value ("All roles", "All statuses") means "don't filter on this field" — checked with a plain truthy test in the filter function, not a separate "cleared" state.
  • useMemo avoids re-filtering on every render that isn't caused by a filter value or the source data changing.
  • Showing a "no results" message when the filtered array is empty avoids a confusingly blank table.