Load Pages
Kanban reads one offset/limit stream for the whole board. It does not have a separate cursor for every workflow column.
Start with append loading: display the first page, then append more cards as users scroll. Use indexed placeholders only when the server knows each unloaded card’s workflow placement and rank.
Load the next page of cards
The endpoint holds 24 cards and returns six per request.
Try it: Choose Load next page until all cards are loaded, then refresh.
HTTP reads: 0 · writes: 0
What to notice: Each request advances the offset. Refresh starts at the first page again.
TypeScript source
The supplied backend handlers use MSW simulation in the browser. Replace the transport with your application endpoint.
import type { KanbanConfig, KanbanCardMoveDetail } from '@revolist/kanban';import type { WorkCard } from './first-board';import { boardConfig, configureBoard } from './first-board';
// apiUrl is YOUR endpoint. The documentation supplies an isolated HTTP demo backend.export function remoteBoard(apiUrl: string): KanbanConfig<WorkCard> { return { ...boardConfig, remote: { chunkSize: 6, query: { retry: false, refetchOnWindowFocus: false }, async loadData(skip, limit, _order, _single, _multi, _quick, _temporal, _ast, _execution, context) { const response = await fetch(`${apiUrl}/cards?skip=${skip}&limit=${limit}`, { signal: context?.signal }); if (!response.ok) throw new Error(`Card loading failed: HTTP ${response.status}`); return response.json(); // { data: Card[], total: number } }, } };}
// Read-only setup; add app-owned write handling before enabling moves.export async function createServerBoard(container: HTMLElement, apiUrl: string) { const grid = document.createElement('revo-grid'); grid.style.cssText = 'display:block;width:100%;height:360px'; container.append(grid); try { // configureBoard waits for grid initialization before activating the remote loader. const kanban = await configureBoard(grid, { ...remoteBoard(apiUrl), readOnly: true }, []); return { grid, kanban, destroy() { grid.remove(); } }; } catch (error) { grid.remove(); throw error; }}
export async function saveMove(apiUrl: string, detail: KanbanCardMoveDetail, signal?: AbortSignal) { const response = await fetch(`${apiUrl}/moves`, { method: 'POST', signal, headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ changedCards: detail.changedCards }), }); if (!response.ok) throw new Error(`Move save could not be confirmed: HTTP ${response.status}`); return response.json();}// grid.kanban = remoteBoard(apiUrl);// grid.addEventListener('kanbancardmove', event => { void saveMove(apiUrl, event.detail); });// In your app, pause conflicting moves while saving; on failure refresh before more edits.// Pagination, refresh and Query policy belong to remote. There is no remote.commit method.import { defineCustomElements } from '@revolist/revogrid/loader';import { KanbanPlugin, type KanbanConfig } from '@revolist/kanban';import '@revolist/revogrid-pro/styles.css';import '@revolist/kanban/styles.css';
export interface WorkCard { id: string; title: string; status: string; order: number; description?: string; team?: string; owner?: string; priority?: string; tags?: string[]; progress?: number; startDate?: string; endDate?: string;}export const demoCards: WorkCard[] = [ { id: 'task-1', title: 'Research customer needs', description: 'Collect feedback for the next release.', status: 'todo', team: 'product', owner: 'Ana', priority: 'normal', order: 1000 }, { id: 'task-2', title: 'Build onboarding', description: 'Make the first session useful.', status: 'doing', team: 'product', owner: 'Mia', priority: 'high', order: 1000 }, { id: 'task-3', title: 'Improve API errors', description: 'Return actionable validation messages.', status: 'todo', team: 'platform', owner: 'Rui', priority: 'normal', order: 2000 }, { id: 'task-4', title: 'Release checklist', description: 'Ready for the next deployment.', status: 'done', team: 'platform', owner: 'Sam', priority: 'normal', order: 1000 },];export const boardConfig: KanbanConfig<WorkCard> = { columns: [{ prop: 'todo', name: 'To do', size: 210 }, { prop: 'doing', name: 'In progress', size: 210 }, { prop: 'done', name: 'Done', size: 210 }], card: { titleField: 'title' }, cardRowHeight: 88, addCard: false, swimlaneColumn: false,};
export async function configureBoard( grid: HTMLRevoGridElement, config: KanbanConfig<WorkCard> = boardConfig, cards: readonly WorkCard[] = demoCards, extraPlugins: HTMLRevoGridElement['plugins'] = [],) { await defineCustomElements(); grid.plugins = [KanbanPlugin, ...extraPlugins]; // The packaged editor and history install automatically. grid.columns = [{ prop: 'title', name: 'Title' }, { prop: 'status', name: 'Status' }, { prop: 'owner', name: 'Owner' }]; // Cached remote data can hydrate immediately: finish initial grid setup first. grid.kanban = config.remote ? { ...config, remote: false } : config; grid.source = cards.map(card => ({ ...card })); await grid.componentOnReady(); const plugins = await grid.getPlugins(); const kanban = plugins.find((plugin): plugin is KanbanPlugin<WorkCard> => plugin instanceof KanbanPlugin); if (!kanban) throw new Error('Kanban could not start.'); if (config.remote) grid.kanban = config; return kanban;}
// Give your container a width and mount once after it exists in the DOM.export async function createKanbanBoard(container: HTMLElement) { await defineCustomElements(); const grid = document.createElement('revo-grid'); grid.style.cssText = 'display:block;width:100%;height:400px'; container.append(grid); try { const kanban = await configureBoard(grid); return { grid, kanban, destroy() { grid.remove(); } }; } catch (error) { grid.remove(); throw error; }}import { http, HttpResponse } from 'msw';import type { DemoBackendModule } from '@revolist/revogrid-examples/demo-api-mocks';
export interface ServerCard { id: string; title: string; status: string; order: number; }export function createKanbanDocsStore() { let failNextMove = false; let cards: ServerCard[] = Array.from({ length: 24 }, (_, index) => ({ id: `remote-${index + 1}`, title: `Server task ${index + 1}`, status: ['todo', 'doing', 'done'][index % 3], order: (index + 1) * 1000, })); return { failNextMove() { failNextMove = true; }, page(skip: number, limit: number) { if (!Number.isSafeInteger(skip) || skip < 0 || !Number.isSafeInteger(limit) || limit < 1 || limit > 100) throw new Error('Invalid page'); return { data: structuredClone(cards.slice(skip, skip + limit)), total: cards.length }; }, move(changes: unknown) { if (failNextMove) { failNextMove = false; throw new Error('Simulated save unavailable'); } if (!Array.isArray(changes) || !changes.length) throw new Error('Changed cards are required'); const ids = new Set<string>(); for (const card of changes) { if (!card || typeof card.id !== 'string' || ids.has(card.id) || !cards.some(item => item.id === card.id) || !['todo', 'doing', 'done'].includes(card.status) || !Number.isFinite(card.order)) throw new Error('Invalid card changes'); ids.add(card.id); } // Apply one validated move batch atomically, including rank-rebalanced neighbors. cards = cards.map(card => { const change = changes.find(item => item.id === card.id); return change ? { ...card, status: change.status, order: change.order } : card; }); return { saved: ids.size }; }, };}
/** HTTP backend supplied with the docs; isolated data per preview session. */export const kanbanDocsBackend: DemoBackendModule = { id: 'kanban-docs', create({ apiUrl, signal }) { const store = createKanbanDocsStore(); return { handlers: [ http.get(`${apiUrl}/cards`, ({ request }) => { const params = new URL(request.url).searchParams; try { return HttpResponse.json(store.page(Number(params.get('skip') ?? 0), Number(params.get('limit') ?? 6))); } catch { return HttpResponse.json({ message: 'Invalid page' }, { status: 400 }); } }), http.post(`${apiUrl}/control/fail-next-write`, () => { store.failNextMove(); return HttpResponse.json({ armed: true }); }), http.post(`${apiUrl}/moves`, async ({ request }) => { try { const body = await request.json() as { changedCards?: unknown }; signal.throwIfAborted(); request.signal.throwIfAborted(); return HttpResponse.json(store.move(body.changedCards)); } catch (error) { return HttpResponse.json({ message: error instanceof Error ? error.message : 'Invalid move batch' }, { status: error instanceof Error && error.message === 'Simulated save unavailable' ? 503 : 400 }); } }), ] }; },};Try it: choose Load next page and compare loaded cards with the known server total. Viewport preloading may request another page automatically.
Remote card pages
Section titled “Remote card pages”Use kanban.remote when the canonical collection should arrive from one
whole-board offset/limit stream:
All existing loadData(skip, limit) callbacks use the built-in query engine. Add
optional remote.query for native polling, refetch, retry and shared-source policy;
see Kanban query usage and configuration types.
Start with Your First Board; these recipes extend its grid and config.
// The backend must return this same repeating status/rank order.const statuses = ['todo', 'doing', 'done'];const loadCards = async (skip: number, limit: number) => { const response = await fetch(`/api/cards?skip=${skip}&limit=${limit}`); const result = await response.json(); return { data: result.cards, total: result.total, };};
grid.kanban = { columns: config.columns, remote: { loadData: loadCards, total: 100_000, chunkSize: 100, preloadThreshold: 0.75, placeholder: (index) => ({ id: `remote-${index}`, status: statuses[index % statuses.length], order: Math.floor(index / statuses.length), }), },};With total and placeholder, Kanban works like Infinity Scroll: it creates the
known number of lightweight positions, renders an animated skeleton for each
unloaded card row, and replaces the requested 100-card slice in place. The
placeholder only needs a unique temporary id, workflow column, swimlane when
configured, and rank fields. Its ID may differ from the returned server card ID;
the source offset owns replacement identity. The workflow, swimlane, and rank
values must match the corresponding server card. Scrolling directly to an
unloaded area requests that area’s chunk; approaching a loaded chunk boundary
preloads the next one.
For automatic refresh, add query: { refetchInterval: 5_000 } to the same remote
object. Indexed mode refreshes mounted blocks in their exact source slots. Append
mode refreshes the retained sequence from its first page and recomputes subsequent
offsets from the new page lengths. refreshRemote() still resets to page zero and
clears local history. Automatic refetch applies authoritative server data without
creating a local history entry.
The optional cancellation context uses position ten of the existing Infinity Scroll loader signature. Positions three through nine remain undefined for whole-board Kanban reads:
import type { KanbanRemoteConfig } from '@revolist/kanban';const loadData: KanbanRemoteConfig<Card>['loadData'] = async (...args) => { const [skip, limit] = args; const response = await fetch(`/api/cards?skip=${skip}&limit=${limit}`, { signal: args[9]?.signal, }); if (!response.ok) throw new Error('Could not load cards'); return response.json();};Each board gets a private cache by default. Equivalent boards can explicitly share
query.source; destroying one board releases only its subscriptions. Destroying
the last subscriber aborts its signal and stops polling. query: false keeps direct
manual reads and disables native caching/refetch policy. Loaded data is detached
for each board. Server filter/dataset changes must use a matching source identity
or replace the loader/query configuration; board filters remain local projection
filters. There is no per-column paging contract.
Without placeholder, remote mode retains append paging: page zero replaces
the current cards and later pages append as the board nears its scroll boundary.
Both modes preserve loaded cards on errors and expose the same inline retry
action.
The result can be a card array or { data, total?, hasMore? }. In append mode,
a returned total takes precedence over hasMore; otherwise an explicit
hasMore wins, then the configured or previously returned total is consulted,
and only then does a short page end the stream. Indexed mode treats the
configured total as its fixed layout: every request must return its complete
requested slice, and response total or hasMore values must agree with that
layout. A mismatch keeps the slice retryable and emits the remote error event.
Local update and move operations preserve indexed server positions. Create and
delete are rejected in indexed mode because they would shift every later
offset; perform those operations on the server and call refreshRemote().
Replacing grid.source externally restarts indexed loading from page zero. In
append mode, local CRUD does not rewind the cursor and an external source
replacement adopts its length as the next offset. Both behaviors invalidate a
late response.
The loader type and indexed chunk behavior are shared with
InfinityScrollPlugin, so the same application adapter can power a table or
Kanban view. Kanban needs the additional placeholder factory because an
unloaded card’s workflow column and optional swimlane cannot be inferred from a
global total. The two plugins should not own one grid simultaneously because
Kanban projects cards into its own virtual rows.
Choosing a mode
Section titled “Choosing a mode”| Mode | Use when | What users see |
|---|---|---|
| Append | You can fetch the next ordered card page | Loaded cards plus subsequent cards as they arrive |
| Indexed | You know a fixed total and can place every unloaded position | Stable board slots with skeletons replaced in place |
placeholder(index) must describe the same workflow column, lane and rank as the eventual server record. A global total alone cannot tell Kanban where an unloaded card belongs.
What remains local
Section titled “What remains local”Search, derived filter choices and append-mode WIP counts cover loaded cards. Indexed placeholders can participate in counts, but they do not replace backend validation. Create/delete in indexed mode must happen on the server followed by refresh because inserting a record shifts later offsets.
Next steps
Section titled “Next steps”See caching, server saving and large boards. See the Kanban API for exact types.