Pagination Remote
This example shows how to use pagination with remote data in the grid. To enable pagination you’ll need to use the PaginationPlugin.
This plugin provides the necessary functionality to paginate data within the grid.
Source code
import { defineCustomElements } from '@revolist/revogrid/loader';import { AdvanceFilterPlugin, PaginationPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import { currentTheme } from '../composables/useRandomData';import { createRemotePaginationColumns } from './pagination.remote.utils';import { startRemotePagination } from './pagination.remote.bootstrap';
defineCustomElements();
export function load(parentSelector: string) { const parent = document.querySelector(parentSelector); if (!parent) throw new Error('Remote pagination container not found.'); const shell = document.createElement('section'); shell.className = 'pagination-remote-demo'; Object.assign(shell.style, { minHeight: '0', height: '360px', display: 'flex', flexDirection: 'column', flex: '1 1 auto', }); parent.appendChild(shell); const lifetime = new AbortController(); const status = document.createElement('p'); status.setAttribute('role', 'status'); status.textContent = 'Starting demo backend…'; shell.appendChild(status); let grid: HTMLRevoGridElement | undefined;
void startRemotePagination(lifetime.signal) .then((pagination) => { if (lifetime.signal.aborted) return; grid = document.createElement('revo-grid'); grid.className = 'overflow-hidden cell-border'; grid.style.cssText = 'height: 100%; min-height: 0; flex: 1 1 0;'; grid.columns = createRemotePaginationColumns(); grid.filter = true; grid.stretch = 'all'; Object.assign(grid, { pagination }); grid.plugins = [ PaginationPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin, ]; grid.theme = currentTheme().isDark() ? 'darkMaterial' : 'material'; grid.hideAttribution = true; shell.appendChild(grid); grid.source = []; status.remove(); }) .catch((error) => { if (lifetime.signal.aborted) return; status.setAttribute('role', 'alert'); status.textContent = error.message; });
return () => { lifetime.abort(); grid?.remove(); shell.remove(); };}<template> <section class="pagination-remote-demo" style=" min-height: 0; height: 360px; display: flex; flex-direction: column; flex: 1 1 auto; " > <p v-if="startupError" role="alert">{{ startupError }}</p> <VGrid v-else-if="pagination" class="overflow-hidden cell-border" style="height: 100%; min-height: 0; flex: 1 1 0" :theme="isDark ? 'darkMaterial' : 'material'" :columns="columns" :source="source" :pagination.prop="pagination" stretch="all" filter :plugins="plugins" hide-attribution /> <p v-else role="status">Starting demo backend…</p> </section></template>
<script setup lang="ts">import { onMounted, onBeforeUnmount, ref, shallowRef } from 'vue';import { VGrid } from '@revolist/vue3-datagrid';import { AdvanceFilterPlugin, PaginationPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import type { PaginationFullConfig } from '@revolist/revogrid-pro';import { currentThemeVue } from '../composables/useRandomData';import { createRemotePaginationColumns } from './pagination.remote.utils';import { startRemotePagination } from './pagination.remote.bootstrap';import type { RemoteUser } from './backend/contracts';
const { isDark } = currentThemeVue();const source = ref<RemoteUser[]>([]);const columns = createRemotePaginationColumns();const plugins = [ PaginationPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin,];const pagination = shallowRef<PaginationFullConfig>();const startupError = ref('');const lifetime = new AbortController();onBeforeUnmount(() => lifetime.abort());onMounted(async () => { try { pagination.value = await startRemotePagination(lifetime.signal); } catch (error) { if (!lifetime.signal.aborted) startupError.value = (error as Error).message; }});</script>import React, { useEffect, useMemo, useState } from 'react';import { RevoGrid } from '@revolist/react-datagrid';import { AdvanceFilterPlugin, PaginationPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import type { PaginationFullConfig } from '@revolist/revogrid-pro';import { currentTheme } from '../composables/useRandomData';import { createRemotePaginationColumns } from './pagination.remote.utils';import { startRemotePagination } from './pagination.remote.bootstrap';import type { RemoteUser } from './backend/contracts';
export default function PaginationRemote(_props: { rows?: unknown[] }) { const source = useMemo<RemoteUser[]>(() => [], []); const columns = useMemo(createRemotePaginationColumns, []); const plugins = useMemo( () => [ PaginationPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin, ], [], ); const [pagination, setPagination] = useState<PaginationFullConfig>(); const [startupError, setStartupError] = useState(''); useEffect(() => { const lifetime = new AbortController(); void startRemotePagination(lifetime.signal) .then((config) => { if (!lifetime.signal.aborted) setPagination(config); }) .catch((error) => { if (!lifetime.signal.aborted) setStartupError(error.message); }); return () => lifetime.abort(); }, []);
return ( <section className="pagination-remote-demo" style={{ minHeight: 0, height: 360, display: 'flex', flexDirection: 'column', flex: '1 1 auto', }} > {startupError ? ( <p role="alert">{startupError}</p> ) : !pagination ? ( <p role="status">Starting demo backend…</p> ) : ( <RevoGrid className="overflow-hidden cell-border" style={{ height: '100%', minHeight: 0, flex: '1 1 0' }} columns={columns} source={source} pagination={pagination} stretch="all" filter hide-attribution theme={currentTheme().isDark() ? 'darkMaterial' : 'material'} plugins={plugins} /> )} </section> );}import { Component, ViewEncapsulation, NO_ERRORS_SCHEMA, signal,} from '@angular/core';import type { OnInit, OnDestroy } from '@angular/core';import { RevoGrid } from '@revolist/angular-datagrid';import { AdvanceFilterPlugin, PaginationPlugin, ColumnStretchPlugin, RowOddPlugin,} from '@revolist/revogrid-pro';import type { PaginationFullConfig } from '@revolist/revogrid-pro';import { currentTheme } from '../composables/useRandomData';import { createRemotePaginationColumns } from './pagination.remote.utils';import { startRemotePagination } from './pagination.remote.bootstrap';import type { RemoteUser } from './backend/contracts';
@Component({ selector: 'pagination-remote-grid', standalone: true, imports: [RevoGrid], template: ` <section class="pagination-remote-demo" style="height: 360px; min-height: 0; display: flex; flex-direction: column; flex: 1 1 auto" > @if (startupError()) { <p role="alert">{{ startupError() }}</p> } @else if (pagination(); as config) { <revo-grid class="overflow-hidden cell-border" [columns]="columns" [source]="source" [filter]="true" stretch="all" [pagination]="config" hide-attribution [theme]="theme" [plugins]="plugins" style="height: 100%; min-height: 0; flex: 1 1 0;" ></revo-grid> } @else { <p role="status">Starting demo backend…</p> } </section> `, encapsulation: ViewEncapsulation.None, schemas: [NO_ERRORS_SCHEMA],})export class PaginationRemoteGridComponent implements OnInit, OnDestroy { theme = currentTheme().isDark() ? 'darkMaterial' : 'material'; source: RemoteUser[] = []; columns = createRemotePaginationColumns(); plugins = [ PaginationPlugin, AdvanceFilterPlugin, ColumnStretchPlugin, RowOddPlugin, ]; pagination = signal<PaginationFullConfig | undefined>(undefined); startupError = signal(''); private lifetime = new AbortController();
ngOnInit() { void startRemotePagination(this.lifetime.signal) .then((config) => { if (!this.lifetime.signal.aborted) this.pagination.set(config); }) .catch((error) => { if (!this.lifetime.signal.aborted) this.startupError.set(error.message); }); } ngOnDestroy() { this.lifetime.abort(); }}import type { ColumnRegular } from '@revolist/revogrid';import { serializeFilterCollection, serializeMultiFilterItems,} from '@revolist/revogrid-pro';import type { PaginationLoadData } from '@revolist/revogrid-pro';import type { RemoteUser, UsersPage } from './backend/contracts';
export function createRemotePaginationColumns(): ColumnRegular[] { return [ { name: 'First Name', prop: 'firstName', sortable: true }, { name: 'Last Name', prop: 'lastName', sortable: true }, { name: 'Relationship', prop: 'status', sortable: true }, ];}
/** Real HTTP client: no access to mock services or seed data. */export function createRemoteUsersLoader( apiUrl: string,): PaginationLoadData<RemoteUser> { return async ( skip, take, sort, filter, filterItems, _quickFilter, _temporal, filterAst, _execution, context, ): Promise<UsersPage> => { const url = new URL( `${apiUrl}/users`, globalThis.location?.href ?? 'http://localhost', ); url.searchParams.set('skip', String(skip)); url.searchParams.set('take', String(take)); if (sort && Object.keys(sort).length) url.searchParams.set('sort', JSON.stringify(sort)); if (filterAst) url.searchParams.set('filterAst', JSON.stringify(filterAst)); else if (filterItems) url.searchParams.set( 'filterItems', JSON.stringify(serializeMultiFilterItems(filterItems) ?? {}), ); else if (filter) url.searchParams.set( 'filter', JSON.stringify(serializeFilterCollection(filter) ?? {}), ); const response = await fetch(url, { signal: context?.signal }); if (!response.ok) { const body = await response.json().catch(() => ({})); throw Object.assign( new Error( typeof body?.message === 'string' ? body.message : `HTTP ${response.status}`, ), { status: response.status }, ); } return response.json(); };}import { startDemoBackend } from '@revolist/revogrid-examples/demo-api-mocks';import type { PaginationFullConfig } from '@revolist/revogrid-pro';import { createRemoteUsersLoader } from './pagination.remote.utils';
/** Host-only setup. An aborted mount also disposes a session that starts late. */export async function startRemotePagination( signal: AbortSignal,): Promise<PaginationFullConfig> { signal.throwIfAborted(); const { remotePaginationBackend } = await import('./backend/module'); signal.throwIfAborted(); const session = await startDemoBackend(remotePaginationBackend); if (signal.aborted) { session.dispose(); signal.throwIfAborted(); } signal.addEventListener('abort', () => session.dispose(), { once: true }); return { itemsPerPage: 10, initialPage: 0, total: 0, buttonCount: 5, loadData: createRemoteUsersLoader(session.apiUrl), };}import { http, HttpResponse } from 'msw';import type { DemoBackendModule } from '@revolist/revogrid-examples/demo-api-mocks';import { createUsersSeed } from './seed';import { queryUsers, UsersQueryError } from './service';
export const remotePaginationBackend: DemoBackendModule = { id: 'pagination', create({ apiUrl }) { const users = createUsersSeed(); return { handlers: [ http.get(`${apiUrl}/users`, ({ request }) => { try { return HttpResponse.json( queryUsers(users, new URL(request.url).searchParams), ); } catch (error) { if (error instanceof UsersQueryError) return HttpResponse.json( { message: error.message }, { status: 400 }, ); throw error; } }), ], }; },};import { filterCoreFunctionsIndexedByType } from '@revolist/revogrid';import type { MultiFilterItem, SortingOrder } from '@revolist/revogrid';import { collectFilterAstFields, compileFilterAst, multiFilterItemsToFilterAst, validateFilterAst,} from '@revolist/revogrid-pro';import type { FilterAst } from '@revolist/revogrid-pro';import { REMOTE_USER_FIELDS } from './contracts';import type { RemoteUser, UsersPage } from './contracts';
const fields = new Set<string>(REMOTE_USER_FIELDS);export class UsersQueryError extends Error {}function record(value: unknown): value is Record<string, unknown> { return !!value && typeof value === 'object' && !Array.isArray(value);}function json(search: URLSearchParams, name: string): unknown { const raw = search.get(name); if (raw === null) return undefined; try { return JSON.parse(raw); } catch { throw new UsersQueryError(`Invalid ${name} JSON.`); }}function offset( search: URLSearchParams, name: string, fallback: number,): number { const raw = search.get(name); if (raw === null) return fallback; const value = Number(raw); if (!/^\d+$/.test(raw) || !Number.isSafeInteger(value) || value < 0) { throw new UsersQueryError(`${name} must be a nonnegative integer.`); } return value;}function parseSort(search: URLSearchParams): SortingOrder { const sort = json(search, 'sort'); if (sort === undefined) return {}; if ( !record(sort) || Object.entries(sort).some( ([field, order]) => !fields.has(field) || (order !== 'asc' && order !== 'desc'), ) ) { throw new UsersQueryError('Invalid sorting fields or direction.'); } return sort as SortingOrder;}function parseFilter(search: URLSearchParams): FilterAst | undefined { let ast = json(search, 'filterAst'); if (ast === undefined) { let items = json(search, 'filterItems'); if (items === undefined) { const collection = json(search, 'filter'); if (collection !== undefined) { if ( !record(collection) || Object.values(collection).some((item) => !record(item)) ) { throw new UsersQueryError('Invalid filter collection.'); } items = Object.fromEntries( Object.entries(collection).map(([field, item]) => [ field, [{ ...(item as object), id: 0 }], ]), ); } } if (items === undefined) return undefined; if ( !record(items) || Object.entries(items).some( ([field, conditions]) => !fields.has(field) || !Array.isArray(conditions) || conditions.some( (item) => !record(item) || typeof item.type !== 'string' || (item.relation !== undefined && item.relation !== 'and' && item.relation !== 'or'), ), ) ) { throw new UsersQueryError('Invalid filter items.'); } ast = multiFilterItemsToFilterAst(items as MultiFilterItem); if (!ast) return undefined; } if (!validateFilterAst(ast).valid) throw new UsersQueryError('Invalid or unsupported filter expression.'); if ( [...collectFilterAstFields(ast as FilterAst)].some( (field) => !fields.has(field), ) ) { throw new UsersQueryError('Unknown filter field.'); } return ast as FilterAst;}/** Backend query independent of HTTP/MSW and client Query caching. */export function queryUsers( rows: readonly RemoteUser[], search: URLSearchParams,): UsersPage { const skip = offset(search, 'skip', 0); const take = offset(search, 'take', 10); if (take > 1000) throw new UsersQueryError('take must not exceed 1000.'); const sort = parseSort(search); const ast = parseFilter(search); // Reuse supported evaluators, including multi-condition and selection semantics. const predicate = ast && compileFilterAst(ast, { columns: REMOTE_USER_FIELDS.map((prop) => ({ prop })), operators: filterCoreFunctionsIndexedByType, }); const filtered = rows.filter((row) => !predicate || predicate.matches(row)); const clauses = Object.entries(sort); const ordered = filtered.sort((a, b) => { for (const [field, direction] of clauses) { const left = a[field as keyof RemoteUser]; const right = b[field as keyof RemoteUser]; const comparison = typeof left === 'number' && typeof right === 'number' ? left - right : String(left) .toLowerCase() .localeCompare(String(right).toLowerCase()); if (comparison) return direction === 'desc' ? -comparison : comparison; } return a.id - b.id; }); return { data: ordered.slice(skip, skip + take), count: ordered.length };}import { makeData } from '../../composables/makeData';import type { RemoteUser } from './contracts';
/** The existing generator is deterministic by row ID; each call owns fresh records. */export function createUsersSeed(): RemoteUser[] { return makeData(102).map(({ id, firstName, lastName, status }) => ({ id: Number(id), firstName, lastName, status, }));}export interface RemoteUser { id: number; firstName: string; lastName: string; status: 'relationship' | 'complicated' | 'single';}export interface UsersPage { data: RemoteUser[]; count: number;}export const REMOTE_USER_FIELDS = [ 'id', 'firstName', 'lastName', 'status',] as const;Simulated HTTP backend
Section titled “Simulated HTTP backend”The demo uses native fetch() against an isolated MSW backend with 102
deterministic records. The backend owns paging, filtering, sorting, and the
filtered count. The HTTP client, bootstrap, routes, and query service are separate
source tabs, shared by all four framework variants.
The bootstrap awaits worker readiness before mounting the grid and disposes its session when the framework unmounts. The loader forwards the optional cancellation signal and preserves HTTP status codes when reporting errors to the grid.
The MSW runner is demo infrastructure in this workspace, not a required product
dependency. In your application, use your own API URL with the client loader. To
reuse the simulation in another demo host, include the shared runner, serve the
MSW worker as mockServiceWorker.js beneath that host’s configured base URL, and
await startup before issuing requests. The demos and docs hosts already serve
the worker matching their installed MSW version. Use HTTPS or localhost.
The demo loader sends the canonical filterAst when supplied, with fallbacks to
the full multi-condition model and legacy single-condition collection. Backend
filter evaluation uses the supported public filter helpers.
Complete remote filter state
Section titled “Complete remote filter state”loadData keeps the original fourth argument for compatibility and passes the
complete advanced filter model as an optional fifth argument:
grid.pagination = { itemsPerPage: 50, initialPage: 0, total: 0, loadData: async ( skip, take, order, singleConditionFilters, multiConditionFilters, _quickFilter, _temporalContext, _filterAst, _executionContext, context, // Optional tenth argument: { signal }. ) => { return fetch('/api/orders', { method: 'POST', signal: context?.signal, body: JSON.stringify({ skip, take, order, filters: multiConditionFilters, }), }).then(response => response.json()); },};signal: context?.signal forwards the optional cancellation signal to fetch,
so the transport can stop when the read is no longer needed. See
loader context and cancellation.
Use multiConditionFilters for new integrations because it preserves every
condition and same-column relation. singleConditionFilters contains only the
first condition for each column.
// fixing render for multiframeworkBuilt-in shared loading
Section titled “Built-in shared loading”The existing pagination.loadData(...) callback now runs through the shared Query
engine automatically. Keep its existing parameters and result shape. An optional
cancellation context { signal } is appended as argument ten, after the existing execution
context. Existing callbacks may ignore it. Local pagination without loadData
continues to use the client source.
For linked views, create an app/session scope using createDataQueryContext from
@revolist/revogrid-pro and supply the same query: { source } inside each pagination
configuration. Ordinary configurations need no setup. The optional query object
also supports reconcile, onStatus, resource and snapshot consistency.
Apply canonical accepted writes and reconcile application proposals before calling
source.accepted(). Use source.invalidate() for external server changes. Assign
normal pagination configuration with a new source when tenant/permission scope
changes; old rows/counts clear immediately and pagination returns to the first page.
Remote hydration preserves history and emits no new save. Each grid receives
detached editable rows; reconciliation runs outside the shared result cache.
Query usage and configuration
Section titled “Query usage and configuration”See Server Data Queries for polling,
shared views,
caching,
retries,
lifecycle and the complete configuration types and defaults.
Your existing loader callback stays unchanged; add options inside query.
The query concepts are explained individually:
- Pages, blocks, routes and observers
- Polling, mount, focus and reconnect refetch
staleTimefreshness andgcTimecache retentionenabled, offline pauses andnetworkModeretry,retryDelayandretryOnMountmetaand callback policies- Contexts, sources, resources, deduplication and concurrency
- Invalidation, accepted writes and acceptance fences
- Eventual/snapshot consistency and deterministic ordering
- Pending edits, reconciliation, hydration and status
- Cancellation and destruction
For an application-owned query library/cache, set query: false. See
bypassing Query
for lifecycle behavior and the difference from query: { enabled: false }.