Skip to content

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.

ScenarioRecommended Approach
< 1,000 rowsLoad all data upfront (no plugin needed)
1,000 - 10,000 rowsConsider based on network speed and data complexity
10,000+ rowsUse ServerSidePlugin
Infinite scroll / paginationUse ServerSidePlugin
Data changes frequently on serverUse ServerSidePlugin with refresh()
  • 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 sortHandler and filterHandler
import '@toolbox-web/grid/features/server-side';

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,
},
},
};

Toggle between virtual scrolling and paging mode, adjust the page size, and enable server-side sorting — all in one demo.

Scroll mode Virtual scroll loads rows on demand; Paging shows one page at a time
Page size Rows fetched per request
Prefetch threshold Prefetch the next block when the user is within this many rows of an unloaded block (virtual mode only)
Server-side sorting Sort on the server instead of client-side
Server-side filtering Filter on the server instead of client-side

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.

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 / filterModel is omitted from GetRowsParams so 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 casesortModefilterMode
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'

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.

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 — call getRows() receives an AbortSignal on params.signal. Pass it to fetch(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 calls unsubscribe() when the request is superseded; HttpClient cancels the underlying XHR in response to that. No firstValueFrom, no takeUntil, 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.

See ServerSideConfig for the full list of options and defaults.

const plugin = grid.getPluginByName('serverSide');
plugin.setDataSource(newDataSource); // Replace the data source at runtime
plugin.refresh(); // Reload current viewport from server
plugin.purgeCache(); // Clear all cached blocks
plugin.getTotalNodeCount(); // Get server-reported total node count
plugin.isNodeLoaded(index); // Check if a specific node is in cache
plugin.getLoadedBlockCount(); // Number of blocks currently cached

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:

  1. onScroll event fires
  2. Plugin calculates which blocks are needed for the visible viewport
  3. Missing blocks are requested from the data source
  4. requestRender() is called when data arrives
  5. 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,
}]

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.

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 updates
const 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);
};

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 render
ws.onmessage = (event) => {
const msg = JSON.parse(event.data);
grid.applyTransactionAsync({
update: [{ id: msg.id, changes: msg.changes }],
});
};
ScenarioMethodAnimations
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 streamingsetDataSource() + applyTransaction()Mixed

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 }] });
});

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:data fires, a structural plugin (Tree or Row Grouping) may claim the data by setting detail.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-children query. ServerSidePlugin calls getChildRows() and broadcasts the result as datasource:children with a source discriminator 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.

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: [] };
}

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.

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.

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.

Event / QueryDirectionDescription
datasource:dataServerSide → pluginsRoot data block loaded. Structural plugins may claim it; unclaimed data is rendered as flat rows
datasource:childrenServerSide → pluginsChild data loaded. Filtered by context.source
datasource:loadingServerSide → pluginsLoading state changed (with optional context)
datasource:errorServerSide → pluginsFetch failed (with optional context)
datasource:fetch-childrenPlugins → ServerSideQuery requesting child rows for a context
datasource:is-activePlugins → ServerSideQuery checking if a data source is configured
datasource:viewport-mappingServerSide → pluginsQuery mapping viewport indices to node-space indices
CodeLevelDescription
TBW140errorgetRows() fetch failed
TBW141errorgetChildRows() fetch failed
TBW142warnPlugin requested children but getChildRows() is not implemented
TBW143infodatasource:data was not claimed by any structural plugin (flat grid mode)
  • 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.
AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://raw.githubusercontent.com/OysteinAmundsen/toolbox/main/llms-full.txt