Skip to content

QueryEngine — Filters, Sorting & Pagination for React

@granit/query-engine is the largest frontend package — a framework-agnostic, headless data grid system that mirrors Granit.QueryEngine on the .NET backend. It provides types for filters, sorts, pagination (offset and cursor), grouping, saved views, query serialization, and a SmartFilterBar suggestion engine.

@granit/react-query-engine wraps everything into React hooks with TanStack Query integration: useQueryEndpoint (full query state machine), usePagination, useInfiniteScroll, useSavedViews, useSmartFilter, and useQueryMeta.

Peer dependencies: axios, @granit/utils, react ^19, @tanstack/react-query ^5

  • Directory@granit/query-engine/ Types, API functions, serialization, operator utilities (framework-agnostic)
    • @granit/react-query-engine QueryProvider, pagination hooks, query endpoint, smart filter, saved views
PackageRoleDepends on
@granit/query-engineFilter/sort/pagination types, API functions, query serialization, operator utilitiesaxios, @granit/utils
@granit/react-query-engineQueryProvider, useQueryEndpoint, usePagination, useInfiniteScroll, useSmartFilter, useSavedViews@granit/query-engine, @tanstack/react-query, react
import { QueryProvider } from '@granit/react-query-engine';
import { useQueryEndpoint, useQueryMeta } from '@granit/react-query-engine';
import { api } from './api-client';
function PatientList() {
return (
<QueryProvider config={{ client: api, basePath: '/api/v1/patients' }}>
<PatientTable />
</QueryProvider>
);
}
interface QueryParams {
readonly page?: number;
readonly pageSize?: number;
readonly cursor?: string; // opaque cursor for keyset pagination
readonly search?: string; // global full-text search
readonly skipTotalCount?: boolean; // skip COUNT(*) for faster queries
readonly filters?: readonly FilterEntry[];
readonly sort?: readonly SortEntry[];
readonly presets?: Readonly<Record<string, readonly string[]>>;
readonly quickFilters?: readonly string[];
readonly groupBy?: string;
}
type FilterOperator =
| 'Eq' | 'Contains' | 'StartsWith' | 'EndsWith'
| 'Gt' | 'Gte' | 'Lt' | 'Lte'
| 'In' | 'Between';
interface FilterEntry {
readonly field: string;
readonly operator: FilterOperator;
readonly value: string; // comma-separated for In/Between
}
interface SortEntry {
readonly field: string;
readonly direction: 'asc' | 'desc';
}

Operator availability by CLR type:

TypeOperators
StringEq, Contains, StartsWith, EndsWith, In
Int32, Decimal, DoubleEq, Gt, Gte, Lt, Lte, In, Between
DateTime, DateOnlyEq, Gt, Gte, Lt, Lte, Between
BooleanEq
Guid, EnumsEq, In
interface PagedResult<T> {
readonly items: readonly T[];
readonly totalCount: number | null; // null when skipTotalCount=true or cursor pagination
readonly hasMore: boolean; // always computed, safe for "Load more" UIs
readonly nextCursor?: string; // opaque — composite cursor encoding sort field values
}
interface GroupedResult<T> {
readonly groups: readonly GroupEntry<T>[];
readonly totalCount: number;
}
interface GroupEntry<T> {
readonly field: string;
readonly value: unknown;
readonly label: string;
readonly count: number;
readonly aggregates?: Readonly<Record<string, unknown>>;
readonly items?: readonly T[];
}
interface QueryMetadata {
readonly columns: readonly ColumnDefinition[];
readonly filterableFields: readonly FilterableField[];
readonly sortableFields: readonly SortableField[];
readonly presetFilterGroups: readonly FilterGroupMeta[];
readonly quickFilters: readonly QuickFilterMeta[];
readonly dateFilters: readonly DateFilterMeta[];
readonly groupByFields: readonly GroupByField[];
readonly pagination: PaginationMeta;
readonly defaultSort?: string;
}
interface PaginationMeta {
readonly defaultPageSize: number; // e.g. 20
readonly maxPageSize: number; // e.g. 100
readonly maxStreamSize: number; // e.g. 100_000 — ceiling for export streaming
readonly supportsCursor: boolean; // true when keyset pagination is available
}
interface ColumnDefinition {
readonly name: string;
readonly label: string;
readonly type: string;
readonly order: number;
readonly isSortable: boolean;
readonly isFilterable: boolean;
readonly isVisible: boolean;
readonly format?: string;
}
interface FilterableField {
readonly name: string;
readonly type: string;
readonly operators: readonly FilterOperator[];
readonly enumValues?: readonly string[];
}
interface SavedViewSummary {
readonly id: string;
readonly name: string;
readonly isShared: boolean;
readonly isDefault: boolean;
}
interface CreateSavedViewRequest {
readonly name: string;
readonly isShared: boolean;
readonly isDefault: boolean;
readonly filterJson?: string;
readonly sortJson?: string;
readonly groupByJson?: string;
readonly visibleColumnsJson?: string;
}
type SmartFilterPhase = 'idle' | 'selectField' | 'selectOperator' | 'enterValue';
interface FilterToken {
readonly id: string;
readonly type: 'filter' | 'preset' | 'quickFilter' | 'search';
readonly label: string;
readonly field?: string;
readonly operator?: FilterOperator;
readonly value?: string;
}
interface FilterSuggestion {
readonly id: string;
readonly type: 'filter' | 'preset' | 'quickFilter' | 'search';
readonly label: string;
readonly description?: string;
readonly field?: string;
readonly operators?: readonly FilterOperator[];
readonly values?: readonly { value: string; label: string }[];
}
function fetchPage<T>(client, basePath, params: QueryParams): Promise<PagedResult<T>>;
function fetchGrouped<T>(client, basePath, params: QueryParams): Promise<GroupedResult<T>>;
function fetchQueryMeta(client, basePath): Promise<QueryMetadata>;
// Saved views CRUD
function fetchSavedViews(client, basePath): Promise<SavedViewSummary[]>;
function createSavedView(client, basePath, request): Promise<SavedViewSummary>;
function updateSavedView(client, basePath, id, request): Promise<SavedViewSummary>;
function deleteSavedView(client, basePath, id): Promise<void>;
function setDefaultSavedView(client, basePath, id): Promise<SavedViewSummary>;

