Skip to content

Common Patterns

Real-world grids rarely use a single feature in isolation. This guide shows tested combinations that solve everyday requirements.

Goal: Let users sort, filter, and select rows from a large dataset.

import '@toolbox-web/grid';
import '@toolbox-web/grid/features/selection';
import '@toolbox-web/grid/features/filtering';
import '@toolbox-web/grid/features/multi-sort';
import { queryGrid } from '@toolbox-web/grid';
import type { ColumnConfig } from '@toolbox-web/grid';
const grid = queryGrid<Employee>('#grid');
grid.gridConfig = {
columns: [
{ field: 'id', header: 'ID', type: 'number', sortable: true },
{ field: 'name', header: 'Name', sortable: true, filterable: true },
{ field: 'department', header: 'Department', filterable: true },
{ field: 'salary', header: 'Salary', type: 'number', sortable: true },
],
features: {
selection: { mode: 'row', multiSelect: true },
filtering: { debounceMs: 200 },
multiSort: true,
},
};
// React to selected rows
grid.on('selection-change', () => {
const sel = grid.getPluginByName('selection');
const rows = sel?.getSelectedRows<Employee>() ?? [];
console.log('Selected:', rows);
});

Goal: Inline cell editing with full undo/redo support.

import '@toolbox-web/grid/features/editing';
import '@toolbox-web/grid/features/undo-redo';
import '@toolbox-web/grid/features/selection';
grid.gridConfig = {
columns: [
{ field: 'id', header: 'ID', type: 'number' },
{ field: 'name', header: 'Name', editable: true },
{ field: 'email', header: 'Email', editable: true },
{ field: 'active', header: 'Active', type: 'boolean', editable: true },
],
features: {
editing: { editOn: 'dblclick', dirtyTracking: true },
undoRedo: true,
selection: 'cell',
},
getRowId: (row) => row.id, // Required for dirty tracking
};
// Validate before committing
grid.on('cell-commit', (detail, e) => {
const { field, value } = detail;
if (field === 'email' && !value.includes('@')) {
e.preventDefault(); // Reject invalid edit
}
});
// Track dirty state
grid.on('dirty-change', () => {
const editing = grid.getPluginByName('editing');
const dirtyRows = editing?.getDirtyRows() ?? [];
saveButton.disabled = dirtyRows.length === 0;
});

Goal: Group rows by a field and show aggregated values (sum, average, count).

import '@toolbox-web/grid/features/grouping-rows';
import '@toolbox-web/grid/features/selection';
const columns = [
{ field: 'department', header: 'Department' },
{ field: 'name', header: 'Name' },
{
field: 'salary',
header: 'Salary',
type: 'number',
format: (value) => `$${value.toLocaleString()}`,
},
{ field: 'id', header: 'Count', type: 'number' },
];
grid.gridConfig = {
columns,
features: {
groupingRows: {
groupOn: (row) => [row.department],
defaultExpanded: true,
// Aggregators live on the grouping config, keyed by field name.
// Built-ins: 'sum' | 'avg' | 'min' | 'max' | 'count' (or a custom function).
aggregators: { salary: 'avg', id: 'count' },
},
selection: 'row',
},
};

Goal: Let users filter data, select a subset, and export it.

import '@toolbox-web/grid/features/export';
import '@toolbox-web/grid/features/selection';
import '@toolbox-web/grid/features/filtering';
grid.gridConfig = {
columns: [
{ field: 'name', header: 'Name', filterable: true },
{ field: 'email', header: 'Email' },
{ field: 'department', header: 'Department', filterable: true },
],
features: {
selection: { mode: 'row', multiSelect: true },
filtering: true,
// Always export only selected rows. Omit `onlySelected` (or pass `rowIndices`
// at call time) to export the full filtered/visible dataset instead.
export: { onlySelected: true },
},
};
// Export button
exportButton.addEventListener('click', () => {
const exp = grid.getPluginByName('export');
exp?.exportCsv({ fileName: 'employees' }); // .csv extension is added automatically
});

Goal: Expand a row to show related detail records.

import '@toolbox-web/grid/features/master-detail';
grid.gridConfig = {
columns: [
{ field: 'orderId', header: 'Order', type: 'number' },
{ field: 'customer', header: 'Customer' },
{ field: 'total', header: 'Total', type: 'number' },
],
features: {
masterDetail: {
// Vanilla signature: (row, rowIndex) => HTMLElement | string
detailRenderer: (row) => {
const container = document.createElement('div');
container.style.padding = '1rem';
container.innerHTML = `<strong>Order #${row.orderId}</strong>`;
// Nested grid for order items
const detail = document.createElement('tbw-grid') as any;
detail.style.height = '200px';
detail.style.display = 'block';
detail.columns = [
{ field: 'item', header: 'Item' },
{ field: 'qty', header: 'Qty', type: 'number' },
{ field: 'price', header: 'Price', type: 'number' },
];
detail.rows = row.items; // Nested data
container.appendChild(detail);
return container;
},
},
},
};

Goal: Handle 10k+ rows with pinned identifier columns and column virtualization.

import '@toolbox-web/grid/features/pinned-columns';
import '@toolbox-web/grid/features/column-virtualization';
import '@toolbox-web/grid/features/multi-sort';
import '@toolbox-web/grid/features/filtering';
grid.gridConfig = {
columns: [
{ field: 'id', header: 'ID', type: 'number', pinned: 'left', width: 80 },
{ field: 'name', header: 'Name', pinned: 'left', width: 180 },
// ... many more columns
],
features: {
pinnedColumns: true,
columnVirtualization: true,
multiSort: true,
filtering: { debounceMs: 300 },
},
fitMode: 'fixed', // Use natural column widths instead of stretching to fill
};

Goal: Push live updates from a WebSocket, SSE, or polling source into the grid efficiently.

