A powerful data table filter component. Library-agnostic. Supports client and server-side filtering.
Status | Title | Assignee | Estimated Hours | Start Date | End Date | Labels | |
---|---|---|---|---|---|---|---|
Backlog | Add workspace settings when using keyboard nav | 14h | Task UI File Processing | ||||
In Progress | Refactor dark mode for archived projects | 10h | Apr 01 | Frontend Security Throughput API Versioning Rate Limiting | |||
In Progress | Update project view | 2h | Mar 31 | Frontend Inference Real-Time Batch Processing Debugging | |||
Todo | Remove workspace settings | JS | 14h | Frontend Deployment | |||
Todo | Refactor task sidebar | MS | 2h | Authentication Validation Overfitting | |||
Done | Implement user permissions | 8h | Apr 13 | Apr 15 | Rate Limiting | ||
Backlog | Redesign status badges when using keyboard nav | AY | 10h | ||||
In Progress | Update project view | RE | 7h | Apr 09 | Urgent Frontend Session Management Cron Job File Upload | ||
Todo | Remove user permissions in mobile view | 6h | Performance Proof of Concept | ||||
Todo | Remove team management | 3h |
Introduction
This library is an add-on to your existing data table for filtering your data, providing key building blocks for building a powerful filtering experience:
- A React hook,
useDataTableFilters()
, which exposes your data table filters state. - A
<DataTableFilter />
component, built with shadcn/ui and inspired by Linear's design. - Integrations for key libraries, such as TanStack Table and nuqs.
Some answers to the most common questions:
- Can I use this with X library? In theory, yes!
- Can I use this with client-side filtering? Yes!
- Can I use this with server-side filtering? Yes!
Installation
From the command line, install the component into your project:
pnpm dlx shadcn@latest add https://ui.bazza.dev/r/filters
There is a known issue with shadcn CLI that prevents the JSON locale files from being installed correctly in JavaScript projects. The cause is unknown and being investigated.
The temporary workaround is to run the installation command above (it will throw an error), then:
- Create the
locales
directory in the component root directory. - Copy the
locales/en.json
file into the directory.
Quick Start
Examples
We have examples that you can use as a reference to build your own applications.
We still recommend reading through the Concepts and Guides sections to get a deep understanding of how to configure and use this component.
Client-side filtering
- TanStack Table, static (i.e. no data fetching, mainly for demo purposes)
Server-side filtering
- TanStack Table, TanStack Query,
nuqs
Concepts
Let's take a look at the most important concepts for using this component.
Strategy
The FilterStrategy
decides where filtering happens: on the client
or on the server
.
With the client
strategy, the client receives all the table data and filters it locally in the browser.
With the server
strategy, the client sends filter requests to the server. The server applies the filters to the data and sends back only the filtered data. The client never sees the entire dataset.
Column data types
When you want to make a column filterable, you first need to define what type of data it contains.
ColumnDataType
identifies the types of data we currently support filtering for:
type ColumnDataType =
| 'text' /* Text data */
| 'number' /* Numerical data */
| 'date' /* Dates */
| 'option' /* Single-valued option (e.g. status) */
| 'multiOption' /* Multi-valued option (e.g. labels) */
Filters
The state of the applied filters on a table is represented as FiltersState
, which is a FilterModel[]
:
type FilterModel<TType extends ColumnDataType = any> = {
columnId: string
type: TType // option, multiOption, text, date, number
operator: FilterOperators[TType] // i.e. 'is', 'is not', 'is any of', etc.
values: FilterValues<TType>
}
Each FilterModel
represents a single filter for a specific column.
Column options
For option
and multiOption
columns (we'll refer to these as option-based columns), there exists a set of possible options for each column - we call these column options.
For example, an issues table could have a
status
column with the options "Backlog", "To Do", "In Progress", and "Done".
We represent each option as a ColumnOption
:
interface ColumnOption {
/* The label to display for the option. */
label: string
/* The internal value of the option. */
value: string
/* An optional icon to display next to the label. */
icon?: React.ReactElement | React.ElementType
}
Column configuration
We describe each column in our data table as a ColumnConfig
.
We create a ColumnConfig
using a builder instance:
/* Create the configuration builder instance. */
const dtf = createColumnConfigHelper<Issue>()
/* Create the column configurations. */
export const columnsConfig = [
dtf
.text()
.id('title')
.accessor((row) => row.title)
.displayName('Title')
.icon(Heading1Icon)
.build(),
dtf
.option()
.accessor((row) => row.status.id)
.id('status')
.displayName('Status')
.icon(CircleDotDashedIcon)
.build(),
/* ... */
] as const
Instance
We use the useDataTableFilters()
hook to create our data table filters instance.
This hooks handles the logic for filtering the data (if using the client
strategy) and updating the filters state.
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
})
Given those inputs, the hook creates your data table filters instance.
The instance has the following properties:
columns
: TheColumn[]
for your data table filters. AColumn
is a superset of aColumnConfig
, with additional properties & methods.filters
: The filters state, represented as aFilterState
object.actions
: A collection of mutators for the filters state.strategy
: The strategy used for filtering (client
orserver
side filtering).
Component
The visual component for the data table filter is the <DataTableFilter />
component.
It takes the columns
, filters
, actions
and strategy
from the hook as input.
import { DataTableFilter } from '@/components/data-table-filter'
export function IssuesTable() {
return (
<div>
<DataTableFilter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
/>
<DataTable />
</div>
)
}
Guides
This section contains guides for using the data table filter component.
These are much more detailed than the Concepts section, and are recommended when you actually go to implement the component for your project.
Before we dive in, let's take a look at a basic scenario — an issue tracker (e.g. Linear) — that will be referenced throughout the guides.
export type Issue = {
id: string
title: string
description?: string
status: IssueStatus
labels?: IssueLabel[]
assignee?: User
startDate?: Date
endDate?: Date
estimatedHours?: number
}
export type User = {
id: string
name: string
picture: string
}
export type IssueLabel = {
id: string
name: string
color: string
}
export type IssueStatus = {
id: 'backlog' | 'todo' | 'in-progress' | 'done'
name: string
order: number
icon: LucideIcon
}
Columns
Why do I need a column configuration?
For this component/library to work effectively, we need to describe each column in our data table.
If you're using a table library, you may be thinking:
"I've already done this for X library. Why do I need to do it again?"
This component requires it's own set of column configurations, which are tailored to the task at hand - column filtering.
We need to know how to access the data for each column, how to display it, what shape the data comes in, what shape it should end up in, and much more.
Column builders
We need to describe each column in our data table. Ideally, in a type-safe way. This is done using our column configuration builder.
It has a fluent API (similar to Zod) that allows us to define a column's properties in a concise, readable, and type-safe manner.
First, you use the createColumnConfigHelper()
function to create a column configuration builder:
import { createColumnConfigHelper } from '@/components/data-table-filter/core/filters'
import type { Issue } from './types'
// dtf = down to... filter? (sorry, couldn't resist)
const dtf = createColumnConfigHelper<Issue>()
Notice how we pass in our data model (Issue
) as a generic parameter. This is required for the column configuration builder to be type safe.
ColumnConfig[]
- otherwise, you'll lose out on all the type-safety benefits we've baked in.From here, we can use our builder instance to create configurations for each column.
Data types
The first call to the column builder is text()
, number()
, date()
, option()
, or multiOption()
.
This defines the data type of the column.
dtf
.text()
Column ID
The second call should be to the id()
method, to give our column a unique ID.
dtf
.text()
.id('title')
Accessor
The accessor()
method is used to define how we extract a column's value for each row in your data table.
dtf
.text()
.id('title')
.accessor((row) => row.title)
- For
text
columns, your accessor should return astring
. - For
number
columns, your accessor should return anumber
. - For
date
columns, your accessor should return aDate
. - For
option
columns, if you're using...- client-side filtering...
- with static
options
, your accessor should return astring
. - with inferred options, your accessor can return
any
, provided you have enough information later on to map it to aColumnOption
usingtransformOptionFn
.
- with static
- server-side filtering, your accessor should return a
string
.
- client-side filtering...
- For
multiOption
columns, if you're using...- client-side filtering...
- with static
options
, your accessor should return astring[]
. - with inferred options, your accessor can return
any[]
, provided you have enough information later on to map each value to aColumnOption
usingtransformOptionFn
.
- with static
- server-side filtering, your accessor should return a
string[]
.
- client-side filtering...
Display Name
We can use the displayName()
method to set the display name for the column.
dtf
.text()
.id('title')
.accessor((row) => row.title)
.displayName('Title')
Icon
We can use the icon()
method to set the icon for the column.
dtf
.text()
.id('title')
.accessor((row) => row.title)
.displayName('Title')
.icon(Heading1Icon)
Options
option
and multiOption
columns.We can determine a column's options in two ways: declared and inferred.
Declared options
This is useful for columns that have a fixed set of options.
We may pass these in...
- at build time (i.e. static)
- at run time by fetching them from a data source (i.e. remote).
Regardless, they are directly known to the column.
For declaring static options, we can use the options()
method from the builder.
const ISSUE_STATUSES: IssueStatus[] = [
{ id: 'backlog', name: 'Backlog', icon: CircleDashedIcon },
{ id: 'todo', name: 'Todo', icon: CircleIcon },
{ id: 'in-progress', name: 'In Progress', icon: CircleDotIcon },
{ id: 'done', name: 'Done', icon: CircleCheckIcon },
] as const
dtf
.option()
.accessor((row) => row.status.id)
.id('status')
.displayName('Status')
.icon(CircleDotDashedIcon)
.options(
ISSUE_STATUSES.map((s) => ({
value: s.id,
label: s.name,
icon: s.icon
}))
)
For declaring remote options, it is best to do this when we instantiate the table filters instance (covered later on).
Inferred options
If you're using server-side filtering, you must use the declared options approach.
This is because the data available on the client is not representative of the full dataset, in the server-side filtering scenario.
This is useful if you're:
- using client-side filtering;
- options can't be known at build time;
- and you don't have a way to fetch them (e.g., no dedicated endpoint).
We infer the options from the available data at runtime, by looping through the data and extracting unique values.
If the values are already in the ColumnOption
shape, you're golden.
If not, we can use the transformOptionFn()
method from the builder, which transforms each unique column value to a ColumnOption
.
export type User = {
id: string
name: string
picture: string
}
const UserAvatar = ({ user }: { user: User }) => {
return (
<Avatar key={user.id} className="size-4">
<AvatarImage src={user.picture} />
<AvatarFallback>
{user.name
.split('')
.map((x) => x[0])
.join('')
.toUpperCase()}
</AvatarFallback>
</Avatar>
)
}
dtf
.option()
.accessor((row) => row.assignee) // User | undefined
.id('assignee')
.displayName('Assignee')
.icon(UserCheckIcon)
.transformOptionFn((u) => ({
value: u.id,
label: u.name,
icon: <UserAvatar user={u} />
}))
Min/max values
number
columns.We can use the min()
and max()
methods to set the faceted minimum and maximum values for a column.
These aren't hard limits, but rather used to set the visual boundaries for the range slider.
dtf
.number()
.accessor((row) => row.estimatedHours)
.id('estimatedHours')
.displayName('Estimated hours')
.icon(ClockIcon)
.min(0)
.max(100)
With the client
strategy, you could skip this step - the min/max values can be computed from the data.
server
strategy.With the server
strategy, we can't compute these values for you, since we don't have access to the full dataset.
For this case, we recommend setting the faceted min
and max
values when we create our instance (covered later on).
server
strategy without specifying the values), filtering will still work as expected, but the slider will not be visible.Build
Finally, we finish off our column configuration by calling the build()
method.
This returns a ColumnConfig
, which we pass onto our table filters instance later on.
dtf
.number()
.accessor((row) => row.estimatedHours)
.id('estimatedHours')
.displayName('Estimated hours')
.icon(ClockIcon)
.min(0)
.max(100)
.build()
Putting it all together
ColumnConfig[]
with as const
to ensure it's immutable and type-safe.Now that we've covered the basics of creating a column configuration, let's put it all together.
We create an array of column configurations, making sure to label it as const
to ensure it's (1) immutable and (2) type-safe for later on.
const columnsConfig = [
dtf
.text()
.id('title')
.accessor((row) => row.title)
.displayName('Title')
.icon(Heading1Icon)
.build(),
dtf
.number()
.accessor((row) => row.estimatedHours)
.id('estimatedHours')
.displayName('Estimated hours')
.icon(ClockIcon)
.min(0)
.max(100)
.build(),
/* ...rest of the columns... */
] as const
Instance
With your column configurations in hand, we can move onto creating our data table filters instance.
This hooks handles the logic for filtering the data (if using the client
strategy) and updating the filters state.
Think of it as the "brain" of the filters.
You can use it completely independent of the <DataTableFilter />
component, if you wish... but come on, we did all that work for you - might as well use it.
Creating the instance
To create the instance, we use the useDataTableFilters()
hook.
import { useDataTableFilters } from '@/components/data-table-filter'
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
})
Let's go through each input:
strategy
: The strategy used for filtering (client
orserver
side filtering).data
: The data to be filtered. When using theserver
strategy, this data is not directly used, but it should still be supplied to ensure type safety.columnsConfig
: The column configurations (ColumnConfig[]
) we created earlier.
The hook returns our table filters instance, of which the most important properties are:
columns
: TheColumn[]
for your data table filters. AColumn
is a superset of aColumnConfig
, with additional properties & methods.filters
: The filters state, represented as aFilterState
object.actions
: A collection of mutators for the filters state.strategy
: The strategy used for filtering (client
orserver
side filtering).
The real magic is in the filters
state, which is exposed to you. You can pass this around your application as you wish...
- To your table library
- To your state management library
- To your data fetching library
- and so on...
We can take this a step further by using a controlled (external) state, covered in the next section.
Using uncontrolled state
By default, the filters
state is uncontrolled; it is managed internally by the instance.
You can specify a default (or initial) value using the defaultFilters
property:
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
defaultFilters: [
{
columnId: 'status',
type: 'option',
operator: 'is',
values: ['backlog'],
},
],
})
Using controlled state
If you want to use controlled (external) state for your filters, you can use the filters
and onFiltersChange
properties. You can hook in your state management solution of choice.
defaultFilters
property is not used when using controlled state.If you use controlled state and wish to specify a default value, use the mechanism provided by your state management solution.
import { useState } from 'react'
import { FilterState } from '@/components/data-table-filter/core/types'
const [filters, setFilters] = useState<FiltersState>([])
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
filters: filters,
onFiltersChange: setFilters,
})
Passing remote options to the instance
If you're using the declared options approach for any of your option-based columns, you can pass your column options to the instance directly!
We expose an options
property on the instance, where we pass in the ColumnOption[]
for relevant columns.
Let's take a look at how we can use this in practice:
/* Step 1: Fetch data from the server */
const labels = useQuery(queries.labels.all())
const statuses = useQuery(queries.statuses.all())
const users = useQuery(queries.users.all())
const issues = useQuery(queries.issues.all())
/* Step 2: Create ColumnOption[] for each option-based column */
const labelOptions = createLabelOptions(labels.data)
const statusOptions = createStatusOptions(statuses.data)
const userOptions = createUserOptions(users.data)
/*
* Step 3: Create our data table filters instance
*
* This instance will handle the logic for filtering the data and updating the filters state.
* We expose an `options` prop to provide the options for each column dynamically, after fetching them above.
* It exposes our filters state, for you to pass on as you wish - e.g. to a TanStack Table instance.
*/
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
options: {
status: statusOptions,
assignee: userOptions,
labels: labelOptions,
},
})
Passing faceted values to the instance
We can also pass in faceted column values to the instance for the relevant columns.
- Faceted unique values: For
option
andmultiOption
columns, we pass in aMap<string, number>
which maps each column option ID to the number of times it appears in the dataset. - Faceted min/max values: For
number
columns, we pass in a tuple[number, number]
representing the minimum and maximum values for the column data.
/* Step 1: Fetch data from the server */
const labels = useQuery(queries.labels.all())
const statuses = useQuery(queries.statuses.all())
const users = useQuery(queries.users.all())
const facetedLabels = useQuery(queries.labels.faceted())
const facetedStatuses = useQuery(queries.statuses.faceted())
const facetedUsers = useQuery(queries.users.faceted())
const facetedEstimatedHours = useQuery(queries.estimatedHours.faceted())
const issues = useQuery(queries.issues.all())
/* Step 2: Create ColumnOption[] for each option-based column */
const labelOptions = createLabelOptions(labels.data)
const statusOptions = createStatusOptions(statuses.data)
const userOptions = createUserOptions(users.data)
/*
* Step 3: Create our data table filters instance
*
* This instance will handle the logic for filtering the data and updating the filters state.
* We expose an `options` prop to provide the options for each column dynamically, after fetching them above.
* Same goes for `faceted` unique values and min/max values.
* It exposes our filters state, for you to pass on as you wish - e.g. to a TanStack Table instance.
*/
const { columns, filters, actions, strategy } = useDataTableFilters({
strategy: 'client',
data: issues.data ?? [],
columnsConfig,
options: {
status: statusOptions,
assignee: userOptions,
labels: labelOptions,
},
faceted: {
status: facetedStatuses.data,
assignee: facetedUsers.data,
labels: facetedLabels.data,
estimatedHours: facetedEstimatedHours.data,
},
})
Internationalization
The standard installation for the component provides English (en
) localization via the lib/i18n.ts
and locales/en.json
files.
We provide an add-on to add support for additional locales:
en
(English) - defaultfr
(French)zh_CN
(Simplified Chinese)zh_TW
(Traditional Chinese)nl
(Dutch)de
(German)
Installation
y/N
), explicitly overwrite the lib/i18n.ts
file.There is a known issue with shadcn CLI that prevents the JSON locale files from being installed correctly in JavaScript projects. The cause is unknown and being investigated.
The temporary workaround is to run the installation command (it will throw an error), then copy the missing locale files into the directory.
pnpm dlx shadcn@latest add https://ui.bazza.dev/r/filters/i18n
This add-on:
- Overwrites the
lib/i18n.ts
file to add all supported locales. - Adds all supported locales to your project, under
locales/[locale].json
.
Usage
You can specify the chosen locale
for the <DataTableFilter />
component and useDataTableFilters()
hook:
<DataTableFilter
filters={filters}
columns={columns}
actions={actions}
strategy={strategy}
locale="fr"
/>
useDataTableFilters({
/* ... */
locale: 'fr',
})
If no locale
is provided, the component defaults to en
(English).
Adding a custom locale
You can add a new locale by create a new file in the locales/
directory. The filename should match the locale code (e.g. fr.json
).
Use the existing locales/en.json
file as a reference for the required keys. Add your translations as values for the keys.
Then, extend the Locale
type in lib/i18n.ts
to include your new locale:
import en from '../locales/en.json'
import fr from '../locales/fr.json'
import xx from '../locales/xx.json'
export type Locale = 'en' | 'fr'
export type Locale = 'en' | 'fr' | 'xx'
type Translations = Record<string, string>
const translations: Record<Locale, Translations> = {
en,
fr,
xx,
}
export function t(key: string, locale: Locale): string {
return translations[locale][key] ?? key
}
Integrations
TanStack Table
This is how to integrate data table filters with the TanStack Table (TST) library.
When should I use this?
If you're using TanStack Table and client-side filtering, you should use this integration.
If you're using server-side filtering, you don't need this integration. Feed your data into your TST table instance and you're good to go.
How it works
createTSTColumns
TanStack Table allows you to define custom filter functions for each column. This is useful if you want to implement custom filtering logic.
We have our own filter functions for each column type, which you can use to filter your data via TST.
The createTSTColumns
function handles this for you. It overrides the filterFn
property for each filterable TST column with the appropriate filter function.
createTSTFilters
You also need to provide the filter state to TST. TST represents the filter state slightly differently than the filters state provided by this component.
The createTSTFilters
function takes in the filters state from useDataTableFilters()
and returns a TST-compatible filter state (ColumnFiltersState
).
Installation
pnpm dlx shadcn@latest add https://ui.bazza.dev/r/filters/tst
Usage
const { columns, filters, actions, strategy } = useDataTableFilters({ /* ... */ })
const tstColumns = useMemo(
() =>
createTSTColumns({
columns: tstColumnDefs, // your TanStack Table column definitions
configs: columns, // Your column configurations
}),
[columns],
)
const tstFilters = useMemo(() => createTSTFilters(filters), [filters])
const table = useReactTable({
data: issues.data ?? [],
columns: tstColumns,
getRowId: (row) => row.id,
getCoreRowModel: getCoreRowModel(),
getFilteredRowModel: getFilteredRowModel(),
state: {
columnFilters: tstFilters
}
})
nuqs
You can use nuqs
to persist the filter state in the URL.
- Install the
nuqs
andzod
packages:
pnpm add nuqs zod
-
Use the appropriate
nuqs
adapter for your framework from the nuqs docs. -
Create your Zod schema for the query filter state:
import { z } from 'zod'
import type { FiltersState } from '@/components/data-table-filter/core/types'
const filtersSchema = z.custom<FiltersState>()
- Create your query state:
const [filters, setFilters] = useQueryState<FiltersState>(
'filters',
parseAsJson(filtersSchema.parse).withDefault([]),
)
- Pass it to your table filters instance:
const { columns, filters, actions, strategy } = useDataTableFilters({
/* ... */
filters,
onFiltersChange: setFilters,
})
Changelog
2025.04.12
We've squashed a pesky bug where inferred column options would show duplicate entries in the filter menu.
We've updated the implementation of uniq()
to use deep equality checks, instead of the previous referencial equality checks via new Set()
.
2025.04.01
This adds support for filtering columns where the column value is not strictly a property of the original data. This was not possible before, due to the limitation of defineMeta
's first argument, which only accepted a direct property on the initial data type.
You can now filter columns where the value is:
- a deeply nested property (i.e.
user.name
) - accessed using a function (i.e.
row => row.user.name.split(' ')[0]
)
To accomplish this, we've decided to change the interface for the defineMeta
helper function. The first property is now an accessor function, instead of an accessor key.
See the example below for how to migrate:
type Issue = {
status: string
user: {
name: string
}
}
const columns = [
/* ... */
columnHelper.accessor('status', {
meta: defineMeta(
'status',
row => row.status,
{
type: 'option',
icon: CircleDotDashedIcon,
options: ISSUE_STATUSES,
}
),
}),
columnHelper.accessor(row => row.user.name, {
meta: defineMeta(
row => row.user.name,
{
type: 'option',
icon: AvatarIcon,
/* ... */
}
),
}),
]