---
id: "packages/active-grid/filter-builder"
title: "Filter builder"
description: "Composing AND/OR filter expressions across columns with the opt-in filter builder."
url: "/packages/active-grid/filter-builder"
locale: "en"
revision: "01965e4725bf6b90"
---

# Filter builder

> [!NOTE]
> **The package's own guide**
>
> This guide is the package's `docs/guides/filter-builder.md`, included
> unchanged from the package as it is in Foundation. Links to the package's
> internal design documents keep their text.

The filter-builder is an advanced UI for composing **AND/OR filter expressions**
across multiple columns. It lives alongside the regular per-column filter row
and is opt-in via `enableFilterBuilder`.

## Mode support — client only

**The filter-builder works in `mode="client"` only.** The composed expression is
evaluated in the browser into a row-predicate; nothing about it is serialised
onto the wire. `ServerFetchParams` carries `filters`, `sorting` and
`globalFilter` — it has no expression field, and the resource layer in
`@workspace/active-resources` has no counterpart to receive one.

Consequently, on a `mode="server"` grid the toolbar button and the panel are
**not rendered**, and setting `enableFilterBuilder` emits a dev-mode warning
(once per grid, silent in production). Withholding the UI is deliberate: a
rendered builder would invite a user to compose an expression that the grid then
discards without saying so. In server mode, use the per-column filter row — those
filters do reach the server.

Wiring the expression tree into the server query is roadmap item `F-12` (an
expression field on `ServerFetchParams` plus a recursive translation with the
same allowlist and type validation that individual filters already get). Until
that lands, this is a client-mode feature.

The same applies to `externalFilter`, which shares the predicate path: it is
applied in client mode only, and dev-warns in server mode. See
`../api/grid-props.md`.

## When to use it

Use the regular per-column filter row when consumers compose filters one
column at a time (the default for most data-grids). Use the filter-builder
when consumers need:

- **OR-logic between conditions**: "name contains 'smith' OR email ends with
  '@acme.com'"
- **Mixed AND/OR composition**: "(role = admin AND status = active) OR
  (createdAt > 2024 AND status = trial)"
- **Persistent named filters**: the filter-tree serializes to a stable shape
  for URL-sync or saved-views

## Enabling

```tsx
import {ActiveGrid} from '@workspace/active-grid';

<ActiveGrid
  gridId="users"
  mode="client"
  data={users}
  columns={columns}
  enableFilterBuilder
/>;
```

The filter-builder panel appears in the toolbar. The regular per-column
filter row remains active — the two are combined with AND.

A condition's value uses the grid's own controls. A number is typed in the
notation of the grid's presentation (the same input as the number filter and
the number editor) and stored as a number; half-typed or unreadable text leaves
the condition's value as it was. A date is typed in the presentation's date
notation or picked from a calendar behind its own button, and stored as an ISO
calendar day (`YYYY-MM-DD`). Emptying a number or date field makes the value
absent, as in a new condition. Other columns take text.

A condition filters only once it is complete. Until its operator has every
value it reads, it is ignored: adding a condition, or emptying its value, leaves
the rows as they were. A missing value is `undefined`, `null`, `''` or a
non-finite number; `0` and `false` are values. A range (`between`, `inRange`)
needs both ends. The no-value operators `isEmpty` and `isNotEmpty` are complete
without a value. An AND or OR group judges only its complete conditions, and a
group with none matches every row. The toolbar button follows the same rule: its
count and its active state show only the conditions that filter, so an
unfinished condition can stay in the panel without the grid looking filtered.
The panel's own count and its clear action cover every condition in it.

