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.
mmddyyyy
| Name | Role | Status | Joined |
|---|---|---|---|
| Alice Martin | Admin | Active | Jan 10, 2022 |
| Bob Chen | Editor | Active | Jun 22, 2022 |
| Carol White | Viewer | Inactive | Feb 14, 2023 |
| David Kim | Editor | Active | Sep 1, 2023 |
| Eve Torres | Viewer | Active | Mar 30, 2024 |
| Frank Lee | Admin | Inactive | Nov 5, 2024 |
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,Selectfor the closed sets ofrole/statusvalues,DatePickerfor thejoineddate 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 waypaginated-tableswaps inpage/pageSize. - Date comparison uses
@internationalized/date'sparseDate+.compare()rather thanDate, matching theCalendarDatevalueDatePickeritself works with. - An empty-string
Selectvalue ("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. useMemoavoids 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.