Better Tables

Filtering

Typed FilterState objects, the full operator reference by column type, per-column filter config, and AND/OR query groups.

Filters are typed FilterState objects (or nested FilterGroupNode trees for AND/OR). The UI reads column metadata from defineTable(); the adapter translates filters to SQL.

import type { FilterState } from '@better-tables/core';

const filters: FilterState[] = [
  { columnId: 'status', type: 'option', operator: 'is', values: ['open'] },
  { columnId: 'customer.company', type: 'text', operator: 'contains', values: ['Acme'] },
];

await tables.fetchData(ticketsTable, { filters });

A bare FilterState[] means implicit AND. Relationship paths in columnId (like customer.company) trigger JOIN generation in the Drizzle adapter.

Each filter's operator field is type-narrowed per column type — passing a number operator to a text column is a compile error.

Operator reference

Wrong-type operators are compile errors, so this table is the complete legal set. isNull / isNotNull are available on every type; operators marked (no value) take an empty values array.

Column typeOperators
textcontains, equals, startsWith, endsWith, isEmpty (no value), isNotEmpty (no value)
numberequals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between (2 values), notBetween (2 values)
dateis, isNot, before, after, between (2 values), notBetween (2 values), isToday, isYesterday, isThisWeek, isThisMonth, isThisYear (relative ones take no value)
optionis, isNot, isAnyOf, isNoneOf
multiOptionincludes, excludes, includesAny, includesAll, excludesAny, excludesAll
booleanisTrue, isFalse (no value)
jsoncontains, equals, isEmpty, isNotEmpty

Source of truth: packages/core/src/types/filter-operators.ts — labels, value arity, and per-type unions are all derived from it.

Per-column filter config

Builders accept a FilterConfig to constrain or tune the filter UI and adapter behavior:

t.number('reopenCount')
  .filterable()
  .numberOperators(['between', 'greaterThan']) // restrict the operator menu
  .range(0, 50);                               // min/max for the input

t.text('subject').searchable({ debounce: 500, includeNull: true });
FilterConfig fieldPurpose
operatorsOptional UI allow-list, intersected with the built-in registry for the column type. When omitted, built-in column types get the full getOperatorsForType(type) menu (includes isNull / isNotNull); restrict with .textOperators() / .numberOperators() / etc. custom columns have an empty registry entry, so the operator menu renders empty for them — setting operators explicitly does not populate it (an explicit list only seeds the initial operator when the chip is added).
optionsChoices for option / multiOption filters (schema enums fill this automatically — see Auto columns); each option takes value, label, and optional color / icon / count
min / maxBounds for number inputs
debounceText input debounce in ms (default 300)
includeNullAlso match null/undefined values
validation(value) => boolean | string — reject or message invalid filter input
includeTimeDate filters: include the time component
formatDate filters: display format
customComponentReplace the filter input with your own component

Note that .searchable() on a text column is filter sugar: it enables the common text operators (contains, equals, startsWith, endsWith, isEmpty, isNotEmpty) plus debounce / includeNull. It does not add isNull / isNotNull, and it does not add the column to a separate global search.

Query groups (AND / OR)

For OR and nesting, pass a FilterGroupNode tree instead of a flat array. A bare FilterState[] means implicit AND. Core does not wrap arrays into a group before dispatch — adapters must accept both shapes:

await tables.fetchData(ticketsTable, {
  filters: {
    kind: 'group',
    logic: 'or',
    children: [
      { columnId: 'status', type: 'option', operator: 'is', values: ['open'] },
      {
        kind: 'group',
        logic: 'and',
        children: [
          { columnId: 'status', type: 'option', operator: 'is', values: ['resolved'] },
          { columnId: 'reopenCount', type: 'number', operator: 'greaterThan', values: [2] },
        ],
      },
    ],
  },
});

Nested groups round-trip through URL sync and programmatic setFilterNode (see URL State). Server-derived aggregate columns (t.count / t.aggregate — see Derived columns) participate in the same trees: Drizzle substitutes correlated-subquery predicates in place so OR groups keep union semantics.

The filter bar stays a flat implicit-AND chip list — visual AND/OR tree editing is not shipped yet:

Filter bar grouping

The filter dropdown groups columns automatically (dates together, statuses together, …). Tune it with autoGroupFilters on <BetterTable>:

<BetterTable
  id="tickets"
  columns={columns}
  data={data}
  autoGroupFilters={{ groupLabels: { dates: 'Timeline' } }}
/>

AutoGroupConfig accepts enabled, autoGroup, customGroups (explicit FilterGroup[] definitions), and groupLabels. Pass groups directly for fully manual grouping.

Client managers

FilterManager and TableStateManager in @better-tables/core power the UI store. Pair with URL state so filters survive refresh and share.

On this page