Custom UI
Build your own filter UI with the hooks behind the ready-made one.
The ready-made filter UI is built entirely on the hooks from @querycn/filter-react. Use them to build your own: a sidebar of checkboxes, quick-filter buttons, a different builder. The hooks handle the state, validation, the URL and the API request; you only render.
Need small changes? It's usually easier to edit the copied components.
Which hook do I need?
| You want to… | Use |
|---|---|
| Load or show data for the applied filter | useAppliedFilter |
| Build the editing panel: add rules, Apply, Clear all | useFilter |
| Render one rule row: field, operator and value pickers | useFilterRule |
| Show warnings on a rule | useRuleWarnings |
| Show options for a select field, static or loaded | useFieldOptions |
All of them must be used inside a FilterProvider.
A minimal builder
A panel with rule rows, using plain HTML elements:
import type { FilterRule } from "@querycn/filter-core"
import { useFilter, useFilterRule } from "@querycn/filter-react"
export function MyFilterPanel() {
const { state, isDirty, addRule, apply, reset } = useFilter()
return (
<form onSubmit={(event) => { event.preventDefault(); apply() }}>
{state.rules.map((rule) => <MyRuleRow key={rule.id} rule={rule} />)}
<button type="button" onClick={() => addRule()}>Add filter</button>
<button type="button" onClick={reset}>Clear all</button>
<button type="submit" disabled={!isDirty}>Apply</button>
</form>
)
}
const MyRuleRow = React.memo(function MyRuleRow({ rule }: { rule: FilterRule }) {
const { fields, operators, arity, setField, setOperator, setValue, remove } = useFilterRule(rule)
return (
<div>
<select value={rule.field} onChange={(e) => setField(e.target.value)}>
<option value="" disabled>Field</option>
{fields.map((f) => <option key={f.name} value={f.name}>{f.label}</option>)}
</select>
<select value={rule.operator ?? ""} onChange={(e) => setOperator(e.target.value)}>
<option value="" disabled>Operator</option>
{operators.map((o) => <option key={o.id} value={o.id}>{o.label}</option>)}
</select>
{arity === "single" && (
<input
value={typeof rule.value === "string" || typeof rule.value === "number" ? rule.value : ""}
onChange={(e) => setValue(e.target.value)}
/>
)}
<button type="button" onClick={remove}>×</button>
</div>
)
})Draft and applied
The provider keeps two versions of the filter:
- The draft is what users are editing. Rules can be incomplete.
useFilteranduseFilterRulework on it. - The applied filter is what's in the URL, and what your data follows.
useAppliedFilterreads it.
apply() copies the complete draft rules to the applied filter. discard() throws the draft away and starts again from the applied filter; the ready-made popover calls it when closed without applying. The applied filter also changes on reset(), on a chip's ×, and when the URL changes (Back / Forward, a shared link).
FilterProvider
import { FilterProvider } from "@querycn/filter-react"
<FilterProvider fields={orderFields} adapter={adapter}>
{children}
</FilterProvider>Prop
Type
Define fields, serializer, messages and registry outside your components (or in useMemo). A new one on every render rebuilds everything.
useAppliedFilter
The applied filter and what's built from it. Use it where you load or show data.
const { query, queryKey, activeCount } = useAppliedFilter()| Property | Description |
|---|---|
query | The API request params, from the serializer. |
queryKey | A short string of the filter's URL params (status__eq=paid, "" when empty). Use it as a cache key. |
activeCount | How many rules are applied, e.g. for a badge. |
state | The applied FilterState: complete rules only. |
removeRule(id) | Removes one applied rule right away, like a chip's ×. |
ruleIssues | Warnings per rule id: "conflict" or "unsupported". |
context | { fields, registry }, ready for core functions like applyFilter. |
messages | The UI text. |
With a custom serializer, pass its output type: useAppliedFilter<Prisma.OrderWhereInput>().
Outside a provider, it returns an empty filter instead of throwing, so a table can call it whether or not a filter is set up.
useFilter
The draft and every editing action. It re-renders on each keystroke, so only use it in the editing UI.
| Property | Description |
|---|---|
state | The draft FilterState, incomplete rules included. |
isDirty | Applying would change the applied filter. |
canAddRule | false once maxRules is reached. |
addRule(field?) | Adds a rule, optionally with a field already picked. |
removeRule(id) | Removes a draft rule. |
setField(id, field) | Changes the field. The operator goes back to the field's default, and the value is cleared. |
setOperator(id, operator) | Changes the operator. The value is kept if its shape stays the same. |
setValue(id, value) | Sets the value. Raw text is fine, like "1.": it's checked on apply. |
setJoin(join) | "and" or "or". |
apply() | Applies the complete rules and writes them to the URL. |
reset() | Clears the filter and applies at once. |
discard() | Drops the edits that weren't applied. |
supportsOperator(id) | Whether the serializer can send this operator. |
Actions always read the latest draft, so setValue(…) followed by apply() in the same handler applies the new value.
useFilterActions() returns the same actions without state, isDirty and canAddRule. It doesn't change while users type, so memoized components that only need actions don't re-render.
useFilterRule
Everything one rule row needs, bound to that rule:
| Property | Description |
|---|---|
field | The rule's field definition, or undefined before one is picked. |
fields | The fields to offer: those with at least one operator the serializer can send. |
operators | { id, label, supported }[] for the field. An operator from the URL that the serializer can't send is listed last, with supported: false. |
arity | The operator's value shape ("none", "single", "range", "multi"), or null before one is picked. Pick the value input from it. |
setField, setOperator, setValue, remove | Actions for this rule. |
useRuleWarnings
useRuleWarnings(rule) returns the warnings for one draft rule, like ["reversedRange"]. Get the text from messages.warnings[key]. It reads the draft, so use it in a small component of its own rather than in a memoized row.
useFieldOptions
Options for a select or multiSelect field, static or loaded from your API:
const { options, loading, error, query, search, retry, getLabel } = useFieldOptions(field, {
selected: currentValues, // values that need a label
enabled: open, // only load the list while the dropdown is open
})| Option | Default | Description |
|---|---|---|
selected | [] | Values to fetch labels for, with resolveLabels. |
enabled | true | Set to false while the list is closed: labels still load, the list doesn't. |
debounceMs | 300 | How long to wait after typing before loading. |
| Result | Description |
|---|---|
options | Options for the current search. The previous list stays while the next one loads. |
loading, error | The current request's state. |
query, search(text) | The search text and its setter. |
retry() | Loads the current search again after an error. |
getLabel(value) | A value's label, or the value itself until it's known. |