Use applyTransaction() to batch add, update, and remove operations into a single render cycle. For high-frequency streams (many messages per second), applyTransactionAsync() automatically merges all calls within one animation frame.

import { createGrid } from '@toolbox-web/grid';
import type { RowTransaction } from '@toolbox-web/grid';
const grid = createGrid<Trade>('#my-grid');
// --- Low-to-moderate frequency: applyTransaction ---
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
grid.applyTransaction({
add: msg.type === 'add' ? [msg.row] : undefined,
update: msg.type === 'update' ? [{ id: msg.id, changes: msg.changes }] : undefined,
remove: msg.type === 'remove' ? [{ id: msg.id }] : undefined,
});
};
// --- High frequency: applyTransactionAsync ---
// Merges rapid calls within a single animation frame
ws.onmessage = (e) => {
const msg = JSON.parse(e.data);
grid.applyTransactionAsync({
update: [{ id: msg.id, changes: msg.changes }],
});
};
interface RowTransaction<T> {
add?: T[]; // Appended to the end
update?: { id: string; changes: Partial<T> }[]; // In-place mutation by row ID
remove?: { id: string }[]; // Removed by row ID
}

Operations are applied in order: removes → updates → adds. This ensures updates don’t target rows about to be removed, and new rows don’t collide with existing IDs.

ScenarioMethodAnimations
User action, moderate stream (< 10 msg/s)applyTransaction()Yes (configurable)
High-frequency ticker (100+ msg/s)applyTransactionAsync()Disabled (batched)

Practical patterns for wiring grid events into your application — listening across frameworks, reacting to scroll, and acting after a render flush.

The grid dispatches standard CustomEvents that bubble up the DOM. All events use bubbles: true and composed: true. Cancelable events (cell-commit, row-commit, column-move, row-move) honour preventDefault() to reject the action — see the Cancelable Events table.

import { queryGrid } from '@toolbox-web/grid';
const grid = queryGrid<Employee>('#my-grid');
// Type-safe event listening
grid.on('cell-click', ({ row, field }) => {
console.log(row, field);
});
// Cancelable events — call preventDefault() to reject the action
grid.on('cell-commit', (detail, e) => {
if (!isValid(detail.value)) {
e.preventDefault(); // Rejects the edit
}
});

tbw-scroll fires (rAF-batched) whenever the grid’s vertical viewport scrolls. The detail payload contains scrollTop, scrollHeight, clientHeight, and a direction: 'vertical' discriminator (reserved for future horizontal opt-in). The detail is a fresh object literal each tick — safe to retain, freeze, copy into framework state, or post to a worker.

grid.on('tbw-scroll', ({ scrollTop, scrollHeight, clientHeight }) => {
if (scrollTop + clientHeight >= scrollHeight - 200) {
loadNextPage();
}
});

A toolbar or progress bar that lives outside the grid and tracks scroll position:

grid.on('tbw-scroll', ({ scrollTop, scrollHeight, clientHeight }) => {
const max = scrollHeight - clientHeight;
const pct = max > 0 ? scrollTop / max : 0;
progressBarEl.style.width = `${pct * 100}%`;
});

For purely visual scroll-driven CSS effects on a different scroller, also consider the native animation-timeline: scroll() — no JS needed.

Defer heavy cell content (Angular @defer style)

Section titled “Defer heavy cell content (Angular @defer style)”

Render expensive content (charts, images, embedded video) only when its row is near the viewport:

grid.on('tbw-scroll', ({ scrollTop, clientHeight }) => {
// Lazy-mount components in the visible window
hydrateHeavyCellsBetween(scrollTop, scrollTop + clientHeight);
});

Tooltips, popovers, context menus rendered outside the grid should typically close when the user scrolls:

grid.on('tbw-scroll', () => closeOpenOverlays());

Framework adapter names — disambiguated to avoid colliding with native scroll events:

AdapterBinding
React<DataGrid onTbwScroll={...} />
Vue<TbwGrid @tbw-scroll="..." />
Angular<tbw-grid (tbwScroll)="..." />

The render event fires once at the end of every render cycle (the single RAF flush in the render scheduler), after all plugin afterRender hooks have run and after grid.ready() has resolved. Use this when you need to act on the rendered DOM immediately after a programmatic mutation — without resorting to setTimeout or double-requestAnimationFrame hacks.

The detail payload includes the highest render phase that ran (phase), whether this was the first render (initial), the post-processRows row count (rowCount), and the current virtual window (visibleRange, or null when virtualization is disabled).

Focus the first input after addRow in full-grid edit mode

Section titled “Focus the first input after addRow in full-grid edit mode”

The original motivation: with editing: { mode: 'grid' } every row is permanently in edit mode. After inserting a new row you want the first cell’s input focused — but the row doesn’t exist in the DOM until the next render.

function addEmployee() {
grid.addRow({ id: crypto.randomUUID(), name: '', email: '' });
grid.addEventListener(
'render',
() => {
const input = grid.querySelector<HTMLInputElement>(
'[data-row="0"][data-col="0"] input',
);
input?.focus();
},
{ once: true },
);
}

The event fires on virtualization-only re-renders too. If you only care about row/column model changes, gate on phase:

import { RenderPhase } from '@toolbox-web/grid';
grid.on('render', ({ phase, rowCount }) => {
if (phase < RenderPhase.ROWS) return; // ignore scroll/style-only flushes
statusBar.textContent = `${rowCount} rows rendered`;
});

Framework adapter names:

AdapterBinding
React<DataGrid onRender={...} />
Vue<TbwGrid @render="..." />
Angular<tbw-grid (render)="..." />
AI assistants: For complete API documentation, implementation guides, and code examples for this library, see https://toolboxjs.com/llms-full.txt