# React Filter Builder: AND / OR Chips and Saved Views

> Build a React filter builder for any list: condition chips, AND / OR groups, saved views, a live result count, a phone bottom sheet and a server-side query.

- Author: [Serban Rusu](https://wingo-ui.com/blog/authors/serban), Founder of Wingo UI
- Published: Nov 6, 2026
- Category: [Component guides](https://wingo-ui.com/blog/category/components)
- Reading time: 11 min
- Canonical: https://wingo-ui.com/blog/react-filter-builder

## TL;DR

A React filter builder lets people combine conditions (field, operator, value) with AND / OR, nest one group, save the result as a named view and see how many rows match while they edit. Use it for back office lists where power users ask questions like "overdue or over $5,000, in Chicago". For shoppers browsing a catalog, a faceted filter panel with checkboxes and a price range is the better UI.

A React filter builder is the bar above a list where people add conditions one by one: City is Chicago, Balance is at least $5,000, Last invoice in the last 30 days. Each condition becomes a chip, the chips combine with AND or OR, and the count next to them says how many rows are left. It is a familiar pattern from issue trackers and spreadsheet-style databases, and it is what back office users ask for once a search box and a status dropdown stop being enough. This guide shows how to build one with the Wingo UI [Filter Builder](https://wingo-ui.com/components/filter-builder), how to wire it to a [Data Table](https://wingo-ui.com/components/data-table) and to a server, and when a plain [Filter Panel](https://wingo-ui.com/components/filter-panel) is the better choice.

## What is a filter builder, and when do you need one?

A filter builder edits a small tree: a root group with a combinator (`and` or `or`) and a list of conditions, where each condition is a field, an operator and a value. A nested group adds parentheses, so "in Chicago and (overdue or balance over $5,000)" is one root AND with a condition and an OR group inside it.

You need one when the questions people ask about a list change from day to day and include numbers, dates or OR. Sales ops filtering clients, support filtering tickets, finance filtering invoices: they do not want a new hard-coded tab for every question. If your list only ever needs "status is X" and a search, the [Data Table](https://wingo-ui.com/components/data-table) already does that with its column filters and quick filter chips, and a builder would be extra weight. Here is which filter UI component fits which job:

| You want people to | Use |
| --- | --- |
| Search and toggle one or two statuses | Data Table search, column filters and `quickFilters` |
| Browse a catalog by facets (price, brand, condition, distance) | Filter Panel |
| Combine any fields with operators, AND / OR and saved views | Filter Builder |
| Store a query that drives an automation ("when X, do Y") | [Rule Builder](https://wingo-ui.com/components/rule-builder) |

## Does shadcn have a filter builder?

It does not. The [shadcn Data Table guide](https://ui.shadcn.com/docs/components/data-table) (checked November 6, 2026) adds one text input that filters the email column through TanStack Table and says you can add filters to other columns the same way. Faceted filters, AND / OR conditions and saved views are not part of it, so "shadcn filters" or a "shadcn data table filter" with conditions in practice means picking a registry item or writing your own.

If you search for "react query builder shadcn", you will land on [React Query Builder](https://react-querybuilder.js.org/docs/compat). As of November 6, 2026 it ships compatibility packages for Ant Design, Chakra UI, Mantine, MUI and others, plus a shadcn registry item installed with `npx shadcn add https://react-querybuilder.js.org/r/query-builder.json`. Its [QueryBuilder docs](https://react-querybuilder.js.org/docs/components/querybuilder) set `maxLevels` to unlimited nesting by default and offer independent combinators between rules, and its [export docs](https://react-querybuilder.js.org/docs/utils/export) list `formatQuery` targets such as SQL, Prisma, Drizzle, MongoDB, CEL and JsonLogic. That is the better tool when you need a full rule tree that you store and run as a query, for example in a reporting tool. It does not cover the list side: chips, a result count, saved views or a phone layout. That list side is what the rest of this guide is about.

## How do you add a filter builder to a React list?

Install it with the CLI. It brings `lib/filters` (the filter model, evaluation and serialization) and the editors it uses:

```bash
npx wingo-ui@latest add filter-builder
```

Describe your fields once. Each field has an `id` (the row key, or an `accessor`), a `label` and a `type`: `text`, `number`, `currency`, `date`, `select`, `multi-select` or `boolean`. The type decides which operators the field offers and which editor opens: a text input, a number or money field, a calendar with quick ranges, or a searchable checklist.

```tsx
"use client"

import { useMemo, useState } from "react"
import { CalendarDays, MapPin, Wallet } from "lucide-react"
import { FilterBuilder, type FilterBuilderField, type FilterGroup, type FilterView } from "@/components/ui/filter-builder"
import { applyFilter, createFilterCondition, createFilterGroup } from "@/lib/filters"

type Client = { id: string; name: string; city: string; status: string; balance: number; lastInvoice: string | null }

const FIELDS: FilterBuilderField<Client>[] = [
  { id: "name", label: "Client", type: "text", group: "Client" },
  {
    id: "city",
    label: "City",
    type: "select",
    icon: <MapPin />,
    group: "Client",
    options: [
      { value: "New York", label: "New York" },
      { value: "Chicago", label: "Chicago" },
      { value: "Austin", label: "Austin" },
    ],
  },
  {
    id: "status",
    label: "Status",
    type: "select",
    group: "Client",
    options: [
      { value: "active", label: "Active", color: "#15803D" },
      { value: "overdue", label: "Overdue", color: "#DC2626" },
    ],
  },
  { id: "balance", label: "Balance", type: "currency", currency: "USD", icon: <Wallet />, group: "Finance" },
  { id: "lastInvoice", label: "Last invoice", type: "date", icon: <CalendarDays />, group: "Finance" },
]

const VIEWS: FilterView[] = [
  {
    id: "overdue",
    name: "Overdue",
    readOnly: true,
    pinned: true,
    filter: createFilterGroup("and", [createFilterCondition(FIELDS[2], { operator: "is", value: "overdue" })]),
  },
]

export function ClientFilters({ clients }: { clients: Client[] }) {
  const [filter, setFilter] = useState<FilterGroup>(() => createFilterGroup("and"))
  const rows = useMemo(() => applyFilter(clients, filter, FIELDS), [clients, filter])

  return (
    <FilterBuilder
      fields={FIELDS}
      value={filter}
      onValueChange={setFilter}
      views={VIEWS}
      resultCount={rows.length}
      previewCount={(draft) => applyFilter(clients, draft, FIELDS).length}
      locale="en-US"
      shortcut="shift+f"
      labels={{ allRecords: "All clients" }}
    />
  )
}
```

`applyFilter` runs the same tree against your rows: text matches ignore case and accents, dates compare by local day, and incomplete conditions are skipped, so a half-typed chip never empties the list. `previewCount` lets the phone sheet say "Show 3 results" before anything is applied.

> Live demo (Filter Builder): The demo starts on Status is Active and Balance at least $5,000. Press + Filter (or Shift+F), pick City and tick Chicago: the chip appears with its editor open and the count updates. Click the and between the first two chips to switch the whole filter to or. Try it at [Filter Builder](https://wingo-ui.com/components/filter-builder) and install it with `npx wingo-ui@latest add filter-builder`.

## Which props matter most?

Six props decide how the builder behaves; the rest are appearance (`size`, `radius`, `tone`) and controlled state.

- `variant`: `chips` (the default) puts every condition inline with its own editor popover. `panel` shows one Filters button with a count that opens a rows editor (one row per condition), and is where nested groups are easiest to edit.
- `applyMode`: `instant` calls `onValueChange` on every complete change (text after a 300ms pause). `apply` keeps a draft with Cancel and Apply, which is what you want when each change costs a request.
- `allowOr` and `allowGroups`: both default to `true`. Turn OR off for audiences that never need it; the builder then stays a plain AND list. Groups go one level deep, which covers "A and (B or C)" without turning the UI into a tree editor.
- `resultCount` and `loading`: the count sits in a polite live region, so screen readers hear "24 results" after each change, and `loading` shows a spinner while a server answers.
- `maxChips`: on desktop, chips past this number fold into a "+3" chip that opens the panel.
- `labels`: every string, English by default. It takes a partial object, so pass only the keys you translate; `DEFAULT_FILTER_BUILDER_LABELS` holds the full English set.

Fields can also limit their operators with `operators: [...]`. Do this when the server only supports some of them, so people cannot build a filter your backend silently ignores.

> Live demo (Filter Builder): The panel variant: open Filters, add a condition, then Add group to build (Balance at least $5,000 or Last invoice in the last 7 days). Try it at [Filter Builder](https://wingo-ui.com/components/filter-builder) and install it with `npx wingo-ui@latest add filter-builder`.

## How do saved views work?

A view is a named filter: `{ id, name, filter, readOnly?, pinned? }`. Pass them in `views` and the views menu lists them, pinned first, with All records on top. Picking one replaces the filter. Editing it afterwards marks it "(modified)" and offers Save changes or Save as new view.

Saving is yours to implement, because only your app knows where views live. Return the saved view from `onSaveView` and it becomes active. If the promise rejects, the builder keeps the name field open and announces the error, so nothing the user typed is lost. A rejected `onDeleteView` announces its own error and keeps the view in the menu.

```tsx
import { serializeFilter } from "@/lib/filters"

export function ClientFiltersWithViews({ clients }: { clients: Client[] }) {
  const [filter, setFilter] = useState<FilterGroup>(() => createFilterGroup("and"))
  const [views, setViews] = useState<FilterView[]>(VIEWS)
  const [activeViewId, setActiveViewId] = useState<string | null>(null)
  const rows = useMemo(() => applyFilter(clients, filter, FIELDS), [clients, filter])

  return (
    <FilterBuilder
      fields={FIELDS}
      value={filter}
      onValueChange={setFilter}
      resultCount={rows.length}
      views={views}
      activeViewId={activeViewId}
      onActiveViewChange={setActiveViewId}
      onSaveView={async ({ name, filter: draft, id }) => {
        const res = await fetch("/api/views", {
          method: "POST",
          headers: { "Content-Type": "application/json" },
          body: JSON.stringify({ id, name, filter: serializeFilter(draft) }),
        })
        if (!res.ok) throw new Error("Could not save the view")
        const saved: FilterView = { id: (await res.json()).id, name, filter: draft }
        setViews((list) => [...list.filter((v) => v.id !== saved.id), saved])
        return saved
      }}
      onDeleteView={async (viewId) => {
        const res = await fetch(`/api/views/${viewId}`, { method: "DELETE" })
        if (!res.ok) throw new Error("Could not delete the view")
        setViews((list) => list.filter((v) => v.id !== viewId))
      }}
    />
  )
}
```

`serializeFilter` (from `@/lib/filters`) turns the tree into a short URL-safe string, and `parseFilter` reads it back. The same string works for `?filter=` links, so a teammate can paste a URL and see exactly the list you saw.

## How do you connect it to a data table?

Keep the filter state above both components and pass the filtered rows down. The table keeps its own search, sorting, selection and phone cards; the builder decides which rows it gets.

```tsx
import { DataTable, createColumns } from "@/components/ui/data-table"

const col = createColumns<Client>()
const columns = col([
  col.accessor("name", { header: "Client" }),
  col.accessor("city", { header: "City" }),
  col.accessor("balance", { header: "Balance", type: "currency", footer: "sum" }),
  col.accessor("lastInvoice", { header: "Last invoice", type: "relative" }),
])

export function ClientsScreen({ clients }: { clients: Client[] }) {
  const [filter, setFilter] = useState<FilterGroup>(() => createFilterGroup("and"))
  const rows = useMemo(() => applyFilter(clients, filter, FIELDS), [clients, filter])

  return (
    <div className="flex flex-col gap-3">
      <FilterBuilder fields={FIELDS} value={filter} onValueChange={setFilter} resultCount={rows.length} locale="en-US" />
      <DataTable data={rows} columns={columns} caption="Clients" locale="en-US" searchPlaceholder="Search clients" />
    </div>
  )
}
```

Leave the table's per-column `filter` options off when the builder is there, so people have one place to filter instead of two that disagree. For lists of thousands of rows that stay in the browser, this is all you need. For the TanStack side of the table itself, see the [TanStack Table v9 data table walkthrough](https://wingo-ui.com/blog/tanstack-table-v9-data-table).

## How do you run the filter on the server?

`lib/filters` has no React in it, so the same `parseFilter` works in a route handler. For a database, walk the tree and turn each condition into a parameterized clause. The whitelist is the important part: field ids map to known columns, and anything unknown is dropped instead of interpolated.

```ts
// app/api/clients/route.ts
import { isFilterGroup, parseFilter, type FilterCondition, type FilterGroup } from "@/lib/filters"

const COLUMNS: Record<string, string> = { name: "name", city: "city", status: "status", balance: "balance", lastInvoice: "last_invoice" }

function conditionSql(c: FilterCondition, params: unknown[]): string | null {
  const column = COLUMNS[c.field]
  if (!column) return null
  const p = (value: unknown) => {
    params.push(value)
    return `$${params.length}`
  }
  const v = c.value
  switch (c.operator) {
    case "contains":
      return typeof v === "string" && v ? `${column} ILIKE ${p(`%${v.replace(/[%_\\]/g, "\\$&")}%`)}` : null
    case "is":
      return typeof v === "string" ? `${column} = ${p(v)}` : null
    case "any-of":
      return Array.isArray(v) && v.length ? `${column} = ANY(${p(v)})` : null
    case "gte":
      return typeof v === "number" ? `${column} >= ${p(v)}` : null
    case "lte":
      return typeof v === "number" ? `${column} <= ${p(v)}` : null
    case "in-last":
      // today plus the previous v - 1 days, the same window applyFilter uses
      return typeof v === "number" ? `${column} >= current_date - (${p(v)}::int - 1)` : null
    case "is-empty":
      return `${column} IS NULL`
    default:
      return null
  }
}

function toWhere(group: FilterGroup, params: unknown[]): string {
  const parts: string[] = []
  for (const node of group.conditions) {
    const sql = isFilterGroup(node) ? toWhere(node, params) : conditionSql(node, params)
    if (sql) parts.push(isFilterGroup(node) ? `(${sql})` : sql)
  }
  return parts.join(group.combinator === "or" ? " OR " : " AND ")
}

export async function GET(request: Request) {
  const filter = parseFilter(new URL(request.url).searchParams.get("filter"))
  const params: unknown[] = []
  const where = filter ? toWhere(filter, params) : ""
  // run `SELECT ... FROM clients ${where ? `WHERE ${where}` : ""}` with params on your Postgres client
  return Response.json({ where, params })
}
```

On the client, send `serializeFilter(filter)` from `onValueChange`, set `loading` while the request runs and pass the total from the response as `resultCount`. Combine it with `applyMode="apply"` when the query is slow. Limit each field's `operators` to the cases your `switch` handles, so the UI never offers one the server drops. Also match the client's text rules: `applyFilter` ignores case and accents, while `=` in Postgres does neither, so use `ILIKE`, a `citext` column or `unaccent()` if the browser and server counts must agree.

## How does it behave on a phone?

Below 768px the chips stay visible and scroll sideways, each with an × that removes it at once. Everything else moves into one bottom sheet behind a Filters button with the count: conditions as rows, and / or as a segmented control, and + Add filter drills into the field list and then the editor with a short slide. Nothing changes until the user taps "Show 24 results"; closing the sheet throws the draft away. That is deliberate: a popover anchored to a 32px chip would cover the list the user is trying to filter, and an instant filter behind a full-height sheet changes a list nobody can see. Touch targets grow to 44px on coarse pointers. Set `responsive={false}` only if your app is desktop-only.

## When is a simple filter panel the better UI?

When the people filtering are shoppers browsing a catalog. A shopper looking for a used phone under $300 wants to tick Apple, drag a price range and see "Show 1,240 results"; they never think in operators or OR groups. The [Filter Panel](https://wingo-ui.com/components/filter-panel) is built for that: a sidebar of facets that applies as you go on desktop (checkbox lists with counts, chips, price over a histogram, location with a radius, toggles) and a full-height sheet with a live count on phones.

> Live demo (Filter panel): Tick a brand, drag the price thumbs and toggle Delivery only: the sidebar applies each change, and on a phone the same filters open as a sheet with a counted Show results button. Try it at [Filter panel](https://wingo-ui.com/components/filter-panel) and install it with `npx wingo-ui@latest add filter-panel`.

A good rule: if you can list every useful filter in advance and each has a handful of values, use the panel. If users need to invent their own questions and save them, use the builder. Some apps use both: the panel on the public catalog, the builder in the admin screen behind it.

## What are the common pitfalls?

- **Chips that filter nothing.** A chip with an operator but no value looks active and is not. The builder removes an incomplete chip when its editor closes; if you build your own, do the same.
- **Two filter systems on one screen.** Column filters in the table header plus a builder above it give two answers to "why is this row hidden". Pick one.
- **OR without parentheses.** "Chicago and overdue or VIP" is ambiguous. Keep OR at the root or inside a visible group, and show the sentence (`describeFilter` in `lib/filters` writes it) where people can read it.
- **Silent server gaps.** If the backend ignores an operator, people see wrong counts. Restrict `operators` per field.
- **No count.** Without "24 results" people cannot tell whether a filter did anything. Pass `resultCount`, and `0` is a real answer.

For more guides like this one, browse the [component guides category](https://wingo-ui.com/blog/category/components) or start from the pillar on [React form components](https://wingo-ui.com/blog/react-form-components-guide), which uses the same field anatomy the editors here share.

## Where to start

The Filter Builder, the Data Table and the Filter Panel are Pro components, included in every plan on the [pricing page](https://wingo-ui.com/pricing). The fastest way to judge the builder is the playground on its [docs page](https://wingo-ui.com/components/filter-builder): switch `variant`, `applyMode` and `allowGroups` and watch what changes. When it fits, install it with `npx wingo-ui@latest add filter-builder` and point `fields` at your own list.

## Components in this post

- [Filter Builder](https://wingo-ui.com/components/filter-builder) (Pro): A React filter builder for any list: condition chips, AND / OR, nested groups, saved views and a live count, with one bottom sheet on phones. Install: `npx wingo-ui@latest add filter-builder`
- [Data Table](https://wingo-ui.com/components/data-table) (Pro): A React data table on TanStack Table: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones. Install: `npx wingo-ui@latest add data-table`
- [Filter panel](https://wingo-ui.com/components/filter-panel) (Pro): A React filter panel for faceted search: a sidebar that applies as you go on desktop, a full-height sheet with a live count on phones. Install: `npx wingo-ui@latest add filter-panel`

## FAQ

### What is the difference between a filter builder and a query builder?

A query builder usually edits a full rule tree with unlimited nesting and exports it to SQL, MongoDB or another query language, so it suits admin tools that store queries. A filter builder is the lighter, list-facing version: chips for each condition, AND / OR, one level of groups, saved views and a result count next to the list.

### Does shadcn/ui have a filter builder component?

No. As of November 2026 the shadcn Data Table guide shows a text input that filters one column through TanStack Table and points to the TanStack docs for more. AND / OR conditions, nested groups and saved views are left to you or to a third-party registry item.

### How do I store a filter in the URL?

Serialize the filter tree into a short URL-safe string and put it in a query parameter such as ?filter=. In Wingo UI, serializeFilter(filter) from lib/filters writes it and parseFilter(text) reads it back, returning null for a stale or hand-edited link so the page can fall back to no filter.

### Should filters apply instantly or with an Apply button?

Apply instantly when filtering is cheap, such as a list already in the browser, so people see the effect of each change. Use an Apply button when every change costs a server request or a slow query, and always keep a draft on phones, where the filter editor covers the list.

### How do I filter a React table on the server with AND / OR conditions?

Send the filter tree to an endpoint, walk it recursively, and turn each condition into a parameterized SQL clause joined by AND or OR, with groups in parentheses. Map field ids to columns through a whitelist so a client can never inject a column name or an operator you do not support.

---

Source: https://wingo-ui.com/blog/react-filter-builder
