Skip to content
Mendy UI

Filter system reference

Definitions, dynamic options, persistence, and controlled state.

Definitions and types

Import defineFilters, filter, bindFilters, remoteOptions, externalOptions, and jsonCodec from @/components/ui/filter-definition.

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.

FactoryValue
filter.selectOne option value or null
filter.multiSelectAn array of option values or null
filter.optionsString IDs from arbitrary data records, or null
filter.textTrimmed text or null
filter.tokensA 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.customYour value type, with a required codec, default, and clear value

Shared settings include label, defaultValue, clearValue, isActive, normalize, validate, codec, urlKey, suggestion, summary, renderSummary, renderEditor, 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 click and keeps the editor open. 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.

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 vendor name when all its companies 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 carrier-dependent query reads the current carrier 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:

PropertyPurpose
scopeCache identity for the organization/user/data source
paramsJSON-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, getLabelMap records to stable string IDs and labels
debounceMsSearch 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. Missing selected records remain removable and display as unavailable once resolution succeeds. A failed lookup preserves the selection and offers retry.

Use stable IDs and JSON-serializable parameters. Do not put credentials in scope or parameters. Authorization belongs to your data-fetching layer.

Paste

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

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:

OptionDefault / behavior
scopeRequired persistence scope
searchKeyq
markerKey_filters; change it when multiple bars share a page
rememberOff, or "session"
maxUrlLength2000 characters for the encoded URL
history"replace"; opt into "push" when browser history should track edits
shallowtrue; false enables the router's server-update behavior
onPersistenceErrorOptional 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.

The pure encodeFilters and decodeFilters functions from filter-state 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 line = bindFilters<LineFilters>();
const definitions = {
  carrier: line.field(
    "carrierId",
    filter.options({
      label: "Carrier",
      options: carriers,
      getValue: (carrier) => carrier.id,
      getLabel: (carrier) => carrier.name,
    }),
  ),
};
 
const filters = useControlledFilters({
  definitions,
  value: existingFilters,
  onPatch: (updates) => setExistingFilters(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. Fields outside the definitions are preserved. A binding's optional update(value, current) can update related fields atomically.

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

The filter menu stays open after applying any filter by default, including single selections, text, ranges, and custom editors. Continue with another filter, or dismiss with Escape, an outside click, or the filter button.

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, and close. 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.

Group related fields under one menu with groups={[{ id: "status-menu", label: "Status", fields: ["status", "pendingPort", "pendingSwap"] }]}. Grouping does not change state ownership or chip removal.

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.