Filters
Filter definitions and state
Definitions, dynamic options, persistence, and controlled state.
On this page
Definitions and types
Import defineFilters, filter, bindFilters, remoteOptions, externalOptions, and jsonCodec from @mendylanda/ui/filters.
defineFilters preserves each field's value type. FilterValues<typeof definitions> extracts the state type. String literal options infer literal unions. Use select and multiSelect for known choices; their default URL codecs reject values outside a static options array. Use options for dynamically fetched IDs that may be absent from the current page. All built-in fields use null as their default cleared value; defaultValue and clearValue can override that. Zero and false are active values. An empty array is inactive unless you override isActive.
| Factory | Value |
|---|---|
filter.select | One option value or null |
filter.multiSelect | An array of option values or null |
filter.options | String IDs from arbitrary data records, or null |
filter.text | Trimmed text or null |
filter.tokens | A deduplicated string array or null |
filter.numberRange | [minimum, maximum] or null; either endpoint can be null |
filter.dateRange | { from, to } or null, with ISO date strings or null endpoints |
filter.custom | Your value type, with a required codec, default, and clear value |
Shared settings include label, icon, defaultValue, clearValue, isActive, normalize, validate, codec, urlKey, suggestion, chipLabel, menuLayout, editorPadding, summary, renderSummary, renderEditor, menu, hidden, disabled, and removable. searchLabel, editorLabel, and placeholder customize editor labels. Application validation remains necessary at the API boundary.
validate returns an error string or undefined. Normalization runs before validation. Removing and clearing use the declared clear value. A custom codec parses and serializes the field's value; JSON codecs can use jsonCodec with a type guard.
Suggestions and summaries
suggestion: { value, label?, loading?, disabled? } creates an unapplied shortcut. A valid preset applies on the first click without opening the editor. Click the applied chip again to edit it. A suggestion without a value opens the editor without applying anything.
FilterBar keeps unapplied suggestions visible alongside applied chips by default. Applying one turns it into an applied chip in the same position; other suggestions stay available. Removing it restores the dashed suggestion. Set suggestions="when-empty" for empty-state shortcuts only, or suggestions="never" to hide them.
Choice fields omit their chip label by default. Text, tokens, dates, ranges, and custom fields retain it. Override with chipLabel: true, false, or a shorter string; the accessible field name stays intact.
Set a global summary on the bar and override it on individual definitions:
{ mode: "count", limit: 3 }shows three values and an additional count.{ mode: "ellipsis", maxWidth: 180 }truncates the displayed text to a width.{ mode: "all" }shows every value and allows wrapping.
The full values remain selected and available in the editor. renderSummary(value, choices) supports domain summaries, such as displaying a team name when all its members are selected. Resolved choices include their original record as data.
Existing query hooks
Pass query results as options, with loading, error, and retry. Use filter.options with getValue and getLabel for arbitrary records.
For external search or pagination, use externalOptions({ items, selectedItems, loading, error, retry, query, onQueryChange, hasMore, loadMore }). selectedItems resolves IDs that are outside the current page. When onQueryChange is supplied, the component displays the provided results without applying another local search filter.
The application continues owning its query hooks. A project query reads the current workspace IDs directly. Refreshing choices does not clear selected IDs. Define an explicit state update when a business rule requires clearing dependent values.
Async option sources
remoteOptions accepts:
| Property | Purpose |
|---|---|
scope | Cache identity for the organization/user/data source |
params | JSON-serializable request inputs, including dependent filters |
search({ query, cursor, params, signal }) | Return { items, cursor? } |
resolve({ ids, params, signal }) | Return records for selected IDs, independent of the current page |
getValue, getLabel | Map records to stable string IDs and labels |
debounceMs | Search delay; defaults to 200 ms |
Requests are shared within a filter bar, canceled when unused, and cached briefly. Changing scope or request parameters changes request identity. Stale results do not replace the current query. Selected labels show skeletons while resolution is pending. Known labels remain visible during refetches. After a successful lookup confirms a missing record, or a lookup fails, unresolved values fall back to their IDs and remain removable. Failed lookups offer retry in the editor.
Use stable IDs and JSON-serializable parameters. Do not put credentials in scope or parameters. Authorization belongs to your data-fetching layer.
Dates
filter.dateRange uses the shadcn Calendar. Click a day to apply it immediately. Click a second day to extend the range. The editor stays open by default, including when editing a chip. A single day sets both endpoints to that date. Values are local calendar dates in YYYY-MM-DD format; your API decides how to interpret inclusive endpoints and time zones. Existing open-ended ranges keep their null endpoint until edited. Clear date uses the field's clearValue.
Recognized input
Add recognize(token) to a definition. Return a typed value when recognized, or undefined when the token belongs elsewhere. pastePriority resolves overlapping recognizers when one should win. Equal-priority matches ask the user to choose.
The search field recognizes pasted input automatically. Typing and pressing Enter uses the same recognition. It splits input on commas, tabs, and newlines. It normalizes, validates, and deduplicates recognized values. Arrays merge with the existing selection; merge overrides that behavior. Unmatched and ambiguous values remain in search. Shift-paste bypasses recognition.
Use menu: false for a recognized field that does not need a menu entry. Its values still appear as editable, removable chips. hidden: true hides the field entirely and excludes it from recognition.
issueId: filter.tokens({
label: "Issue ID",
menu: false,
recognize: (token) => (/^ISSUE-\d+$/i.test(token) ? [token.toUpperCase()] : undefined),
});Text entered directly into an individual token editor belongs to that field. It uses the same normalization and validation when applied.
State and URL persistence
useFilters(definitions, { defaultValues?, defaultSearch?, onChange? }) owns local state.
useUrlFilters(definitions, options) uses nuqs. Options:
| Option | Default / behavior |
|---|---|
scope | Required persistence scope |
searchKey | q |
markerKey | _filters; change it when multiple bars share a page |
remember | Off, or "session" |
maxUrlLength | 2000 characters for the encoded URL |
history | "replace"; opt into "push" when browser history should track edits |
shallow | true; false enables the router's server-update behavior |
onPersistenceError | Optional storage/URL failure callback |
Use different URL keys for multiple independent bars. Duplicate keys within a definition set are rejected. Unrelated URL parameters are preserved. Arrays and ranges use JSON, strings remain readable, and explicit null is encoded separately from an absent default. Use urlKey and codec to preserve existing parameter conventions.
Applied changes update the URL; drafts and open menus do not. Nuqs handles browser navigation. Remembered values restore only when no managed URL parameters exist. Clearing removes remembered state. Storage access failures are reported without discarding the current in-memory overflow selection.
If a selection exceeds the URL budget, the full values are saved in session storage and the URL contains a session reference. Recent references are retained for navigation. shareable becomes false and persistenceMessage explains the limitation. Opening that reference in a different session reports that the values are unavailable. createLink(base) returns null when the complete current selection cannot be represented by the URL.
Filter values that look like JSON use a ~ui: URL prefix so routers cannot reinterpret them before the filter codec runs. Ordinary text stays readable. decodeFilters accepts both this representation and earlier JSON URLs; use it instead of manually parsing filter parameters. The prefix is transport syntax and is not passed to custom codecs.
With TanStack Router, wrap the route outlet in NuqsAdapter from nuqs/adapters/tanstack-router. The router's default search parser can remain enabled. The browser integration tests cover this adapter's updates, reloads, and back/forward navigation. nuqs currently describes its TanStack adapter as experimental and does not claim full TanStack Start support; this URL fix does not establish broader SSR compatibility for that adapter.
The pure encodeFilters and decodeFilters functions from @mendylanda/ui/filters/core can be used with server-readable definitions. Keep those definitions free of client-only imports when using them on a server.
Binding existing state
const project = bindFilters<ProjectFilters>();
const definitions = {
workspace: project.field(
"workspaceId",
filter.options({
label: "Workspace",
options: workspaces,
getValue: (workspace) => workspace.id,
getLabel: (workspace) => workspace.name,
}),
),
};
const filters = useControlledFilters({
definitions,
value: existingFilters,
onPatch: (updates) => setExistingFilters((current) => ({ ...current, ...updates })),
search: {
read: (value) => value.q?.join(", ") ?? "",
write: (text) => ({ q: text ? text.split(",").map((v) => v.trim()) : null }),
},
});onPatch receives one typed patch per operation, plus metadata identifying edit, removal, clear, paste, suggestion, or search. Merge patches into the previous state to preserve fields outside the definitions. A binding's optional update(value, current) can update related fields atomically.
project.composite(keys, { field, read, write }) maps one editor to several state keys. Its write function returns the complete mapped fields so applying or removing a date range updates both endpoints together. The editor receives the field's typed value; the mapping receives the selected state keys.
An existing state's field type must match its definition. Use filter.options for general string IDs, and literal option types for existing enums. Optional state fields read as the definition's default when undefined.
Responsive search
FilterBar and FilterRoot keep typing local and apply the main search after a 300 ms pause. This works with useFilters, useUrlFilters, and useControlledFilters. The controller's search is the applied query for matching data; the input preserves the exact typed text, including spaces. Applied queries are trimmed. Enter applies immediately, except while composing text. Clearing, pasting recognized filters, and selecting a filter cancel the timer and apply immediately with the current search draft. Closing the root or an external search/filter change discards pending typing. Older acknowledgements preserve newer typing.
Set searchDebounceMs on the bar or root to change the delay. Set it to 0 to use immediate controller writes or retain an existing draft adapter. Calls to the controller outside the root remain immediate.
For a standalone input, useFilterSearch(value, onChange, delay = 300, options?) returns value, setValue, and flush. It clears immediately, cancels pending writes on unmount, and preserves newer typing when an older applied value arrives. Empty searches call onChange(null). Values are unchanged by default; pass { normalize: (text) => text.trim() } to normalize applied queries while retaining the input text. Pass an applied filter or scope key as options.context so external changes also cancel unfinished typing. Call flush() on Enter. A custom clear-all action must call setValue("") as well as clearing its applied filters.
Grouped fields
groups combines related field IDs into one menu category. A group accepts id, label, fields, optional icon, and separatorBefore. Definitions not assigned to a group appear first, followed by groups in their configured order. Hidden fields are excluded.
Within a group, menuLayout: "inline" places the label beside its editor. Built-in single-choice fields render compact toggle buttons in this layout, with no search row; use short option labels such as Yes and No and a renderSummary that explains the selected meaning. Other field types keep their normal editor. A group has one Clear action for all removable fields, and duplicate headings are omitted.
Menu behavior
On desktop, each editor sits beside its selected row at its own height. The list matches the search field width and adjusts when the viewport cannot fit both panels. Opening the menu shows categories without selecting one. Focus alone does not open a category. Hover previews its editor; click, Enter, Space, or the arrow toward the editor moves focus inside. Use MendyUIProvider menuLayout="connected" for a full-height attached editor. Up and Down Arrow navigate the filter list; Left Arrow returns from an option to the list. Text inputs and calendars keep their own arrow-key behavior.
Below 640px, tapping a filter replaces the list with its editor. The Back button or Escape returns to the list; a second Escape closes it. The menu opens and switches editors without animation, and long lists and editors scroll within the available space.
The menu stays open after applying any filter by default, including single selections, text, ranges, and custom editors. Continue with another filter, or dismiss with an outside click or the filter button. On desktop, Escape closes it. Use the panel's Clear action to remove its applied values while keeping the editor open.
Each filter gets an icon based on its editor type. Supply icon: <YourIcon /> in its definition to choose another icon. Groups also accept icon.
Set <FilterBar closeMenuOnApply /> to close it after each successful change. A definition can override the bar with closeMenuOnApply: true or false. Invalid values keep the editor open. This setting controls the add-filter menu; editing an existing chip keeps its existing completion behavior. A custom editor can still call close() explicitly.
Custom editors and layout
renderEditor receives typed value, setValue, draft, setDraft, apply, close, autoFocus, and location. Honor autoFocus: false during hover previews; focus moves into the editor on explicit entry. location is "menu", "chip", or "inline". setValue applies immediately and updates the draft. In the filter menu, it follows closeMenuOnApply; in a chip, it stays open unless the value is cleared. setDraft changes only the local draft. apply() commits the draft and follows the menu's closeMenuOnApply setting; inside a chip it closes. Closing an editor discards an unfinished draft. Built-in date and number ranges apply each valid change immediately. Text and token editors save with Enter; Shift+Enter adds a line. For explicit submission, supply a custom renderEditor, for example FilterTextEditor with commitMode="apply".
For another layout, compose FilterRoot, FilterSearch or FilterMenu, FilterList, FilterClear, and FilterFeedback. They share the same controller and behavior as FilterBar. Custom editors and summaries can use your own markup; underlying primitives use the application's shadcn tokens.
Package styling, control replacements, option rendering, and custom layouts are documented in customization.
Option search
filter.select and filter.multiSelect include search by default. Set searchable: false for short lists such as Status. This opt-out also applies to dynamic and large lists. The search row is integrated into the editor, and remains available while results load or shrink. Custom editors own their search UI.
Single-choice filters contain only the options you define. Activating the selected value again clears it with the mouse, Enter, or Space. Set removable: false when a selection must remain applied.