On `mode="server"` this same configuration renders no button and no panel, and
dev-warns instead. See [Mode support](#mode-support--client-only).

## The expression tree

A filter expression is a tree of `FilterCondition`-leaves wrapped in
`FilterGroup`-nodes with `combinator: 'and' | 'or'`:

```typescript
import type {
  FilterExpression,
  FilterCondition,
  FilterGroup,
  FilterExpressionNode,
} from '@workspace/active-grid';

// A filter condition on one column
interface FilterCondition {
  id: string;
  field: string; // column id
  operator: FilterOperator;
  value?: unknown;
  valueTo?: unknown; // for range operators
}

// A group with AND/OR logic
interface FilterGroup {
  id: string;
  combinator: 'and' | 'or';
  children: FilterExpressionNode[]; // can contain conditions or nested groups
}

// FilterExpressionNode is a discriminated union
type FilterExpressionNode = FilterCondition | FilterGroup;

// The root is always a group
type FilterExpression = FilterGroup;
```

The `isFilterGroup(node)` type-guard narrows a `FilterExpressionNode` to
`FilterGroup` (it returns `true` when the node has a `combinator` field).

### Example: composed expression

```typescript
const expression: FilterExpression = {
  id: 'root',
  combinator: 'or',
  children: [
    {
      id: 'group-1',
      combinator: 'and',
      children: [
        {id: 'c1', field: 'role', operator: 'equals', value: 'admin'},
        {id: 'c2', field: 'status', operator: 'equals', value: 'active'},
      ],
    },
    {
      id: 'group-2',
      combinator: 'and',
      children: [
        {
          id: 'c3',
          field: 'createdAt',
          operator: 'greaterThan',
          value: new Date('2024-01-01'),
        },
        {id: 'c4', field: 'status', operator: 'equals', value: 'trial'},
      ],
    },
  ],
};
```

This represents `(role = admin AND status = active) OR (createdAt > 2024
AND status = trial)`.

## Initial expression and change observation

### Uncontrolled (recommended)

Let the grid manage its own expression-state. Optionally seed it with
`initialFilterExpression`.

```tsx
<ActiveGrid
  enableFilterBuilder
  initialFilterExpression={savedExpression}
  // ...
/>
```

### Observe changes

Subscribe to changes via `onFilterExpressionChange` for a host-owned saved-view
integration. `initialFilterExpression` seeds the grid; it is not a controlled
value prop. Observing the expression does not transfer its runtime ownership
to the host.

```tsx
const [expression, setExpression] = useState<FilterExpression | null>(null);

<ActiveGrid
  enableFilterBuilder
  initialFilterExpression={savedExpression}
  onFilterExpressionChange={(expr) => {
    setExpression(expr);
    // Persist to URL or saved-view storage
  }}
  // ...
/>;
```

## Evaluating expressions client-side

The grid evaluates the expression internally. If you need to filter rows
yourself (e.g., for a custom row-list outside the grid), use
`evaluateFilterExpression`:

```typescript
import {evaluateFilterExpression} from '@workspace/active-grid';

const filtered = rows.filter((row) =>
  evaluateFilterExpression(row, expression),
);
```

That evaluator is exported independently of the grid, so a consumer who needs
AND/OR composition against a server dataset can build the expression tree
themselves and translate it in their own query layer. The grid does not do that
translation for them today — that is `F-12`.

## Combining with per-column filters

The per-column filter row stays active when `enableFilterBuilder` is on.
The two are combined with AND. A user can pin a per-column filter (e.g.
"status = active") while building a complex expression over other columns.

## Persistence — not built in

**The filter-expression is not persisted by the grid.** Neither driver carries
it: `enableUrlSync` serialises `page`, `pageSize`, `sort`, `filters` and
`search` (`src/state/url-driver.ts`), and `enablePersistence` validates and
stores `sorting`, `columnFilters`, `columnOrder`, `columnSizing` and
`globalFilter` (`src/state/validate-persisted-state.ts`). There is no
`filterExpression` key in either.

To persist an expression, own it in the consumer: subscribe with
`onFilterExpressionChange`, write it wherever you keep view-state, and seed it
back through `initialFilterExpression` on mount. Keep the tree
JSON-serializable (no `Date`-objects — use ISO strings) if you round-trip it
through a URL or storage.

## A11y

The filter-builder panel uses the shared Base UI primitives and provides full
keyboard navigation. Tab through the group/condition tree; Enter to open
operator-selects; Escape to close popovers.

## Reference

- `FilterExpression`, `FilterCondition`, `FilterGroup`, `FilterExpressionNode`,
  `isFilterGroup` — exported from `@workspace/active-grid`
- `evaluateFilterExpression(row, expression)` — client-side row evaluator
- Server mode: the builder is withheld and dev-warns; per-column filters are the
  server-capable route (see `../api/resource-mode.md`)
- API reference: `../api/filters.md`
