Skip to main content

Overview

@woltz/react-rich-domain provides React hooks and UI components for seamless integration with rich-domain’s Criteria API. It’s distributed via the shadcn/ui registry, making installation simple and allowing you to own the code.

useCriteria Hook

Full-featured hook for managing filters, sorting, pagination, and search

Filter Component

Pre-built UI component for building dynamic filter interfaces

URL Sync

Automatic synchronization with URL query parameters

Persistence

Optional localStorage persistence for filter state

Installation

Install components using the shadcn CLI:
# Install the useCriteria hook
npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/use-criteria.json"
# Install the Filter component (includes useCriteria as dependency)
npx shadcn add "https://tarcisioandrade.github.io/rich-domain/packages/react-rich-domain/public/r/filter.json"
The Filter component requires shadcn/ui components: button, input, popover, and calendar.

What Gets Installed

src/
├── hooks/
│   └── use-criteria.ts          # The main hook
├── types/
│   └── use-criteria.type.ts     # TypeScript types
├── utils/
│   └── persistence.ts           # URL and localStorage utilities
├── components/
│   └── filter/
│       ├── filter.tsx           # Main Filter component
│       ├── filter-row.tsx       # Individual filter row
│       ├── filter-field-selector.tsx
│       ├── filter-operator-selector.tsx
│       ├── filter-value-selector.tsx
│       ├── filter-date-value.tsx
│       └── filter-boolean-value.tsx
└── lib/
    └── filter-utils.ts          # Filter utilities

useCriteria Hook

A comprehensive hook for managing Criteria state in React applications.

Basic Usage

import { useCriteria } from "@/hooks/use-criteria";

interface User {
  id: string;
  name: string;
  email: string;
  status: "active" | "inactive";
  age: number;
  createdAt: Date;
}

function UserList() {
  const {
    criteria,
    filters,
    sorting,
    pagination,
    search,
    addFilter,
    removeFilter,
    clearFilters,
    setSort,
    setPage,
    setSearch,
    reset,
  } = useCriteria<User>({
    pageSize: 20,
  });

  // Use criteria.toJSON() to send to your API
  const fetchUsers = async () => {
    const response = await fetch(
      `/api/users?${new URLSearchParams(criteria.toJSON())}`
    );
    return response.json();
  };

  return <div>{/* Your UI */}</div>;
}

Options

interface UseCriteriaOptions<T> {
  // Pagination
  initialPage?: number; // Default: 1
  pageSize?: number; // Default: 20

  // Initial state
  initialFilters?: Filter<FieldPath<T>, unknown>[];
  initialSort?: Order[];
  initialSearch?: {
    fields: FieldPath<T>[];
    value: string;
  };

  // Callbacks
  onChange?: (criteria: Criteria<T>) => void;

  // Persistence
  persistKey?: string; // localStorage key
  syncWithUrl?: boolean; // Sync with URL params (default: false)
}

Return Value

interface UseCriteriaReturn<T> {
  // The Criteria instance
  criteria: Criteria<T>;

  // Derived state
  filters: Filter<string, unknown>[];
  sorting: Order[];
  pagination: { page: number; limit: number; offset: number };
  search: { fields: FieldPath<T>[]; value: string } | null;

  // Filter methods
  addFilter: <K extends FieldPath<T>>(
    field: K,
    operator: OperatorsForType<PathValue<T, K>>,
    value?: FilterValueFor<PathValue<T, K>>,
    options?: CriteriaOptions
  ) => void;
  removeFilter: (index: number) => void;
  clearFilters: () => void;
  addOrReplaceByIndex: (props: {
    field: FieldPath<T>;
    operator: OperatorsForType<PathValue<T, FieldPath<T>>>;
    value?: FilterValueFor<PathValue<T, FieldPath<T>>>;
    options?: CriteriaOptions;
    replaceIndex?: number;
  }) => void;

  // Sort methods
  setSort: (field: FieldPath<T>, direction: OrderDirection) => void;
  toggleSort: (field: FieldPath<T>) => void;
  clearSort: () => void;

  // Pagination methods
  setPage: (page: number) => void;
  nextPage: () => void;
  prevPage: () => void;
  setPageSize: (size: number) => void;

  // Search methods
  setSearch: (fields: FieldPath<T>[], value: string) => void;
  clearSearch: () => void;

  // Utility methods
  reset: () => void;
  getFilterByField: (
    field: FieldPath<T>
  ) => Filter<string, unknown> | undefined;
  getSortByField: (field: FieldPath<T>) => Order | undefined;
}

With React Query

import { useQuery } from "@tanstack/react-query";
import { useCriteria } from "@/hooks/use-criteria";

