Data table filteralpha

A powerful data table filter component. Library-agnostic. Supports client and server-side filtering.

StatusTitleAssigneeEstimated HoursStart DateEnd DateLabels
Backlog
Add workspace settings when using keyboard nav
14h
Task
UI
File Processing
In Progress
Refactor dark mode for archived projects
10hApr 01
Frontend
Security
Throughput
API Versioning
Rate Limiting
In Progress
Update project view
2hMar 31
Frontend
Inference
Real-Time
Batch Processing
Debugging
Todo
Remove workspace settings
JS14h
Frontend
Deployment
Todo
Refactor task sidebar
MS2h
Authentication
Validation
Overfitting
Done
Implement user permissions
8hApr 13Apr 15
Rate Limiting
Backlog
Redesign status badges when using keyboard nav
AY10h
In Progress
Update project view
RE7hApr 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
0 of 30000 row(s) selected. Total row count: 30000

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
JavaScript projects should add JSON locales manually after installation.

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:

  1. Create the locales directory in the component root directory.
  2. 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

Server-side filtering

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: The Column[] for your data table filters. A Column is a superset of a ColumnConfig, with additional properties & methods.
  • filters: The filters state, represented as a FilterState object.
  • actions: A collection of mutators for the filters state.
  • strategy: The strategy used for filtering (client or server 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.

types.ts
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:

columns.ts
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.

We strongly advise using the column builder instead of directly creating a 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.

If you're using our TanStack Table integration, this column ID should be the same one you used in your table column definitions.
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 a string.
  • For number columns, your accessor should return a number.
  • For date columns, your accessor should return a Date.
  • For option columns, if you're using...
    • client-side filtering...
      • with static options, your accessor should return a string.
      • with inferred options, your accessor can return any, provided you have enough information later on to map it to a ColumnOption using transformOptionFn.
    • server-side filtering, your accessor should return a string.
  • For multiOption columns, if you're using...
    • client-side filtering...
      • with static options, your accessor should return a string[].
      • with inferred options, your accessor can return any[], provided you have enough information later on to map each value to a ColumnOption using transformOptionFn.
    • server-side filtering, your accessor should return a string[].

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

This applies to 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
Inferred options are only available for client-side filtering.

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

This applies to 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.

You should specify the min/max values if you're using the 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).

If we cannot determine the min/max values (i.e. if you use 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

Make sure to declare your 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 or server side filtering).
  • data: The data to be filtered. When using the server 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: The Column[] for your data table filters. A Column is a superset of a ColumnConfig, with additional properties & methods.
  • filters: The filters state, represented as a FilterState object.
  • actions: A collection of mutators for the filters state.
  • strategy: The strategy used for filtering (client or server 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.

The 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 and multiOption columns, we pass in a Map<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) - default
  • fr (French)
  • zh_CN (Simplified Chinese)
  • zh_TW (Traditional Chinese)
  • nl (Dutch)
  • de (German)

Installation

When prompted (y/N), explicitly overwrite the lib/i18n.ts file.
Feel free to remove any unused locales after the installation.
JavaScript projects should add JSON locales manually after installation.

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

If you spend the time to add a locale, please consider contributing it back to the project!

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:

lib/i18n.ts
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

This section is under development.

You can use nuqs to persist the filter state in the URL.

  1. Install the nuqs and zod packages:
pnpm add nuqs zod
  1. Use the appropriate nuqs adapter for your framework from the nuqs docs.

  2. 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>()
  1. Create your query state:
const [filters, setFilters] = useQueryState<FiltersState>(
  'filters',
  parseAsJson(filtersSchema.parse).withDefault([]),
)
  1. 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 is a breaking change.

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,
        /* ... */
      }
    ),
  }),
]