Server-Side Plugin
The Server-Side plugin enables virtual scrolling with lazy loading from a remote API. It’s designed for large datasets (10,000+ rows) where loading all data upfront would be impractical or slow.
When to Use This Plugin
Section titled “When to Use This Plugin”| Scenario | Recommended Approach |
|---|---|
| < 1,000 rows | Load all data upfront (no plugin needed) |
| 1,000 - 10,000 rows | Consider based on network speed and data complexity |
| 10,000+ rows | Use ServerSidePlugin |
| Infinite scroll / pagination | Use ServerSidePlugin |
| Data changes frequently on server | Use ServerSidePlugin with refresh() |
Key Features
Section titled “Key Features”- Block-based fetching: Loads only visible rows plus a buffer
- LRU caching: Keeps recently viewed blocks in memory
- Automatic prefetching: Loads next blocks as user scrolls
- Concurrent request limiting: Prevents overwhelming the server
- Loading placeholders: Shows loading state for pending rows
- Integration with sorting/filtering: Works with
sortHandlerandfilterHandler
Installation
Section titled “Installation”import '@toolbox-web/grid/features/server-side';Basic Usage
Section titled “Basic Usage”The feature requires a data source that implements the getRows method. Pass it directly in the config via dataSource:
import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/server-side';import type { GetRowsParams, ServerSideDataSource } from '@toolbox-web/grid/plugins/server-side';
const dataSource: ServerSideDataSource = { async getRows(params: GetRowsParams) { // params: { startNode, endNode, sortModel, filterModel } const response = await fetch(`/api/data?start=${params.startNode}&end=${params.endNode}`); const data = await response.json(); return { rows: data.rows, totalNodeCount: data.total, // Required for scroll height calculation }; },};
const grid = queryGrid('tbw-grid');grid.gridConfig = { columns: [ { field: 'id', header: 'ID' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, ], features: { serverSide: { pageSize: 50, dataSource, }, },};import '@toolbox-web/grid-react/features/server-side';import { DataGrid } from '@toolbox-web/grid-react';import type { GetRowsParams, ServerSideDataSource } from '@toolbox-web/grid/plugins/server-side';
const dataSource: ServerSideDataSource = { async getRows(params: GetRowsParams) { const response = await fetch(`/api/data?start=${params.startNode}&end=${params.endNode}`); const data = await response.json(); return { rows: data.rows, totalNodeCount: data.total }; },};
function ServerSideGrid() { return ( <DataGrid columns={[ { field: 'id', header: 'ID' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, ]} serverSide={{ pageSize: 50, dataSource }} style={{ height: '400px' }} /> );}<script setup lang="ts">import '@toolbox-web/grid-vue/features/server-side';import { TbwGrid, TbwGridColumn } from '@toolbox-web/grid-vue';import type { GetRowsParams, ServerSideDataSource } from '@toolbox-web/grid/plugins/server-side';
const dataSource: ServerSideDataSource = { async getRows(params: GetRowsParams) { const response = await fetch(`/api/data?start=${params.startNode}&end=${params.endNode}`); const data = await response.json(); return { rows: data.rows, totalNodeCount: data.total }; },};
const serverSideConfig = { pageSize: 50, dataSource,};</script>
<template> <TbwGrid :server-side="serverSideConfig" style="height: 400px"> <TbwGridColumn field="id" header="ID" /> <TbwGridColumn field="name" header="Name" /> <TbwGridColumn field="email" header="Email" /> </TbwGrid></template>// Feature import - enables the [serverSide] inputimport { GridServerSideDirective } from '@toolbox-web/grid-angular/features/server-side';import { Component } from '@angular/core';import { Grid } from '@toolbox-web/grid-angular';import type { ColumnConfig } from '@toolbox-web/grid';import type { GetRowsParams, ServerSideDataSource } from '@toolbox-web/grid/plugins/server-side';
@Component({ selector: 'app-server-side-grid', imports: [Grid, GridServerSideDirective], template: ` <tbw-grid [columns]="columns" [serverSide]="serverSideConfig" style="height: 400px; display: block;"> </tbw-grid> `,})export class ServerSideGridComponent { columns: ColumnConfig[] = [ { field: 'id', header: 'ID' }, { field: 'name', header: 'Name' }, { field: 'email', header: 'Email' }, ];
dataSource: ServerSideDataSource = { getRows: async (params: GetRowsParams) => { const response = await fetch(`/api/data?start=${params.startNode}&end=${params.endNode}`); const data = await response.json(); return { rows: data.rows, totalNodeCount: data.total }; }, };
serverSideConfig = { pageSize: 50, dataSource: this.dataSource, };}Toggle between virtual scrolling and paging mode, adjust the page size, and enable server-side sorting — all in one demo.
<tbw-grid style="height: 400px;"></tbw-grid>import '@toolbox-web/grid';import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/filtering';import '@toolbox-web/grid/features/pinned-rows';import '@toolbox-web/grid/features/server-side';
const grid = queryGrid('tbw-grid');const pager = document.querySelector('.pager');const prevBtn = pager.querySelector('.prev');const nextBtn = pager.querySelector('.next');const pageInfo = pager.querySelector('.page-info');
const columns = [ { field: 'id', header: 'ID', type: 'number', sortable: true, filterable: false }, { field: 'name', header: 'Name', sortable: true }, { field: 'department', header: 'Department', sortable: true }, { field: 'salary', header: 'Salary', type: 'number', sortable: true }, { field: 'email', header: 'Email' },];
const departments = ['Engineering', 'Marketing', 'Sales', 'HR', 'Finance'];const allData = Array.from({ length: 10000 }, (_, i) => ({ id: i + 1, name: `Employee ${i + 1}`, department: departments[i % departments.length], salary: 50000 + Math.floor(Math.random() * 50000), email: `employee${i + 1}@example.com`,}));
let currentPage = 0;let currentPageSize = 50;let pageData = allData; // sorted/filtered view used by paging modelet pageSortModel: { field: string; direction: 'asc' | 'desc' } | null = null;let pageFilterModel: Record<string, any> | undefined;// In-flight getRows count. Used by the right-side custom panel to switch// between "Scroll to load more rows..." and "Loading..." messages.let loadingCount = 0;
function refreshPinnedRows() { grid.getPluginByName('pinnedRows')?.refresh?.();}
function sortData(data: typeof allData, sort: { field: string; direction: 'asc' | 'desc' }) { const dir = sort.direction === 'asc' ? 1 : -1; return [...data].sort((a, b) => { const aVal = (a)[sort.field]; const bVal = (b)[sort.field]; const cmp = aVal < bVal ? -1 : aVal > bVal ? 1 : 0; return cmp * dir; });}
function applyFilters(data: typeof allData, filterModel: Record<string, any> | undefined) { if (!filterModel) return data; const fields = Object.keys(filterModel); if (fields.length === 0) return data; return data.filter((row) => fields.every((field) => { const f = filterModel[field]; if (!f) return true; const val = (row)[field]; // Set filter: { value: string[] } — keep rows whose value is in the set. if (Array.isArray(f.value)) return f.value.includes(val); // Text contains (default for strings) if (typeof f.value === 'string') { return String(val).toLowerCase().includes(f.value.toLowerCase()); } return true; }), );}
function createDataSource() { return { async getRows(params: any) { loadingCount++; refreshPinnedRows(); try { await new Promise((r) => setTimeout(r, 200)); let data = allData; if (params.filterModel) data = applyFilters(data, params.filterModel); if (params.sortModel?.length) data = sortData(data, params.sortModel[0]); return { rows: data.slice(params.startNode, params.endNode), totalNodeCount: data.length }; } finally { loadingCount = Math.max(0, loadingCount - 1); refreshPinnedRows(); } }, };}
function recomputePageData() { let data = allData; if (pageFilterModel) data = applyFilters(data, pageFilterModel); if (pageSortModel) data = sortData(data, pageSortModel); pageData = data;}
function updatePager() { const totalPages = Math.ceil(pageData.length / currentPageSize); pageInfo.textContent = `Page ${currentPage + 1} of ${totalPages}`; prevBtn.disabled = currentPage === 0; nextBtn.disabled = currentPage >= totalPages - 1;}
function loadPage(page: number) { currentPage = page; grid.rows = pageData.slice(page * currentPageSize, (page + 1) * currentPageSize); updatePager();}
function rebuild(values: Record<string, unknown>) { const mode = values.mode; const pageSize = values.pageSize; const loadThreshold = values.loadThreshold; const serverSort = values.serverSort; const serverFilter = values.serverFilter; currentPageSize = pageSize;
const config: any = { columns };
if (mode === 'virtual') { pager.style.display = 'none'; config.features = { // When the corresponding toggle is OFF, sort/filter the already-loaded // rows locally (no refetch). When ON, the default 'server' mode triggers // a refetch through getRows with sortModel/filterModel. serverSide: { pageSize, cacheBlockSize: pageSize, loadThreshold, sortMode: serverSort ? 'server' : 'local', filterMode: serverFilter ? 'server' : 'local', }, filtering: true, pinnedRows: { position: 'bottom', showRowCount: false, customPanels: [ { id: 'loaded-count', position: 'left', render: (ctx: any) => { const total = ctx.totalRows; const rows = (ctx.grid).rows; const loaded = Array.isArray(rows) ? rows.filter((r) => !(r)?.__loading).length : 0; return loaded >= total ? `Rows: ${total}` : `Rows: ${loaded}/${total}`; }, }, { id: 'scroll-info', position: 'right', render: (ctx: any) => { if (loadingCount > 0) return '<em>Loading...</em>'; const total = ctx.totalRows; const rows = (ctx.grid).rows; const loaded = Array.isArray(rows) ? rows.filter((r) => !(r)?.__loading).length : 0; if (loaded >= total) return ''; return '<em>Scroll to load more rows...</em>'; }, }, ], }, }; } else { pager.style.display = 'flex'; currentPage = 0; pageSortModel = null; pageFilterModel = undefined; recomputePageData(); // Paging mode is a custom code path: the serverSide plugin isn't used, // so we wire sort-change/filter-change ourselves and rebuild the page // from the full data set. Honors the serverSort/serverFilter toggles by // simulating a server round-trip (the local-only branch would only sort // the 50 rows already on screen). config.features = { filtering: true }; }
grid.gridConfig = config;
if (mode === 'virtual') { grid.ready().then(() => grid.getPluginByName('serverSide')?.setDataSource(createDataSource())); } else { // Paging mode wires sort/filter listeners itself. When the toggle is ON // ("server"), reslice from the full dataset; when OFF ("local"), let the // grid sort/filter just the current page in place (default behavior). grid.ready().then(() => { if (serverSort) { grid.addEventListener('sort-change', ((e: CustomEvent) => { const detail = e.detail; pageSortModel = detail?.field && (detail.direction === 1 || detail.direction === -1) ? { field: detail.field, direction: detail.direction === 1 ? 'asc' : 'desc' } : null; recomputePageData(); loadPage(0); })); } if (serverFilter) { grid.addEventListener('filter-change', (() => { const active = grid.getPluginByName('filtering')?.getActiveFilters?.() ?? []; pageFilterModel = active.length ? Object.fromEntries(active.map((f: any) => [f.field, f])) : undefined; recomputePageData(); loadPage(0); })); } loadPage(0); }); }}
rebuild({ mode: 'virtual', pageSize: 50, loadThreshold: 0, serverSort: false, serverFilter: false });
prevBtn.addEventListener('click', () => loadPage(currentPage - 1));nextBtn.addEventListener('click', () => loadPage(currentPage + 1));Server-Side Sorting & Filtering
Section titled “Server-Side Sorting & Filtering”The plugin handles sorting and filtering natively: when the user clicks a sortable header or applies a column filter, the cache is purged and getRows() is called again with sortModel and filterModel populated. Your handler decides how to translate those into the request:
async getRows(params: GetRowsParams) { const url = new URL('/api/data', location.origin); url.searchParams.set('start', String(params.startNode)); url.searchParams.set('end', String(params.endNode)); if (params.sortModel?.length) { url.searchParams.set('sort', params.sortModel[0].field); url.searchParams.set('dir', params.sortModel[0].direction); } if (params.filterModel) { url.searchParams.set('filter', JSON.stringify(params.filterModel)); } const res = await fetch(url); return res.json();}Toggle Server-side sorting / Server-side filtering in the demo above to see the request parameters change. Toggling them off switches to local mode — the plugin keeps the loaded blocks and sorts/filters them in the browser without a refetch.
Local Sort / Filter Modes
Section titled “Local Sort / Filter Modes”By default, the ServerSidePlugin treats every sort or filter change as a model change: it purges the cache and refetches all visible blocks. This is correct when the backend owns ordering and filtering, but if you want to sort or filter the already-loaded rows in the browser without a roundtrip, set the corresponding mode to 'local':
serverSide: { dataSource, sortMode: 'local', // re-sort cached rows in place; no refetch filterMode: 'local', // re-filter cached rows in place; no refetch}When a mode is 'local':
- The plugin no longer refetches on
sort-change/filter-change— it just requests a re-render. - The corresponding
sortModel/filterModelis omitted fromGetRowsParamsso scroll-triggered block fetches don’t leak local state to the backend. - Already-loading placeholder rows are pinned to the end of the sort order so the in-progress fetch stays visually grouped.
| Use case | sortMode | filterMode |
|---|---|---|
| Backend owns sort + filter (default) | 'server' | 'server' |
| Backend filters; user re-sorts on screen | 'local' | 'server' |
| Backend pre-sorts; user filters on screen | 'server' | 'local' |
| All processing on already-loaded page | 'local' | 'local' |
Prefetching with loadThreshold
Section titled “Prefetching with loadThreshold”By default the plugin only fetches a block when the visible viewport enters it. On gentle scrolling this means users see placeholder rows for the duration of the network round-trip. Set loadThreshold to prefetch the next block(s) before the user scrolls into them:
serverSide: { dataSource, pageSize: 100, loadThreshold: 50, // start fetching the next block when within 50 rows of it}The threshold is applied symmetrically (the viewport is expanded by loadThreshold rows in both directions before block coverage is computed). The maxConcurrentRequests cap and per-block dedup still apply, so a large threshold during fast scrolling will not flood the server.
A reasonable starting point is pageSize / 2. Values larger than cacheBlockSize will eagerly request 2+ blocks ahead, which can hurt perceived performance with slow backends.
Request Cancellation
Section titled “Request Cancellation”getRows() may return either a Promise or an Observable (anything with a .subscribe() method — RxJS Observable, the TC39 Observable proposal shape, etc.). The plugin cancels superseded requests automatically:
-
Promise /
fetch— callgetRows()receives anAbortSignalonparams.signal. Pass it tofetch(url, { signal })and the browser cancels the request:async getRows(params) {const res = await fetch(`/api/data?start=${params.startNode}&end=${params.endNode}`, {signal: params.signal,});const data = await res.json();return { rows: data.items, totalNodeCount: data.total };} -
Observable / Angular
HttpClient— return the observable directly. The plugin subscribes once and callsunsubscribe()when the request is superseded;HttpClientcancels the underlying XHR in response to that. NofirstValueFrom, notakeUntil, no signal plumbing:getRows: (params) =>this.http.get<EmployeesResponse>('/api/data', { params: toHttpParams(params) }).pipe(map((d) => ({ rows: d.items, totalNodeCount: d.total }))),
A request is considered superseded when the sort/filter model changes mid-flight, when refresh() or purgeCache() is called, or when the plugin detaches. If a data source ignores cancellation entirely, the plugin still discards the late result so a stale block can’t overwrite a freshly-loaded one.
Configuration Options
Section titled “Configuration Options”See ServerSideConfig for the full list of options and defaults.
TypeScript Interfaces
Section titled “TypeScript Interfaces”ServerSideDataSource— Data source contract withgetRows()and optionalgetChildRows()GetRowsParams— Parameters passed togetRows()(startNode, endNode, sortModel, filterModel, signal)GetRowsResult— Return type withrowsandtotalNodeCountGetChildRowsParams— Parameters forgetChildRows()with plugin contextGetChildRowsResult— Return type withrows
Programmatic API
Section titled “Programmatic API”const plugin = grid.getPluginByName('serverSide');
plugin.setDataSource(newDataSource); // Replace the data source at runtimeplugin.refresh(); // Reload current viewport from serverplugin.purgeCache(); // Clear all cached blocksplugin.getTotalNodeCount(); // Get server-reported total node countplugin.isNodeLoaded(index); // Check if a specific node is in cacheplugin.getLoadedBlockCount(); // Number of blocks currently cachedArchitecture: How It Works
Section titled “Architecture: How It Works”The plugin uses a block-based caching strategy:
┌────────────────────────────────────────────────┐│ Total Dataset: 100,000 rows (on server) │├────────────────────────────────────────────────┤│ Block 0: rows 0-99 [CACHED] ││ Block 1: rows 100-199 [CACHED] ││ Block 2: rows 200-299 [LOADING...] ││ Block 3: rows 300-399 [NOT LOADED] ││ ... ││ Block 999: rows 99900-99999 [NOT LOADED] │└────────────────────────────────────────────────┘Scroll triggers:
onScrollevent fires- Plugin calculates which blocks are needed for the visible viewport
- Missing blocks are requested from the data source
requestRender()is called when data arrives- Grid re-renders with new data
Loading state: Rows that haven’t loaded yet are represented with placeholder objects:
{ __loading: true, __index: 42 }You can detect loading rows via a custom cell renderer and style them accordingly:
columns: [{ field: 'name', renderer: (value, row) => row.__loading ? '<span class="loading">Loading…</span>' : value,}]Live Data Updates (WebSocket/SSE)
Section titled “Live Data Updates (WebSocket/SSE)”The grid’s Transaction API lets you push real-time updates from any streaming source — WebSocket, Server-Sent Events, polling, or any other transport — into the grid efficiently.
The grid deliberately does not manage transport connections. Your application owns the WebSocket/SSE lifecycle (authentication, reconnection, backoff), and the grid provides the efficient mutation primitives to apply incoming changes.
WebSocket Example
Section titled “WebSocket Example”import { queryGrid } from '@toolbox-web/grid';import '@toolbox-web/grid/features/server-side';import type { RowTransaction } from '@toolbox-web/grid';import type { GetRowsParams, ServerSideDataSource } from '@toolbox-web/grid/plugins/server-side';
interface Employee { id: string; name: string; status: string;}
const dataSource: ServerSideDataSource<Employee> = { async getRows(params: GetRowsParams) { const res = await fetch(`/api/employees?start=${params.startNode}&end=${params.endNode}`); return res.json(); },};
const grid = queryGrid<Employee>('tbw-grid');grid.gridConfig = { columns: [ { field: 'id', header: 'ID' }, { field: 'name', header: 'Name' }, { field: 'status', header: 'Status' }, ], features: { serverSide: { pageSize: 50, dataSource, }, },};
// Connect WebSocket for live updatesconst ws = new WebSocket('wss://api.example.com/employees/live');
ws.onmessage = (event) => { const msg = JSON.parse(event.data);
const tx: RowTransaction<Employee> = {}; switch (msg.type) { case 'insert': tx.add = [msg.row]; break; case 'update': tx.update = [{ id: msg.rowId, changes: msg.changes }]; break; case 'delete': tx.remove = [{ id: msg.rowId }]; break; }
grid.applyTransaction(tx);};import '@toolbox-web/grid-react/features/server-side';import { DataGrid, useGrid } from '@toolbox-web/grid-react';import type { RowTransaction } from '@toolbox-web/grid';import type { GetRowsParams, ServerSideDataSource } from '@toolbox-web/grid/plugins/server-side';import { useEffect } from 'react';
interface Employee { id: string; name: string; status: string;}
const dataSource: ServerSideDataSource<Employee> = { async getRows(params: GetRowsParams) { const res = await fetch(`/api/employees?start=${params.startNode}&end=${params.endNode}`); return res.json(); },};
function LiveGrid() { const { ref, element } = useGrid<Employee>();
// Connect WebSocket for live updates useEffect(() => { if (!element) return; const ws = new WebSocket('wss://api.example.com/employees/live');
ws.onmessage = (event) => { const msg = JSON.parse(event.data); const tx: RowTransaction<Employee> = {}; if (msg.type === 'insert') tx.add = [msg.row]; if (msg.type === 'update') tx.update = [{ id: msg.rowId, changes: msg.changes }]; if (msg.type === 'delete') tx.remove = [{ id: msg.rowId }]; element.applyTransaction(tx); };
return () => ws.close(); }, [element]);
return ( <DataGrid ref={ref} columns={[ { field: 'id', header: 'ID' }, { field: 'name', header: 'Name' }, { field: 'status', header: 'Status' }, ]} serverSide={{ pageSize: 50, dataSource }} style={{ height: '400px' }} /> );}<script setup lang="ts">import '@toolbox-web/grid-vue/features/server-side';import { TbwGrid, TbwGridColumn, useGrid } from '@toolbox-web/grid-vue';import type { RowTransaction } from '@toolbox-web/grid';import type { GetRowsParams, ServerSideDataSource } from '@toolbox-web/grid/plugins/server-side';import { watch, onUnmounted } from 'vue';
interface Employee { id: string; name: string; status: string;}
const dataSource: ServerSideDataSource<Employee> = { async getRows(params: GetRowsParams) { const res = await fetch(`/api/employees?start=${params.startNode}&end=${params.endNode}`); return res.json(); },};
const serverSideConfig = { pageSize: 50, dataSource,};
const { gridElement, isReady } = useGrid<Employee>();let ws: WebSocket | null = null;
watch(isReady, (ready) => { if (!ready) return; const grid = gridElement.value;
ws = new WebSocket('wss://api.example.com/employees/live'); ws.onmessage = (event) => { const msg = JSON.parse(event.data); const tx: RowTransaction<Employee> = {}; if (msg.type === 'insert') tx.add = [msg.row]; if (msg.type === 'update') tx.update = [{ id: msg.rowId, changes: msg.changes }]; if (msg.type === 'delete') tx.remove = [{ id: msg.rowId }]; grid?.applyTransaction(tx); };});
onUnmounted(() => ws?.close());</script>
<template> <TbwGrid :server-side="serverSideConfig" style="height: 400px"> <TbwGridColumn field="id" header="ID" /> <TbwGridColumn field="name" header="Name" /> <TbwGridColumn field="status" header="Status" /> </TbwGrid></template>import { GridServerSideDirective } from '@toolbox-web/grid-angular/features/server-side';import { Component, effect, OnDestroy } from '@angular/core';import { Grid, injectGrid } from '@toolbox-web/grid-angular';import type { ColumnConfig, RowTransaction } from '@toolbox-web/grid';import type { GetRowsParams, ServerSideDataSource } from '@toolbox-web/grid/plugins/server-side';
interface Employee { id: string; name: string; status: string;}
@Component({ selector: 'app-live-grid', imports: [Grid, GridServerSideDirective], template: ` <tbw-grid [columns]="columns" [serverSide]="serverSideConfig" style="height: 400px; display: block;"> </tbw-grid> `,})export class LiveGridComponent implements OnDestroy { grid = injectGrid<Employee>(); columns: ColumnConfig[] = [ { field: 'id', header: 'ID' }, { field: 'name', header: 'Name' }, { field: 'status', header: 'Status' }, ];
dataSource: ServerSideDataSource<Employee> = { async getRows(params: GetRowsParams) { const res = await fetch(`/api/employees?start=${params.startNode}&end=${params.endNode}`); return res.json(); }, };
serverSideConfig = { pageSize: 50, dataSource: this.dataSource, };
private ws: WebSocket | null = null;
constructor() { effect(() => { const el = this.grid.element(); if (!el) return;
this.ws = new WebSocket('wss://api.example.com/employees/live'); this.ws.onmessage = (event) => { const msg = JSON.parse(event.data); const tx: RowTransaction<Employee> = {}; if (msg.type === 'insert') tx.add = [msg.row]; if (msg.type === 'update') tx.update = [{ id: msg.rowId, changes: msg.changes }]; if (msg.type === 'delete') tx.remove = [{ id: msg.rowId }]; el.applyTransaction(tx); }; }); }
ngOnDestroy() { this.ws?.close(); }}High-Frequency Streams
Section titled “High-Frequency Streams”For data feeds with many updates per second (e.g. financial tickers, IoT sensors), use applyTransactionAsync() to automatically batch all updates arriving within a single animation frame:
// All messages within one frame are merged into a single renderws.onmessage = (event) => { const msg = JSON.parse(event.data); grid.applyTransactionAsync({ update: [{ id: msg.id, changes: msg.changes }], });};Choosing the Right Method
Section titled “Choosing the Right Method”| Scenario | Method | Animations |
|---|---|---|
| User actions, moderate streams (< 10 msg/s) | applyTransaction() | Yes (configurable) |
| High-frequency ticker (100+ msg/s) | applyTransactionAsync() | Disabled (batched) |
| Bulk initial load from REST, then switch to streaming | setDataSource() + applyTransaction() | Mixed |
Server-Sent Events (SSE)
Section titled “Server-Sent Events (SSE)”SSE works the same way — just swap the transport:
const source = new EventSource('/api/employees/stream');
source.addEventListener('insert', (e) => { grid.applyTransaction({ add: [JSON.parse(e.data)] });});
source.addEventListener('update', (e) => { const { id, changes } = JSON.parse(e.data); grid.applyTransaction({ update: [{ id, changes }] });});
source.addEventListener('delete', (e) => { const { id } = JSON.parse(e.data); grid.applyTransaction({ remove: [{ id }] });});Composing with Other Plugins
Section titled “Composing with Other Plugins”ServerSidePlugin acts as the data layer in the grid’s unified DataSource architecture. In its simplest form it drives a flat grid with no extra plugins. It also composes with structural plugins (Tree, Row Grouping) and Master-Detail for hierarchical or expandable data.
flowchart TB
subgraph SSP["ServerSidePlugin (data layer)"]
direction LR
GR["getRows()<br/>Block-based fetch"]
GCR["getChildRows()<br/>On-demand children"]
end
GR -- "broadcasts" --> E1["datasource:data"]
GCR -- "broadcasts" --> E2["datasource:children"]
E1 --> Flat & Tree & Group
E2 --> Tree & Group & MD
Flat["Flat Grid<br/>unclaimed → rendered directly"]
Tree["TreePlugin<br/>claims root data + children"]
Group["GroupingRowsPlugin<br/>claims root data + children"]
MD["MasterDetailPlugin<br/>claims children for detail panels"]
Key concepts:
- Flat grid by default: When no structural plugin (Tree or Row Grouping) claims the data, ServerSidePlugin renders the rows directly. This is the simplest and most common mode — paginated API data driving a virtual-scrolling table.
- Structural plugins claim data: When
datasource:datafires, a structural plugin (Tree or Row Grouping) may claim the data by settingdetail.claimed = true. If none claims it, ServerSidePlugin handles it as flat rows. - On-demand children: When a user expands a tree node, group, or detail row, the display plugin fires a
datasource:fetch-childrenquery. ServerSidePlugin callsgetChildRows()and broadcasts the result asdatasource:childrenwith asourcediscriminator so only the requesting plugin consumes it. - Child rows are not paginated:
getChildRows()returns all children in a single batch. If the server has a large child set, limit it server-side.
getChildRows() Interface
Section titled “getChildRows() Interface”When composing with Tree, Row Grouping, or Master-Detail, implement getChildRows() on the data source. The context.source discriminator tells you which plugin is requesting children:
import type { GetChildRowsParams, GetChildRowsResult } from '@toolbox-web/grid/plugins/server-side';
async getChildRows(params: GetChildRowsParams): Promise<GetChildRowsResult> { const { source } = params.context;
if (source === 'tree') { const { parentNode } = params.context; const res = await fetch(`/api/tree/${parentNode.id}/children`); return { rows: await res.json() }; }
if (source === 'grouping-rows') { const { groupKey } = params.context; const res = await fetch(`/api/groups/${groupKey}/rows`); return { rows: await res.json() }; }
if (source === 'master-detail') { const { row } = params.context; const res = await fetch(`/api/orders/${row.id}/items`); return { rows: await res.json() }; }
return { rows: [] };}ServerSide + Tree
Section titled “ServerSide + Tree”ServerSidePlugin fetches top-level tree nodes in blocks. TreePlugin claims the data and flattens the hierarchy. For lazy children, TreePlugin fires datasource:fetch-children with source: 'tree'.
See the Tree plugin docs for full details and examples.
ServerSide + Row Grouping
Section titled “ServerSide + Row Grouping”ServerSidePlugin fetches group definitions as root data. When a group is expanded, GroupingRowsPlugin fires datasource:fetch-children to load the group’s rows.
See the Row Grouping plugin docs for full details and examples.
ServerSide + Master-Detail
Section titled “ServerSide + Master-Detail”ServerSidePlugin manages the master rows with full virtual scrolling or pagination — loading more master rows on scroll (infinite scroll) or on page navigation, just like it does for flat data, Tree, or Row Grouping. MasterDetail does not claim root data, so master rows flow through ServerSide’s block cache and pagination unchanged.
When a detail panel is expanded, MasterDetailPlugin fires datasource:fetch-children to load the detail data on demand. Detail data can also be embedded in the master row object, in which case getChildRows() is not needed.
See the Master-Detail plugin docs for full details and examples.
DataSource Event Bus
Section titled “DataSource Event Bus”| Event / Query | Direction | Description |
|---|---|---|
datasource:data | ServerSide → plugins | Root data block loaded. Structural plugins may claim it; unclaimed data is rendered as flat rows |
datasource:children | ServerSide → plugins | Child data loaded. Filtered by context.source |
datasource:loading | ServerSide → plugins | Loading state changed (with optional context) |
datasource:error | ServerSide → plugins | Fetch failed (with optional context) |
datasource:fetch-children | Plugins → ServerSide | Query requesting child rows for a context |
datasource:is-active | Plugins → ServerSide | Query checking if a data source is configured |
datasource:viewport-mapping | ServerSide → plugins | Query mapping viewport indices to node-space indices |
Diagnostic Codes
Section titled “Diagnostic Codes”| Code | Level | Description |
|---|---|---|
TBW140 | error | getRows() fetch failed |
TBW141 | error | getChildRows() fetch failed |
TBW142 | warn | Plugin requested children but getChildRows() is not implemented |
TBW143 | info | datasource:data was not claimed by any structural plugin (flat grid mode) |
Limitations
Section titled “Limitations”- Child rows are not paginated.
getChildRows()returns all children in a single batch. For tree nodes, group rows, or detail data with many children, limit the response server-side. - One structural claim per data event. Only one of Tree or Row Grouping can claim
datasource:data. They remain mutually incompatible. When neither is active, data is rendered as flat rows. - Master-Detail is not a structural plugin. It does not claim
datasource:data— master rows are managed by ServerSide with full virtual scrolling and pagination support. MasterDetail only uses the child data path (datasource:fetch-children/datasource:children) for on-demand detail loading.
See Also
Section titled “See Also”- Common Patterns — Real-Time Data — Transport-agnostic streaming patterns
- Tree Plugin — Server-Side Data — Tree-specific data flow
- Row Grouping — Server-Side Data — Group-specific data flow
- Master-Detail — Server-Side Data — Detail panel data flow
- Filtering — Column-level filtering with async server-side support
- Multi-Sort — Multi-column sorting with async server-side support
- Export — Export grid data
- Common Patterns — Full application recipes
- Plugins Overview — Plugin compatibility and combinations