Headless, type-safe filter system for ZenStack 3. Derive
filters from your schema, turn a user's active filters into a Prisma-style
where input, and — optionally — persist them per scope with a small set of
React hooks.
Community package — not affiliated with or endorsed by the ZenStack team.
- Schema-driven —
FilterDefs are derived from your ZenStack schema, so fields, relations, and types stay in sync with the source of truth. Dotted paths like"customer.organization.name"resolve relations automatically. - Type-safe — the model is bound before the config is checked, so every
wherevalue is narrowed to its filter's type. Noanyleaking into your query builders. - Headless — the core (
build,generate,operators, …) has zero React or UI dependency. The React hooks live behind separate entry points, so non-React consumers never pull inreact. - Persistence is optional — use the pure
buildWherecore on its own, or opt into per-scope persistence and saved views via hooks.
- Install
- Concepts
- Quick start
- Defining a filter set
- React hooks
- Required schema
- Generating the models
- Operators
- Entry points
npm install zenstack-filterPeer dependencies (install the ones you use):
npm install @zenstackhq/orm @zenstackhq/schema
# only if you use the React hooks:
npm install react| Peer | Range | Required for |
|---|---|---|
@zenstackhq/orm |
^3 |
types |
@zenstackhq/schema |
^3 |
schema helpers |
react |
>=18 |
hook entry points only (optional) |
A few terms recur throughout the API:
| Term | What it is |
|---|---|
| Filter set | A named collection of filters bound to one model. Created with filterFactory.Model(config); returned as an opaque handle you pass to buildWhere / hooks. |
FilterDef |
A single available filter (a field or a virtual one), generated from the set: its type, label, operators, and resolved relation path. |
ActiveFilter |
One filter the user has actually applied: { identifier, table, operator, value }. The list of these is what you turn into a where. |
where |
The Prisma-style input buildWhere produces — drop it straight into db.model.findMany({ where }). |
| View | A named, saved snapshot of a filter set (FilterView). The working state (viewId: null) and each view are separate buckets of persisted filters. |
The flow is always the same: define a set → collect active filters → build a
where. Persistence and views are an optional layer the React hooks add on
top of that core.
import { createFilterSystem } from "zenstack-filter";
import { buildWhere } from "zenstack-filter/build";
import { schema } from "./zenstack/schema"; // your generated ZenStack schema
const { filterFactory } = createFilterSystem({ schema });
// Define a filter set for a model. `where` values are typed per filter.
const invoiceFilters = filterFactory.Invoice({
fields: {
status: true,
total: true,
"customer.name": true, // relation path — resolved automatically
},
});
// `activeFilters` is whatever the user has applied, e.g. from your UI:
const activeFilters = [
{ identifier: "status", table: "Invoice", operator: "equal", value: "PAID" },
];
// Turn active filters into a Prisma-style where input.
const where = buildWhere(invoiceFilters, activeFilters);
const invoices = await db.invoice.findMany({ where });A set is configured with fields (schema-derived) and virtual (custom
filters that aren't a single field).
const invoiceFilters = filterFactory.Invoice({
// Optional stable key — required if you register two sets on the same model.
key: "invoices",
fields: {
// `true` accepts the schema-inferred type, operators, and label.
status: true,
"customer.name": true,
// …or override per field.
total: { label: "Amount", type: "number" },
priority: {
type: "select",
options: [
{ value: "low", label: "Low" },
{ value: "high", label: "High" },
],
},
},
// Virtual filters: not derived from one field. The `where` callback gets the
// typed value and returns a fully-typed where input for the model.
virtual: {
overdue: {
label: "Overdue",
type: "select",
options: [{ value: "yes", label: "Overdue only" }],
where: () => ({ dueDate: { lt: new Date().toISOString() }, status: "OPEN" }),
},
},
});Filter types: text · number · select · multiselect · date ·
dateRange · choice. Each has a default operator and a set of valid
operators — see Operators.
The hooks add persistence on top of the core. They expect a ZenStack-generated
client for your Filter (and FilterView) model — see
Required schema.
"use client";
import { useFilter } from "zenstack-filter/useFilter";
function InvoiceList() {
const { where, control } = useFilter(invoiceFilters, {
client, // ZenStack client for the Filter model (stable reference)
scope: { userId }, // strongly-typed; persists/loads this user's filters
});
const invoices = db.invoice.useFindMany({ where });
return (
<>
{control.availableFilters.map((def) => (
<FilterChip key={def.identifier} def={def} onApply={control.applyFilter} />
))}
{control.activeFilters.map((f) => (
<ActivePill key={f.identifier} filter={f} onRemove={control.removeFilter} />
))}
<button onClick={control.clearFilters}>Clear all</button>
{/* render `invoices.data` */}
</>
);
}useFilter returns { where, control }:
| Field | What it is |
|---|---|
where |
Prisma-style where input, ready for findMany. |
control.availableFilters |
FilterDef[] — what can be applied (filterable by filterAvailable). |
control.activeFilters |
ActiveFilter[] — what is currently applied. |
control.applyFilter(f) |
Persist (upsert) an active filter. |
control.removeFilter(f) |
Remove a single active filter. |
control.clearFilters() |
Remove all filters in the current bucket. |
control.hasErrors |
true if the last persistence mutation failed. |
Options: client (required), scope, enabled (gate persistence on/off),
viewId (null working state — the default — or a view id to edit that view
live), and filterAvailable (visibility predicate for the palette).
import { useFilterViews } from "zenstack-filter/useFilterViews";
const { views, activeView, saveAsNewView, renameView, deleteView } =
useFilterViews(invoiceFilters, {
viewClient, // ZenStack client for the FilterView model
filterClient: client, // same client useFilter uses for the Filter model
scope: { userId },
activeViewId, // the view currently open in your UI
});Point useFilter's viewId at activeView?.id to read and edit that view's
rows directly — open views are edited live, not copied into the working state.
useFilterOptions(def) resolves a filter's static array or loader options to
{ options, loading } for rendering a dropdown. For large datasets, supply a
search-first async source (useSearch / useResolve) — zenstack-filter/infiniteSource
provides createInfiniteSearch to wire infinite scroll onto an
useInfiniteQuery-style hook.
The hooks persist values as-is —
applyFilterwrites whatever you hand it (it only skips filters flaggeddisabled). Validate in your editor UI before callingapplyFilter.
Persistence (the useFilter / useFilterViews hooks) reads and writes two
models in your ZenStack schema: Filter and FilterView. The package owns
a fixed set of columns — id, filterSet, identifier, operator, value,
viewId, createdAt, updatedAt on Filter, and id, name, filterSet,
createdAt, updatedAt on FilterView.
You can scaffold these models with the bundled plugin instead of writing them by hand — see Generating the models. If you only use the headless core (
buildWhere,createFilterSystem) without the persistence hooks, you don't need these models at all.
This is exactly what the plugin scaffolds — write it by hand only if you'd rather not run the plugin:
model Filter {
id String @id @default(cuid())
filterSet String
identifier String
operator String
value Json
viewId String?
view FilterView? @relation(fields: [viewId], references: [id], onDelete: Cascade)
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add scope fields here (e.g. userId, organizationId) and fold them into the
// @@unique below so one owner's filters cannot collide with another's.
@@unique([filterSet, identifier, viewId])
@@index([viewId])
// TODO: restrict access for your app, e.g.
// @@allow('read,create,update,delete', auth() != null && userId == auth().userId)
@@allow('all', true)
}
model FilterView {
id String @id @default(cuid())
name String
filterSet String
filters Filter[]
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
// Add the same scope fields as on Filter and fold them into @@unique.
@@unique([filterSet, name])
// TODO: restrict access for your app.
@@allow('all', true)
}Instead of writing the models by hand, register the bundled plugin in your
schema.zmodel. On the next zen generate it scaffolds the two models into a
ZModel file you then import:
plugin filter {
provider = 'zenstack-filter/plugin'
output = './generated/filter.zmodel' // relative to the schema; default: filter.zmodel
filterModel = 'Filter' // optional, default: Filter
viewModel = 'FilterView' // optional, default: FilterView
}npx zen generateThen import the generated file once:
import './generated/filter'The file is written only once — regeneration is skipped as soon as the
models exist in your schema (whether scaffolded or hand-written). So after the
first run the file is yours: add scope fields, tighten the @@allow policy, add
relations, and re-run zen generate freely without losing changes. The scaffold
ships with @@allow('all', true) and a TODO — restrict it before going to
production.
| Option | Default | Description |
|---|---|---|
output |
filter.zmodel |
Target file, relative to the schema directory |
filterModel |
Filter |
Name of the generated filter model |
viewModel |
FilterView |
Name of the generated filter-view model |
The plugin needs
@zenstackhq/sdkand@zenstackhq/language— both come with a ZenStack 3 install. If you renamefilterModel, pass the same name tocreateFilterSystem({ schema, filterModel: 'SavedFilter' })— it flows throughFilterSetinto the hooks so the typedscopestays bound to the right model.
Each filter type ships with a default operator and a set of valid ones. Override
them per field via fields.<name>.operators / defaultOperator, or import the
helpers from zenstack-filter/operators (operatorsFor, defaultOperatorFor,
operatorsByType, …).
| Type | Default operator |
|---|---|
text |
contains |
number |
equal |
select |
equal |
multiselect |
contains |
date |
equal |
dateRange |
equal |
choice |
equal |
Available operators: equal, notEqual, greater, less, contains,
notContains.
date and dateRange pass their values through to Prisma untouched — you
decide the format (full ISO …Z, a date-only YYYY-MM-DD, a zoned offset, …).
Note that a date-only less/lte bound compares against midnight and excludes
the rest of that day; pass an end-of-day or exclusive next-day bound to include
the whole day.
The core is framework-agnostic; the React pieces are isolated so non-React
consumers never pull in react.
| Import | What it is |
|---|---|
zenstack-filter |
createFilterSystem, filterFactory |
zenstack-filter/build |
buildWhere — active filters → where input |
zenstack-filter/types |
shared types (FilterDef, ActiveFilter, ModelFilterConfig, FieldOverride, FilterMeta, …) |
zenstack-filter/operators |
filter operators + helpers |
zenstack-filter/generate |
generateFilterDefs / findFilterDef — list/look up a set's filters |
zenstack-filter/useFilter |
React: filter state + persistence |
zenstack-filter/useFilterViews |
React: saved filter views |
zenstack-filter/useFilterOptions |
React: async option loading |
zenstack-filter/infiniteSource |
React: infinite option source |
zenstack-filter/plugin |
ZenStack plugin: scaffold the persistence models |
FilterMeta is empty by default. Augment it to attach project-specific keys
(icons, groups, …) to field overrides and virtual filters — the package itself
never reads them:
declare module "zenstack-filter/types" {
interface FilterMeta {
icon?: string;
group?: string;
}
}MIT © Cedrik Meis