Bookmarkable URLs — serialize query state to URL search params and back.

function serializeQueryParams(params: QueryParams): string;
function parseQueryParams(search: string): QueryParams;
// Example:
serializeQueryParams({
page: 2, pageSize: 20, search: 'dupont',
filters: [{ field: 'Status', operator: 'Eq', value: 'Active' }],
sort: [{ field: 'CreatedAt', direction: 'desc' }],
});
// → "page=2&pageSize=20&search=dupont&filter[Status.Eq]=Active&sort=-CreatedAt"
const STRING_OPERATORS: readonly FilterOperator[];
const NUMBER_OPERATORS: readonly FilterOperator[];
const DATE_OPERATORS: readonly FilterOperator[];
const BOOLEAN_OPERATORS: readonly FilterOperator[];
const ENUM_OPERATORS: readonly FilterOperator[];
const OPERATOR_LABELS: Record<FilterOperator, string>;
function inferOperators(clrType: string): readonly FilterOperator[];

The backend supports two pagination strategies. The frontend should choose based on UX:

ModeWhen to useQuery paramsResponse shape
Offset (default)Page navigation, data gridspage, pageSizetotalCount + hasMore
Cursor (keyset)Infinite scroll, large datasets (100K+)cursor, pageSizehasMore + nextCursor, no totalCount

Cursor pagination details:

  • Cursors are opaque — the frontend must never parse or construct them.
  • The backend encodes all active sort field values into the cursor (composite keyset). This means changing the sort order invalidates existing cursors — reset to cursor: undefined when the user changes sort.
  • Check pagination.supportsCursor in the metadata before enabling cursor mode.
  • When combining cursor pagination with sorting, the backend automatically appends the cursor property as a tiebreaker if it is not already in the sort specification.

skipTotalCount optimization:

For large tables, pass skipTotalCount: true to avoid the COUNT(*) query. The backend fetches pageSize + 1 rows to compute hasMore without counting. Use this for “Load more” UIs where total count is not displayed.

<QueryProvider config={{ client: api, basePath: '/api/v1/patients' }}>
{children}
</QueryProvider>

Main hook — manages the full query state machine (search, filter, sort, pagination, grouping) via useReducer (14 actions) with TanStack Query for data fetching.

interface UseQueryEndpointReturn<T> {
readonly params: QueryParams;
readonly query: UseQueryResult<PagedResult<T>>;
readonly groupedQuery: UseQueryResult<GroupedResult<T>>;
readonly isGrouped: boolean;
// Dispatchers
readonly setPage: (page: number) => void;
readonly setPageSize: (pageSize: number) => void;
readonly setSearch: (search: string) => void;
readonly setFilters: (filters: readonly FilterEntry[]) => void;
readonly addFilter: (filter: FilterEntry) => void;
readonly removeFilter: (field: string, operator?: string) => void;
readonly setSort: (sort: readonly SortEntry[]) => void;
readonly toggleSort: (field: string) => void; // none → asc → desc → none
readonly setPresets: (group: string, names: readonly string[]) => void;
readonly toggleQuickFilter: (name: string) => void;
readonly setGroupBy: (groupBy: string | undefined) => void;
readonly setParams: (params: QueryParams) => void;
readonly reset: () => void;
}