function UserList() {
  const { criteria, filters, setPage, addFilter, clearFilters } =
    useCriteria<User>({
      pageSize: 10,
      syncWithUrl: true, // Sync state with URL
    });

  const { data, isLoading } = useQuery({
    queryKey: ["users", criteria.toJSON()],
    queryFn: () => fetchUsers(criteria),
  });

  return (
    <div>
      {/* Filter controls */}
      <button onClick={() => addFilter("status", "equals", "active")}>
        Active Only
      </button>
      <button onClick={clearFilters}>Clear Filters</button>

      {/* Table */}
      {isLoading ? <p>Loading...</p> : <table>{/* ... */}</table>}

      {/* Pagination */}
      <button
        onClick={() => setPage(data.meta.page - 1)}
        disabled={!data.meta.hasPrevious}
      >
        Previous
      </button>
      <span>
        Page {data.meta.page} of {data.meta.totalPages}
      </span>
      <button
        onClick={() => setPage(data.meta.page + 1)}
        disabled={!data.meta.hasNext}
      >
        Next
      </button>
    </div>
  );
}

URL Synchronization

Enable syncWithUrl to automatically sync criteria state with URL query parameters:
const { criteria, addFilter } = useCriteria<User>({
  syncWithUrl: true,
});

// Adding a filter updates the URL:
addFilter("status", "equals", "active");
// URL: ?status:equals=active&page=1&limit=20

// Users can bookmark and share filtered views!

localStorage Persistence

Use persistKey to persist criteria state across page reloads:
const { criteria, filters } = useCriteria<User>({
  persistKey: "user-list-filters",
});

// Filters are automatically saved to localStorage
// and restored on component mount

Filter Component

A pre-built, customizable filter UI component that integrates with useCriteria.

Basic Usage

import { useCriteria } from "@/hooks/use-criteria";
import { Filter } from "@/components/filter/filter";
import type { QueryFilter } from "@/lib/filter-utils";

interface User {
  id: string;
  name: string;
  email: string;
  status: "active" | "inactive" | "pending";
  age: number;
  createdAt: Date;
}

// Define filterable fields
const userFields: QueryFilter[] = [
  {
    field: "name",
    fieldLabel: "Name",
    type: "string",
  },
  {
    field: "email",
    fieldLabel: "Email",
    type: "string",
  },
  {
    field: "status",
    fieldLabel: "Status",
    type: "string",
    options: [
      { value: "active", label: "Active" },
      { value: "inactive", label: "Inactive" },
      { value: "pending", label: "Pending" },
    ],
  },
  {
    field: "age",
    fieldLabel: "Age",
    type: "number",
  },
  {
    field: "createdAt",
    fieldLabel: "Created At",
    type: "date",
  },
];

function UserFilters() {
  const { filters, addOrReplaceByIndex, removeFilter, clearFilters } =
    useCriteria<User>();

  return (
    <Filter
      fields={userFields}
      filters={filters}
      addOrReplaceByIndex={addOrReplaceByIndex}
      removeFilter={removeFilter}
      clearFilters={clearFilters}
    />
  );
}

QueryFilter Type

interface QueryFilter {
  // Field path in the entity (e.g., "name", "profile.bio")
  field: string;

  // Display label for the field
  fieldLabel: string;

  // Field type determines available operators
  type: "string" | "number" | "date" | "boolean";

  // Optional: predefined options for select fields
  options?: Array<{
    value: string;
    label: string;
    icon?: React.ReactNode;
  }>;
}

Field Types and Operators

The Filter component automatically shows the appropriate operators based on field type:
TypeOperators
stringequals, notEquals, contains, startsWith, endsWith, in, notIn, isNull, isNotNull
numberequals, notEquals, greaterThan, greaterThanOrEqual, lessThan, lessThanOrEqual, between, in, notIn, isNull, isNotNull
dateequals, notEquals, greaterThan, lessThan, between, isNull, isNotNull
booleanequals, notEquals, isNull, isNotNull

With Options (Select Fields)

For fields with predefined values, use the options property:
const orderFields: QueryFilter[] = [
  {
    field: "status",
    fieldLabel: "Order Status",
    type: "string",
    options: [
      { value: "draft", label: "Draft", icon: <FileIcon /> },
      { value: "confirmed", label: "Confirmed", icon: <CheckIcon /> },
      { value: "shipped", label: "Shipped", icon: <TruckIcon /> },
      { value: "delivered", label: "Delivered", icon: <PackageIcon /> },
    ],
  },
  {
    field: "priority",
    fieldLabel: "Priority",
    type: "string",
    options: [
      { value: "low", label: "Low" },
      { value: "medium", label: "Medium" },
      { value: "high", label: "High" },
    ],
  },
];

Nested Fields

Support for nested entity paths:
const userFields: QueryFilter[] = [
  {
    field: "profile.bio",
    fieldLabel: "Bio",
    type: "string",
  },
  {
    field: "address.city",
    fieldLabel: "City",
    type: "string",
  },
  {
    field: "address.country",
    fieldLabel: "Country",
    type: "string",
    options: [
      { value: "US", label: "United States" },
      { value: "BR", label: "Brazil" },
      { value: "UK", label: "United Kingdom" },
    ],
  },
];

Complete Example

