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 schemaconst{ filterFactory }=createFilterSystem({ schema });// Define a filter set for a model. `where` values are typed per filter.constinvoiceFilters=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:constactiveFilters=[{identifier: "status",table: "Invoice",operator: "equal",value: "PAID"},];// Turn active filters into a Prisma-style where input.constwhere=buildWhere(invoiceFilters,activeFilters);constinvoices=awaitdb.invoice.findMany({ where });A set is configured with fields (schema-derived) and virtual (custom
filters that aren't a single field).
constinvoiceFilters=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: newDate().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";functionInvoiceList(){const{ where, control }=useFilter(invoiceFilters,{
client,// ZenStack client for the Filter model (stable reference)scope: { userId },// strongly-typed; persists/loads this user's filters});constinvoices=db.invoice.useFindMany({ where });return(<>{control.availableFilters.map((def)=>(<FilterChipkey={def.identifier}def={def}onApply={control.applyFilter}/>))}{control.activeFilters.map((f)=>(<ActivePillkey={f.identifier}filter={f}onRemove={control.removeFilter}/>))}<buttononClick={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 modelfilterClient: client,// same client useFilter uses for the Filter modelscope: { 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.
Each generated FilterOperatorDef carries a value (a stable key like
"equal") and a label. The defaults are English (is, contains, …). To
translate them, pass translateOperator to createFilterSystem — it receives
the operator key and returns the localized label:
import{createFilterSystem}from"zenstack-filter";import{schema}from"./zenstack/schema";import{t}from"./i18n";// any i18n libraryconst{ filterFactory }=createFilterSystem({
schema,translateOperator: op=>t(`filter.op.${op}`),// op: "equal" | "contains" | …});The function is called once per operator while a set's FilterDefs are
generated, so labels reflect the locale active at generation time. A live
locale switch re-translates only once the defs are regenerated — fine for
apps where the language is fixed at load (or a reload on switch is acceptable).
Field-level operators you supply with an explicit label keep that label;
only operators without one are passed through translateOperator.
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"{interfaceFilterMeta{icon?: string;group?: string;}}MIT © Cedrik Meis