Filter, search, preset, and quick-filter changes automatically reset to page 1.

Classic offset-based pagination with page navigation.

interface UsePaginationReturn<T> {
readonly items: readonly T[];
readonly totalCount: number;
readonly page: number;
readonly totalPages: number;
readonly hasPreviousPage: boolean;
readonly hasNextPage: boolean;
readonly goToPage: (page: number) => void;
readonly nextPage: () => void;
readonly previousPage: () => void;
readonly refresh: () => void;
readonly loading: boolean;
}

Load-more pagination — accumulates items from successive pages. Supports both offset and cursor modes. When pagination.supportsCursor is true in the metadata, the hook automatically uses cursor pagination with skipTotalCount: true for optimal performance on large datasets.

interface UseInfiniteScrollReturn<T> {
readonly items: readonly T[];
readonly totalCount: number | null; // null when using cursor pagination
readonly hasMore: boolean;
readonly loadMore: () => void;
readonly refresh: () => void;
readonly loading: boolean;
readonly loadingMore: boolean;
}

Fetches and caches query metadata (staleTime: Infinity — stable per deployment).

function useQueryMeta(): UseQueryResult<QueryMetadata>;

Full CRUD for saved views with cache invalidation.

interface UseSavedViewsReturn {
readonly views: UseQueryResult<SavedViewSummary[]>;
readonly create: UseMutationResult<SavedViewSummary, Error, CreateSavedViewRequest>;
readonly update: UseMutationResult<...>;
readonly remove: UseMutationResult<void, Error, string>;
readonly setDefault: UseMutationResult<SavedViewSummary, Error, string>;
}

State machine for the SmartFilterBar omnibox. Manages the flow: field → operator → value, generates contextual suggestions, and outputs FilterEntry[] ready for useQueryEndpoint.

interface UseSmartFilterReturn {
readonly phase: SmartFilterPhase;
readonly tokens: readonly FilterToken[];
readonly suggestions: readonly FilterSuggestion[];
// Extracted from tokens (ready for useQueryEndpoint)
readonly filters: readonly FilterEntry[];
readonly search: string | undefined;
readonly presets: Readonly<Record<string, readonly string[]>>;
readonly quickFilters: readonly string[];
// Actions
readonly setInput: (value: string) => void;
readonly selectField: (field: string) => void;
readonly selectOperator: (operator: FilterOperator) => void;
readonly confirmValue: (value: string) => void;
readonly removeToken: (id: string) => void;
readonly clearAll: () => void;
readonly cancel: () => void;
}
CategoryKey exportsPackage
Query typesQueryParams, FilterEntry, SortEntry, FilterOperator, PagedResult<T>, GroupedResult<T>@granit/query-engine
MetadataQueryMetadata, ColumnDefinition, FilterableField, SortableField@granit/query-engine
Saved viewsSavedViewSummary, CreateSavedViewRequest, UpdateSavedViewRequest@granit/query-engine
SmartFilterFilterToken, FilterSuggestion, SmartFilterPhase@granit/query-engine
API functionsfetchPage(), fetchGrouped(), fetchQueryMeta(), saved views CRUD@granit/query-engine
SerializationserializeQueryParams(), parseQueryParams()@granit/query-engine
OperatorsinferOperators(), STRING_OPERATORS, OPERATOR_LABELS@granit/query-engine
ProviderQueryProvider, useQueryConfig(), buildQueryKey()@granit/react-query-engine
Query hookuseQueryEndpoint()@granit/react-query-engine
PaginationusePagination(), useInfiniteScroll()@granit/react-query-engine
Metadata hookuseQueryMeta()@granit/react-query-engine
Saved views hookuseSavedViews()@granit/react-query-engine
Smart filteruseSmartFilter()@granit/react-query-engine

Understanding the backend pipeline helps frontend developers know what happens when query params are sent to GET /api/{entity}:

  1. Filteringfilter[field.op]=value validated against a whitelist of filterable columns
  2. Presets — OR within each group, AND between groups (mutually exclusive toggles)
  3. Quick filters — independent toggleable predicates (AND semantics)
  4. Global searchsearch=term across declared properties (pluggable strategy — default: LIKE '%term%')
  5. CountCOUNT(*) on the filtered (unsorted) query (skipped when skipTotalCount=true)
  6. Sortsort=-createdAt,name validated against sortable column whitelist
  7. Pagination — offset (Skip/Take) or keyset (composite cursor WHERE clause)

Key constraints enforced server-side:

  • pageSize is clamped to maxPageSize (default 100) — the frontend cannot request unlimited rows
  • Non-whitelisted filter/sort fields are silently ignored
  • Streaming exports are capped at maxStreamSize (default 100K rows)