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
Package structure
Section titled “Package structure”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
| Package | Role | Depends on |
|---|---|---|
@granit/query-engine | Filter/sort/pagination types, API functions, query serialization, operator utilities | axios, @granit/utils |
@granit/react-query-engine | QueryProvider, 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> );}import { fetchPage, fetchQueryMeta, serializeQueryParams } from '@granit/query-engine';import type { QueryParams, PagedResult, FilterEntry } from '@granit/query-engine';TypeScript SDK
Section titled “TypeScript SDK”Query parameters
Section titled “Query parameters”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;}Filter system
Section titled “Filter system”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:
| Type | Operators |
|---|---|
String | Eq, Contains, StartsWith, EndsWith, In |
Int32, Decimal, Double | Eq, Gt, Gte, Lt, Lte, In, Between |
DateTime, DateOnly | Eq, Gt, Gte, Lt, Lte, Between |
Boolean | Eq |
Guid, Enums | Eq, In |
Result types
Section titled “Result types”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[];}Query metadata
Section titled “Query metadata”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[];}Saved views
Section titled “Saved views”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;}SmartFilter types
Section titled “SmartFilter types”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 }[];}API functions
Section titled “API functions”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 CRUDfunction 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>;Query serialization
Section titled “Query serialization”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"Operator utilities
Section titled “Operator utilities”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[];Pagination modes
Section titled “Pagination modes”The backend supports two pagination strategies. The frontend should choose based on UX:
| Mode | When to use | Query params | Response shape |
|---|---|---|---|
| Offset (default) | Page navigation, data grids | page, pageSize | totalCount + hasMore |
| Cursor (keyset) | Infinite scroll, large datasets (100K+) | cursor, pageSize | hasMore + 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: undefinedwhen the user changes sort. - Check
pagination.supportsCursorin 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.
React bindings
Section titled “React bindings”QueryProvider
Section titled “QueryProvider”<QueryProvider config={{ client: api, basePath: '/api/v1/patients' }}> {children}</QueryProvider>useQueryEndpoint<T>(options?)
Section titled “useQueryEndpoint<T>(options?)”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.
usePagination<T>(options)
Section titled “usePagination<T>(options)”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;}useInfiniteScroll<T>(options)
Section titled “useInfiniteScroll<T>(options)”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;}useQueryMeta()
Section titled “useQueryMeta()”Fetches and caches query metadata (staleTime: Infinity — stable per deployment).
function useQueryMeta(): UseQueryResult<QueryMetadata>;useSavedViews()
Section titled “useSavedViews()”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>;}useSmartFilter(options?)
Section titled “useSmartFilter(options?)”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;}Public API summary
Section titled “Public API summary”| Category | Key exports | Package |
|---|---|---|
| Query types | QueryParams, FilterEntry, SortEntry, FilterOperator, PagedResult<T>, GroupedResult<T> | @granit/query-engine |
| Metadata | QueryMetadata, ColumnDefinition, FilterableField, SortableField | @granit/query-engine |
| Saved views | SavedViewSummary, CreateSavedViewRequest, UpdateSavedViewRequest | @granit/query-engine |
| SmartFilter | FilterToken, FilterSuggestion, SmartFilterPhase | @granit/query-engine |
| API functions | fetchPage(), fetchGrouped(), fetchQueryMeta(), saved views CRUD | @granit/query-engine |
| Serialization | serializeQueryParams(), parseQueryParams() | @granit/query-engine |
| Operators | inferOperators(), STRING_OPERATORS, OPERATOR_LABELS | @granit/query-engine |
| Provider | QueryProvider, useQueryConfig(), buildQueryKey() | @granit/react-query-engine |
| Query hook | useQueryEndpoint() | @granit/react-query-engine |
| Pagination | usePagination(), useInfiniteScroll() | @granit/react-query-engine |
| Metadata hook | useQueryMeta() | @granit/react-query-engine |
| Saved views hook | useSavedViews() | @granit/react-query-engine |
| Smart filter | useSmartFilter() | @granit/react-query-engine |
Backend pipeline overview
Section titled “Backend pipeline overview”Understanding the backend pipeline helps frontend developers know what happens when
query params are sent to GET /api/{entity}:
- Filtering —
filter[field.op]=valuevalidated against a whitelist of filterable columns - Presets — OR within each group, AND between groups (mutually exclusive toggles)
- Quick filters — independent toggleable predicates (AND semantics)
- Global search —
search=termacross declared properties (pluggable strategy — default:LIKE '%term%') - Count —
COUNT(*)on the filtered (unsorted) query (skipped whenskipTotalCount=true) - Sort —
sort=-createdAt,namevalidated against sortable column whitelist - Pagination — offset (
Skip/Take) or keyset (composite cursorWHEREclause)
Key constraints enforced server-side:
pageSizeis clamped tomaxPageSize(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)
See also
Section titled “See also”- Granit.QueryEngine module — .NET whitelist-first query engine with expression trees
- Notifications — Uses
useInfiniteScrollfrom this package for the notification inbox - Data Exchange — Uses
@granit/utilsfor shared formatting