A full example combining useCriteria, Filter, and a data table:
import { useQuery } from "@tanstack/react-query";
import { useCriteria } from "@/hooks/use-criteria";
import { Filter } from "@/components/filter/filter";
import type { QueryFilter } from "@/lib/filter-utils";
import { PaginatedResult } from "@woltz/rich-domain";

interface Product {
  id: string;
  name: string;
  category: string;
  price: number;
  stock: number;
  status: "active" | "draft" | "archived";
  createdAt: Date;
}

const productFields: QueryFilter[] = [
  { field: "name", fieldLabel: "Name", type: "string" },
  {
    field: "category",
    fieldLabel: "Category",
    type: "string",
    options: [
      { value: "electronics", label: "Electronics" },
      { value: "clothing", label: "Clothing" },
      { value: "books", label: "Books" },
    ],
  },
  { field: "price", fieldLabel: "Price", type: "number" },
  { field: "stock", fieldLabel: "Stock", type: "number" },
  {
    field: "status",
    fieldLabel: "Status",
    type: "string",
    options: [
      { value: "active", label: "Active" },
      { value: "draft", label: "Draft" },
      { value: "archived", label: "Archived" },
    ],
  },
  { field: "createdAt", fieldLabel: "Created At", type: "date" },
];

async function fetchProducts(criteria: Criteria<Product>) {
  const params = new URLSearchParams();

  // Add filters
  criteria.getFilters().forEach((f) => {
    params.set(`${f.field}:${f.operator}`, String(f.value));
  });

  // Add pagination
  const pagination = criteria.getPagination();
  params.set("page", String(pagination.page));
  params.set("limit", String(pagination.limit));

  // Add sorting
  const orders = criteria.getOrders();
  if (orders.length > 0) {
    params.set(
      "orderBy",
      orders.map((o) => `${o.field}:${o.direction}`).join(",")
    );
  }

  const response = await fetch(`/api/products?${params}`);
  return response.json() as Promise<PaginatedResult<Product>>;
}

export function ProductList() {
  const {
    criteria,
    filters,
    sorting,
    pagination,
    addOrReplaceByIndex,
    removeFilter,
    clearFilters,
    setSort,
    setPage,
  } = useCriteria<Product>({
    pageSize: 10,
    syncWithUrl: true,
  });

  const { data, isLoading, error } = useQuery({
    queryKey: ["products", criteria.toJSON()],
    queryFn: () => fetchProducts(criteria),
  });

  if (error) {
    return <div>Error loading products</div>;
  }

  return (
    <div className="space-y-4">
      {/* Filters */}
      <Filter
        fields={productFields}
        filters={filters}
        addOrReplaceByIndex={addOrReplaceByIndex}
        removeFilter={removeFilter}
        clearFilters={clearFilters}
      />

      {/* Table */}
      {isLoading ? (
        <div>Loading...</div>
      ) : (
        <table className="w-full">
          <thead>
            <tr>
              <th onClick={() => setSort("name", "asc")}>Name</th>
              <th onClick={() => setSort("category", "asc")}>Category</th>
              <th onClick={() => setSort("price", "asc")}>Price</th>
              <th onClick={() => setSort("stock", "asc")}>Stock</th>
              <th onClick={() => setSort("status", "asc")}>Status</th>
            </tr>
          </thead>
          <tbody>
            {data?.data.map((product) => (
              <tr key={product.id}>
                <td>{product.name}</td>
                <td>{product.category}</td>
                <td>${product.price}</td>
                <td>{product.stock}</td>
                <td>{product.status}</td>
              </tr>
            ))}
          </tbody>
        </table>
      )}

      {/* Pagination */}
      {data && (
        <div className="flex items-center justify-between">
          <span>
            Showing {pagination.offset + 1} to{" "}
            {Math.min(pagination.offset + pagination.limit, data.meta.total)} of{" "}
            {data.meta.total} results
          </span>
          <div className="flex gap-2">
            <button
              onClick={() => setPage(pagination.page - 1)}
              disabled={!data.meta.hasPrevious}
            >
              Previous
            </button>
            <span>
              Page {pagination.page} of {data.meta.totalPages}
            </span>
            <button
              onClick={() => setPage(pagination.page + 1)}
              disabled={!data.meta.hasNext}
            >
              Next
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

API Reference

useCriteria Options

OptionTypeDefaultDescription
initialPagenumber1Starting page number
pageSizenumber20Items per page
initialFiltersFilter[][]Pre-applied filters
initialSortOrder[][]Pre-applied sorting
initialSearchobjectundefinedPre-applied search
onChangefunctionundefinedCallback when criteria changes
persistKeystringundefinedlocalStorage key for persistence
syncWithUrlbooleanfalseSync state with URL params

Filter Props

PropTypeRequiredDescription
fieldsQueryFilter[]YesAvailable fields for filtering
filtersFilter[]YesCurrent active filters
addOrReplaceByIndexfunctionYesAdd or update a filter
removeFilterfunctionYesRemove a filter by index
clearFiltersfunctionYesClear all filters