Skip to content
WingoUI
ComponentsTemplatesDocsPricingAI agentsBlogChangelogContact
  1. Home
  2. Blog
  3. Component guides
  4. React Filter Builder: AND / OR Chips and Saved Views
Auf Deutsch lesen
  1. Blog
  2. Component guides
  3. React Filter Builder: AND / OR Chips and Saved Views
Component guides
Component guides

React Filter Builder: AND / OR Chips and Saved Views

SR
Serban Rusu · Founder of Wingo UI
Nov 6, 2026 · 11 min read

On this page

0%
  1. What is a filter builder, and when do you need one?
  2. Does shadcn have a filter builder?
  3. How do you add a filter builder to a React list?
  4. Which props matter most?
  5. How do saved views work?
  6. How do you connect it to a data table?
  7. How do you run the filter on the server?
  8. How does it behave on a phone?
  9. When is a simple filter panel the better UI?
  10. What are the common pitfalls?
  11. Where to start
  12. FAQ
    1. What is the difference between a filter builder and a query builder?
    2. Does shadcn/ui have a filter builder component?
    3. How do I store a filter in the URL?
    4. Should filters apply instantly or with an Apply button?
    5. How do I filter a React table on the server with AND / OR conditions?

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.

Published Nov 6, 2026

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, how to wire it to a Data Table and to a server, and when a plain 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 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 toUse
Search and toggle one or two statusesData 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 viewsFilter Builder
Store a query that drives an automation ("when X, do Y")Rule Builder

Does shadcn have a filter builder?

It does not. The shadcn Data Table guide (opens in a new tab) (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 (opens in a new tab). 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 (opens in a new tab) set maxLevels to unlimited nesting by default and offer independent combinators between rules, and its export docs (opens in a new tab) 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.

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
$ npx wingo-ui@latest add filter-builder
ProFilter Builder docs

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.

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)
$ npx wingo-ui@latest add filter-builder
ProFilter Builder docs

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.

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

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
$ npx wingo-ui@latest add filter-panel
ProFilter panel docs

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 or start from the pillar on React form components, 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. The fastest way to judge the builder is the playground on its docs page: 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

    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.

    Pro
  • Data Table

    A React data table on TanStack Table: search, filters, sorting, bulk actions, pinning, resizing, virtualization and cards on phones.

    Pro
  • Filter panel

    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.

    Pro

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.

Try it in your project

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.

75 components free, no account needed. Pro from $8 a month.

Open Filter Builder liveSee pricing
  • Filter builder
  • Query builder
  • Data table
  • Saved views
  • React

Share

SR

About the author

Serban Rusu

Founder of Wingo UI

Serban Rusu founded Wingo UI. He builds its component library, CLI and MCP server, and writes about React interfaces for phones and AI coding agents.

More from Serban
Keep reading

Related posts

All posts
Component guides

Audit Log UI in React: Filters, Diffs and CSV Export

Build an audit log UI in React that people can use: who did what to which record, day groups, filters, before and after diffs, CSV export and the event shape.

SRSerban Rusu·Nov 6, 2026·10 min read
Component guides

React Editable Table: Spreadsheet Grid or Inline Edit

Build a react editable table that works like a spreadsheet: typed cells, range selection, Excel copy and paste, undo, validation, or inline edit per cell.

SRSerban Rusu·Nov 5, 2026·9 min read
Component guides

React Empty State Component: First Use, No Results, Errors

Build a React empty state component for first use, no results and errors, with copy that says what happened and one action that moves people forward.

SRSerban Rusu·Oct 19, 2026·8 min read
Newsletter

Get new posts by email

New posts, product news and occasional offers from Wingo UI. We count clicks to keep it relevant.

No spam. Unsubscribe at any time.

WingoUI

Animated, configurable, mobile-first React components and blocks. Copy the source, make it yours, and let your coding agent build with it.

Product

  • Components
  • Templates
  • Pricing
  • AI agents
  • For teams
  • Theme generator

Resources

  • Docs
  • Contact
  • Programs
  • Blog
  • Changelog
  • Changelog RSS
  • llms.txt

Compare

  • Wingo UI vs paid kits
  • Wingo UI vs shadcn/ui
  • shadcn/ui alternatives
  • MUI alternatives
  • Tailwind Plus alternatives
  • Magic UI alternatives
  • Free components

Legal

  • Terms
  • Privacy
  • Refunds
  • License
  • Imprint

Component categories

  • Buttons & Actions
  • Inputs
  • Forms
  • Navigation
  • Overlays
  • Feedback
  • Data Display
  • Tables & Lists
  • Charts & Stats
  • Layout
  • Media
  • AI Kit
  • Text & Effects
  • Mobile
  • Commerce
  • Marketing Sections
  • Blocks
  • Hooks & Utilities

© 2026 Wingo UI

Wingo UI