Server-side data
Use the dataProvider option so Handsontable loads row data from your backend instead of keeping the full dataset in the browser. The grid stays aligned with paging, column sorting, and (optionally) column filters that run on the server. The same configuration wires create, update, and remove to your API. When the dataProvider object is complete (all required keys valid), Handsontable ignores a static data array and loads rows only through fetchRows. If you still pass a data array with a complete provider, Handsontable logs a console warning that data is ignored.
Demo
The example below splits in-memory server logic (catalog, simulated latency, filters, CRUD, optional failed fetch) from grid settings (columns, emptyDataState, dialog, hooks). Row id values are used for rowId only, not shown as a column. Use Reload data after a simulated failure, or Simulate failed fetch to reject the next fetchRows — with dialog: true, the Dialog plugin shows Could not load data. afterDataProviderFetch and afterDataProviderFetchError update the same status line (for example #example1-status) in the bordered area above the buttons for both outcomes. Replace createInventoryDemoServer() with your API module in a real app.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const LATENCY_MS = 450;const SEED_CATALOG = [ { id: 1, product: 'Wireless ergonomic keyboard', sku: 'PER-KBD-901', category: 'Peripherals', unitPrice: 79.99, inStock: 210, }, { id: 2, product: 'USB-C 7-port hub', sku: 'PER-HUB-707', category: 'Peripherals', unitPrice: 45.5, inStock: 88, }, { id: 3, product: '27" QHD monitor', sku: 'DSP-MON-270', category: 'Displays', unitPrice: 329, inStock: 42, }, { id: 4, product: 'Laptop stand aluminum', sku: 'ACC-STD-112', category: 'Accessories', unitPrice: 39.99, inStock: 156, }, { id: 5, product: 'Noise-cancelling headset', sku: 'AUD-HDS-440', category: 'Audio', unitPrice: 199, inStock: 67, }, { id: 6, product: 'Mechanical keyboard MX Brown', sku: 'PER-KBD-303', category: 'Peripherals', unitPrice: 129, inStock: 94, }, { id: 7, product: 'Webcam 1080p autofocus', sku: 'VID-CAM-108', category: 'Video', unitPrice: 89, inStock: 203, }, { id: 8, product: 'Docking station Thunderbolt 4', sku: 'PER-DCK-401', category: 'Peripherals', unitPrice: 279, inStock: 31, }, { id: 9, product: 'Portable SSD 1TB', sku: 'STO-SSD-1T', category: 'Storage', unitPrice: 119.99, inStock: 178, }, { id: 10, product: 'Office chair mesh back', sku: 'FUR-CHR-882', category: 'Furniture', unitPrice: 349, inStock: 22, }, { id: 11, product: 'LED desk lamp dimmable', sku: 'LGT-LMP-210', category: 'Lighting', unitPrice: 54.25, inStock: 115, }, { id: 12, product: 'Bluetooth mouse silent', sku: 'PER-MSE-055', category: 'Peripherals', unitPrice: 32.99, inStock: 340, }, { id: 13, product: 'HDMI 2.1 cable 2m', sku: 'CBL-HDM-200', category: 'Cables', unitPrice: 18.99, inStock: 512, }, { id: 14, product: 'USB-C PD charger 65W', sku: 'PWR-PD65-W', category: 'Power', unitPrice: 49, inStock: 189, }, { id: 15, product: 'Mesh Wi-Fi router', sku: 'NET-RT-M1', category: 'Networking', unitPrice: 159, inStock: 58, }, { id: 16, product: 'External HDD 4TB', sku: 'STO-HDD-4T', category: 'Storage', unitPrice: 99.5, inStock: 76, }, { id: 17, product: 'Graphics tablet medium', sku: 'CRT-TAB-M2', category: 'Creative', unitPrice: 249, inStock: 41, }, { id: 18, product: 'Microphone USB condenser', sku: 'AUD-MIC-U1', category: 'Audio', unitPrice: 74.99, inStock: 99, }, { id: 19, product: 'Privacy screen 14"', sku: 'ACC-PRV-140', category: 'Accessories', unitPrice: 42, inStock: 133, }, { id: 20, product: 'Surge protector 8-outlet', sku: 'PWR-SRG-808', category: 'Power', unitPrice: 28.75, inStock: 267, }, { id: 21, product: 'FHD portable monitor 15.6"', sku: 'DSP-MON-156', category: 'Displays', unitPrice: 189, inStock: 48, }, { id: 22, product: 'Ethernet cable Cat6 5m', sku: 'CBL-ETH-500', category: 'Cables', unitPrice: 12.5, inStock: 620, }, { id: 23, product: 'Laptop sleeve 15"', sku: 'ACC-SLV-150', category: 'Accessories', unitPrice: 35, inStock: 201, }, { id: 24, product: 'Smart power strip', sku: 'PWR-STR-S1', category: 'Power', unitPrice: 59.99, inStock: 71, }, { id: 25, product: 'Ring light 12" with stand', sku: 'LGT-RNG-120', category: 'Lighting', unitPrice: 65, inStock: 84, }, { id: 26, product: 'NVMe enclosure USB4', sku: 'STO-ENC-NV', category: 'Storage', unitPrice: 79, inStock: 55, }, { id: 27, product: 'Vertical mouse', sku: 'PER-MSE-VER', category: 'Peripherals', unitPrice: 56.5, inStock: 112, }, { id: 28, product: 'Conference speakerphone', sku: 'AUD-SPK-CF', category: 'Audio', unitPrice: 189.99, inStock: 36, },];
function asLowerString(cell) { if (cell === null || cell === undefined) { return ''; }
return String(cell).toLowerCase();}
function matchesCondition(cell, cond) { const args = Array.isArray(cond.args) ? cond.args : []; const name = cond.name;
if (!name || name === 'none') { return true; }
switch (name) { case 'eq': return asLowerString(cell) === asLowerString(args[0]); case 'neq': return asLowerString(cell) !== asLowerString(args[0]); case 'contains': return asLowerString(cell).includes(asLowerString(args[0])); case 'not_contains': return !asLowerString(cell).includes(asLowerString(args[0])); case 'begins_with': return asLowerString(cell).startsWith(asLowerString(args[0])); case 'ends_with': return asLowerString(cell).endsWith(asLowerString(args[0])); case 'gt': return Number(cell) > Number(args[0]); case 'gte': return Number(cell) >= Number(args[0]); case 'lt': return Number(cell) < Number(args[0]); case 'lte': return Number(cell) <= Number(args[0]); case 'between': return Number(cell) >= Number(args[0]) && Number(cell) <= Number(args[1]); case 'not_between': return Number(cell) < Number(args[0]) || Number(cell) > Number(args[1]); case 'empty': return cell === null || cell === undefined || String(cell) === ''; case 'not_empty': return cell !== null && cell !== undefined && String(cell) !== ''; default: return true; }}
function rowMatchesFilterColumn(row, colFilter) { const value = row[colFilter.prop]; const conditions = colFilter.conditions ?? []; const op = colFilter.operation ?? 'conjunction';
if (conditions.length === 0) { return true; }
const parts = conditions.map((c) => () => matchesCondition(value, c));
if (op === 'disjunction') { return parts.some((fn) => fn()); }
if (op === 'disjunctionWithExtraCondition' && parts.length >= 3) { return parts.slice(0, -1).some((fn) => fn()) && parts[parts.length - 1](); }
return parts.every((fn) => fn());}
function applyQueryFilters(rows, filters) { if (!filters || filters.length === 0) { return rows; }
return rows.filter((row) => filters.every((f) => rowMatchesFilterColumn(row, f)));}
function delay(ms, signal) { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new DOMException('Aborted', 'AbortError'));
return; }
const timeoutId = setTimeout(resolve, ms);
signal.addEventListener( 'abort', () => { clearTimeout(timeoutId); reject(new DOMException('Aborted', 'AbortError')); }, { once: true } ); });}
function createInventoryDemoServer() { const store = { nextId: SEED_CATALOG.reduce((max, r) => Math.max(max, r.id), 0), rows: SEED_CATALOG.map((r) => ({ ...r })), };
let failNextFetch = false;
return { clearFailNextFetch() { failNextFetch = false; }, setFailNextFetch() { failNextFetch = true; }, fetchRows(queryParameters, { signal }) { const { page, pageSize, sort, filters } = queryParameters; let rows = [...store.rows];
if (sort) { rows.sort((a, b) => { const av = a[sort.prop]; const bv = b[sort.prop]; let cmp = 0;
if (av < bv) { cmp = -1; } else if (av > bv) { cmp = 1; }
return sort.order === 'asc' ? cmp : -cmp; }); }
rows = applyQueryFilters(rows, filters);
const start = (page - 1) * pageSize; const pageRows = rows.slice(start, start + pageSize);
return delay(LATENCY_MS, signal).then(() => { if (signal.aborted) { return Promise.reject(new DOMException('Aborted', 'AbortError')); }
if (failNextFetch) { failNextFetch = false;
return Promise.reject(new Error('Simulated server error (for example HTTP 503).')); }
return { rows: pageRows, totalRows: rows.length, }; }); }, async onRowsCreate({ rowsAmount, position, referenceRowId }) { const newRows = [];
for (let i = 0; i < rowsAmount; i += 1) { store.nextId += 1; newRows.push({ id: store.nextId, product: 'New product', sku: `NEW-${store.nextId}`, category: 'Uncategorized', unitPrice: 0, inStock: 0, }); }
let insertAt = store.rows.length;
if (referenceRowId !== undefined && referenceRowId !== null) { const refIdx = store.rows.findIndex((r) => r.id === referenceRowId);
if (refIdx >= 0) { insertAt = position === 'above' ? refIdx : refIdx + 1; } }
store.rows.splice(insertAt, 0, ...newRows); }, async onRowsUpdate(rows) { rows.forEach(({ id, changes }) => { const row = store.rows.find((r) => r.id === id);
if (row) { Object.assign(row, changes); } }); }, async onRowsRemove(rowIds) { const idSet = new Set(rowIds);
store.rows = store.rows.filter((r) => !idSet.has(r.id)); }, };}
// =============================================================================// Grid — Handsontable options, hooks, and demo toolbar.// =============================================================================const server = createInventoryDemoServer();const statusEl = document.querySelector('#example1-status');const container = document.querySelector('#example1');const hot = new Handsontable(container, { dataProvider: { rowId: 'id', fetchRows: server.fetchRows, onRowsCreate: server.onRowsCreate, onRowsUpdate: server.onRowsUpdate, onRowsRemove: server.onRowsRemove, }, columns: [ { data: 'product' }, { data: 'sku', readOnly: true }, { data: 'category' }, { data: 'unitPrice', type: 'numeric', numericFormat: { pattern: '$0,0.00' }, }, { data: 'inStock', type: 'numeric' }, ], rowHeaders: true, colHeaders: ['Product', 'SKU', 'Category', 'Unit price', 'In stock'], width: '100%', height: '300px', licenseKey: 'non-commercial-and-evaluation', columnSorting: true, pagination: true, dropdownMenu: true, filters: true, contextMenu: true, emptyDataState: true, dialog: { animation: false, }, beforeDataProviderFetch: (params) => { if (statusEl) { statusEl.textContent = params.skipLoading ? 'Updating after sort or edit…' : 'Loading data…'; } }, afterDataProviderFetch: () => { if (statusEl) { statusEl.textContent = `Ready (simulated ${LATENCY_MS}ms request).`; } }, afterDataProviderFetchError: (error) => { if (statusEl) { statusEl.textContent = `Could not load data: ${error.message}`; } },});
function runFetch(clearFail) { if (clearFail) { server.clearFailNextFetch(); } else { server.setFailNextFetch(); }
const p = hot.getPlugin('dataProvider').fetchData();
void p.catch(() => {});}
document.querySelector('#example1-reload')?.addEventListener('click', () => runFetch(true));document.querySelector('#example1-fail-fetch')?.addEventListener('click', () => runFetch(false));import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import type { DataProviderQueryParameters, DataProviderFetchOptions, DataProviderBeforeFetchParameters, DataProviderFilterColumn, RowUpdatePayload,} from 'handsontable/plugins/dataProvider';
registerAllModules();
// =============================================================================// In-memory "server" — replace with REST/GraphQL clients in production.// =============================================================================
type DemoRow = { id: number; product: string; sku: string; category: string; unitPrice: number; inStock: number;};
type FilterCondition = { name?: string; args?: unknown[] };
const LATENCY_MS = 450;
const SEED_CATALOG: readonly DemoRow[] = [ { id: 1, product: 'Wireless ergonomic keyboard', sku: 'PER-KBD-901', category: 'Peripherals', unitPrice: 79.99, inStock: 210, }, { id: 2, product: 'USB-C 7-port hub', sku: 'PER-HUB-707', category: 'Peripherals', unitPrice: 45.5, inStock: 88, }, { id: 3, product: '27" QHD monitor', sku: 'DSP-MON-270', category: 'Displays', unitPrice: 329, inStock: 42, }, { id: 4, product: 'Laptop stand aluminum', sku: 'ACC-STD-112', category: 'Accessories', unitPrice: 39.99, inStock: 156, }, { id: 5, product: 'Noise-cancelling headset', sku: 'AUD-HDS-440', category: 'Audio', unitPrice: 199, inStock: 67, }, { id: 6, product: 'Mechanical keyboard MX Brown', sku: 'PER-KBD-303', category: 'Peripherals', unitPrice: 129, inStock: 94, }, { id: 7, product: 'Webcam 1080p autofocus', sku: 'VID-CAM-108', category: 'Video', unitPrice: 89, inStock: 203, }, { id: 8, product: 'Docking station Thunderbolt 4', sku: 'PER-DCK-401', category: 'Peripherals', unitPrice: 279, inStock: 31, }, { id: 9, product: 'Portable SSD 1TB', sku: 'STO-SSD-1T', category: 'Storage', unitPrice: 119.99, inStock: 178, }, { id: 10, product: 'Office chair mesh back', sku: 'FUR-CHR-882', category: 'Furniture', unitPrice: 349, inStock: 22, }, { id: 11, product: 'LED desk lamp dimmable', sku: 'LGT-LMP-210', category: 'Lighting', unitPrice: 54.25, inStock: 115, }, { id: 12, product: 'Bluetooth mouse silent', sku: 'PER-MSE-055', category: 'Peripherals', unitPrice: 32.99, inStock: 340, }, { id: 13, product: 'HDMI 2.1 cable 2m', sku: 'CBL-HDM-200', category: 'Cables', unitPrice: 18.99, inStock: 512, }, { id: 14, product: 'USB-C PD charger 65W', sku: 'PWR-PD65-W', category: 'Power', unitPrice: 49, inStock: 189, }, { id: 15, product: 'Mesh Wi-Fi router', sku: 'NET-RT-M1', category: 'Networking', unitPrice: 159, inStock: 58, }, { id: 16, product: 'External HDD 4TB', sku: 'STO-HDD-4T', category: 'Storage', unitPrice: 99.5, inStock: 76, }, { id: 17, product: 'Graphics tablet medium', sku: 'CRT-TAB-M2', category: 'Creative', unitPrice: 249, inStock: 41, }, { id: 18, product: 'Microphone USB condenser', sku: 'AUD-MIC-U1', category: 'Audio', unitPrice: 74.99, inStock: 99, }, { id: 19, product: 'Privacy screen 14"', sku: 'ACC-PRV-140', category: 'Accessories', unitPrice: 42, inStock: 133, }, { id: 20, product: 'Surge protector 8-outlet', sku: 'PWR-SRG-808', category: 'Power', unitPrice: 28.75, inStock: 267, }, { id: 21, product: 'FHD portable monitor 15.6"', sku: 'DSP-MON-156', category: 'Displays', unitPrice: 189, inStock: 48, }, { id: 22, product: 'Ethernet cable Cat6 5m', sku: 'CBL-ETH-500', category: 'Cables', unitPrice: 12.5, inStock: 620, }, { id: 23, product: 'Laptop sleeve 15"', sku: 'ACC-SLV-150', category: 'Accessories', unitPrice: 35, inStock: 201, }, { id: 24, product: 'Smart power strip', sku: 'PWR-STR-S1', category: 'Power', unitPrice: 59.99, inStock: 71, }, { id: 25, product: 'Ring light 12" with stand', sku: 'LGT-RNG-120', category: 'Lighting', unitPrice: 65, inStock: 84, }, { id: 26, product: 'NVMe enclosure USB4', sku: 'STO-ENC-NV', category: 'Storage', unitPrice: 79, inStock: 55, }, { id: 27, product: 'Vertical mouse', sku: 'PER-MSE-VER', category: 'Peripherals', unitPrice: 56.5, inStock: 112, }, { id: 28, product: 'Conference speakerphone', sku: 'AUD-SPK-CF', category: 'Audio', unitPrice: 189.99, inStock: 36, },];
function asLowerString(cell: unknown): string { if (cell === null || cell === undefined) { return ''; }
return String(cell).toLowerCase();}
function matchesCondition(cell: unknown, cond: FilterCondition): boolean { const args = Array.isArray(cond.args) ? cond.args : []; const name = cond.name;
if (!name || name === 'none') { return true; }
switch (name) { case 'eq': return asLowerString(cell) === asLowerString(args[0]); case 'neq': return asLowerString(cell) !== asLowerString(args[0]); case 'contains': return asLowerString(cell).includes(asLowerString(args[0])); case 'not_contains': return !asLowerString(cell).includes(asLowerString(args[0])); case 'begins_with': return asLowerString(cell).startsWith(asLowerString(args[0])); case 'ends_with': return asLowerString(cell).endsWith(asLowerString(args[0])); case 'gt': return Number(cell) > Number(args[0]); case 'gte': return Number(cell) >= Number(args[0]); case 'lt': return Number(cell) < Number(args[0]); case 'lte': return Number(cell) <= Number(args[0]); case 'between': return Number(cell) >= Number(args[0]) && Number(cell) <= Number(args[1]); case 'not_between': return Number(cell) < Number(args[0]) || Number(cell) > Number(args[1]); case 'empty': return cell === null || cell === undefined || String(cell) === ''; case 'not_empty': return cell !== null && cell !== undefined && String(cell) !== ''; default: return true; }}
function rowMatchesFilterColumn( row: DemoRow, colFilter: DataProviderFilterColumn): boolean { const value = row[colFilter.prop as keyof DemoRow]; const conditions = colFilter.conditions ?? []; const op = colFilter.operation ?? 'conjunction';
if (conditions.length === 0) { return true; }
const parts = conditions.map((c) => () => matchesCondition(value, c));
if (op === 'disjunction') { return parts.some((fn) => fn()); }
if (op === 'disjunctionWithExtraCondition' && parts.length >= 3) { return parts.slice(0, -1).some((fn) => fn()) && parts[parts.length - 1](); }
return parts.every((fn) => fn());}
function applyQueryFilters( rows: DemoRow[], filters: DataProviderFilterColumn[] | null): DemoRow[] { if (!filters || filters.length === 0) { return rows; }
return rows.filter((row) => filters.every((f) => rowMatchesFilterColumn(row, f)));}
function delay(ms: number, signal: AbortSignal): Promise<void> { return new Promise((resolve, reject) => { if (signal.aborted) { reject(new DOMException('Aborted', 'AbortError'));
return; }
const timeoutId = setTimeout(resolve, ms);
signal.addEventListener( 'abort', () => { clearTimeout(timeoutId); reject(new DOMException('Aborted', 'AbortError')); }, { once: true } ); });}
type InventoryDemoServer = { clearFailNextFetch: () => void; setFailNextFetch: () => void; fetchRows: ( queryParameters: DataProviderQueryParameters, options: DataProviderFetchOptions ) => Promise<{ rows: DemoRow[]; totalRows: number }>; onRowsCreate: (payload: { rowsAmount: number; position: 'above' | 'below'; referenceRowId?: unknown; }) => Promise<void>; onRowsUpdate: (rows: RowUpdatePayload[]) => Promise<void>; onRowsRemove: (rowIds: unknown[]) => Promise<void>;};
function createInventoryDemoServer(): InventoryDemoServer { const store = { nextId: SEED_CATALOG.reduce((max, r) => Math.max(max, r.id), 0), rows: SEED_CATALOG.map((r) => ({ ...r })), };
let failNextFetch = false;
return { clearFailNextFetch() { failNextFetch = false; }, setFailNextFetch() { failNextFetch = true; }, fetchRows(queryParameters, { signal }) { const { page, pageSize, sort, filters } = queryParameters; let rows = [...store.rows];
if (sort) { rows.sort((a, b) => { const av = a[sort.prop as keyof DemoRow]; const bv = b[sort.prop as keyof DemoRow]; let cmp = 0;
if (av < bv) { cmp = -1; } else if (av > bv) { cmp = 1; }
return sort.order === 'asc' ? cmp : -cmp; }); }
rows = applyQueryFilters(rows, filters);
const start = (page - 1) * pageSize; const pageRows = rows.slice(start, start + pageSize);
return delay(LATENCY_MS, signal).then(() => { if (signal.aborted) { return Promise.reject(new DOMException('Aborted', 'AbortError')); }
if (failNextFetch) { failNextFetch = false;
return Promise.reject(new Error('Simulated server error (for example HTTP 503).')); }
return { rows: pageRows, totalRows: rows.length, }; }); }, async onRowsCreate({ rowsAmount, position, referenceRowId }) { const newRows: DemoRow[] = [];
for (let i = 0; i < rowsAmount; i += 1) { store.nextId += 1; newRows.push({ id: store.nextId, product: 'New product', sku: `NEW-${store.nextId}`, category: 'Uncategorized', unitPrice: 0, inStock: 0, }); }
let insertAt = store.rows.length;
if (referenceRowId !== undefined && referenceRowId !== null) { const refIdx = store.rows.findIndex((r) => r.id === referenceRowId);
if (refIdx >= 0) { insertAt = position === 'above' ? refIdx : refIdx + 1; } }
store.rows.splice(insertAt, 0, ...newRows); }, async onRowsUpdate(rows) { rows.forEach(({ id, changes }) => { const row = store.rows.find((r) => r.id === id);
if (row) { Object.assign(row, changes); } }); }, async onRowsRemove(rowIds) { const idSet = new Set(rowIds);
store.rows = store.rows.filter((r) => !idSet.has(r.id)); }, };}
// =============================================================================// Grid — Handsontable options, hooks, and demo toolbar.// =============================================================================
const server = createInventoryDemoServer();const statusEl = document.querySelector('#example1-status');const container = document.querySelector('#example1') as HTMLElement;
const hot = new Handsontable(container, { dataProvider: { rowId: 'id', fetchRows: server.fetchRows, onRowsCreate: server.onRowsCreate, onRowsUpdate: server.onRowsUpdate, onRowsRemove: server.onRowsRemove, }, columns: [ { data: 'product' }, { data: 'sku', readOnly: true }, { data: 'category' }, { data: 'unitPrice', type: 'numeric', numericFormat: { pattern: '$0,0.00' }, }, { data: 'inStock', type: 'numeric' }, ], rowHeaders: true, colHeaders: ['Product', 'SKU', 'Category', 'Unit price', 'In stock'], width: '100%', height: '300px', licenseKey: 'non-commercial-and-evaluation', columnSorting: true, pagination: true, dropdownMenu: true, filters: true, contextMenu: true, emptyDataState: true, dialog: { animation: false, }, beforeDataProviderFetch: (params: DataProviderBeforeFetchParameters) => { if (statusEl) { statusEl.textContent = params.skipLoading ? 'Updating after sort or edit…' : 'Loading data…'; } }, afterDataProviderFetch: () => { if (statusEl) { statusEl.textContent = `Ready (simulated ${LATENCY_MS}ms request).`; } }, afterDataProviderFetchError: (error: Error) => { if (statusEl) { statusEl.textContent = `Could not load data: ${error.message}`; } },});
function runFetch(clearFail: boolean) { if (clearFail) { server.clearFailNextFetch(); } else { server.setFailNextFetch(); }
const p = hot.getPlugin('dataProvider').fetchData();
void p.catch(() => {});}
document.querySelector('#example1-reload')?.addEventListener('click', () => runFetch(true));document.querySelector('#example1-fail-fetch')?.addEventListener('click', () => runFetch(false));<div class="example-controls-container"> <div class="controls"> <button type="button" id="example1-reload">Reload data</button> <button type="button" id="example1-fail-fetch">Simulate failed fetch</button> </div> <output id="example1-status">Initializing</output></div><div id="example1"></div>What the DataProvider plugin does
When you pass a complete dataProvider configuration, Handsontable:
- Calls your
fetchRowsfunction with query parameters (page,pageSize,sort,filters) and anAbortSignal. - Expects
fetchRowsto resolve with{ rows, totalRows }. Rows must be objects (or arrays) compatible with yourcolumnsdatakeys. Ifrowsis missing or not an array, Handsontable treats it as[]. IftotalRowsis missing or not a non-negative number, it falls back torows.length. After a successful response, iftotalRowsimplies fewer pages than the requestedpage, Handsontable automatically refetches the last valid page (for example after deletes on the last page). - Uses
rowIdso updates, removes, and server operations can target stable row identity. - Sends create, update, and remove work through
onRowsCreate,onRowsUpdate, andonRowsRemove(see Create, update, and remove). - Registers a default
hasExternalDataSourcehandler that returnstruewhen the configuration is complete, so the Pagination and Filters plugins can run in server-driven mode together withcolumnSorting(single-column sort only; see Sorting and filtering).
You must supply all of rowId, fetchRows, onRowsCreate, onRowsUpdate, and onRowsRemove. If any callback is missing or invalid, the plugin stays enabled but the affected operations no-op until the configuration is valid. A truthy non-object value (for example dataProvider: true) enables the plugin instance but is never a complete configuration. If a key fails validation (for example rowId is neither a string nor a function), Handsontable emits a console warning like other plugin settings and keeps the previous stored value for that key.
Migrate from client-side data
Use this checklist when moving from a full in-memory data array and hooks such as afterChange to dataProvider:
- Stable row ids — Set
rowIdto a property name (for example'id') or a function. Every row from the server must include that field. fetchRows— Replace ad hocloadDatacalls withfetchRows(queryParameters, { signal }). Readpage,pageSize,sort, andfiltersfromqueryParameters, call your API, return{ rows, totalRows }, and honorsignalfor cancellation. Drop or stop updating the olddataarray; with a complete provider, it is not used.- CRUD callbacks — Implement
onRowsCreate,onRowsUpdate, andonRowsRemovefor your backend. Until all three are valid functions (withrowIdandfetchRows), the configuration is incomplete. - Pagination and sort — Set
paginationtotrueor pass an object (for example{ pageSize: 10 }sofetchRowsreceives thatpageSizeinqueryParameters). UsecolumnSortingfor single-column server sort. KeepmultiColumnSorting,trimRows,manualRowMove, andmanualColumnMoveoff if you need DataProvider to run; when any of them is truthy, the DataProvider plugin does not enable (see Conflicting options). - Save hooks — If you only used
afterChangeto POST each edit, remove that and rely ononRowsUpdatebatches instead. Keep unrelated hooks (validation UI, analytics, etc.). - Filters (optional) — Enable
filtersand translatequeryParameters.filterson the server. See Column filter. - Frameworks — Pass
dataProviderinside grid settings (HotTable,hot-table, or Vue:settings). AfterupdateSettingsin React, preserve selection withselection.exportSelection()andselection.importSelection()on the Handsontable instance where your wrapper documents it.
Required configuration sketch
dataProvider: { rowId: 'id', // or (row) => row.id fetchRows: async (queryParameters, { signal }) => { // queryParameters: { page, pageSize, sort, filters } const res = await fetch(buildUrl(queryParameters), { signal }); const json = await res.json();
return { rows: json.data, totalRows: json.total }; }, onRowsCreate: async (payload) => { /* POST */ }, onRowsUpdate: async (rows) => { /* PATCH */ }, onRowsRemove: async (rowIds) => { /* DELETE */ },},pagination: { pageSize: 10 }, // or `true`; `pageSize` is read from Pagination into `fetchRows` queryParameters (not a dataProvider key)columnSorting: true, // server-side sort (one column)filters: true, // server-side column filters; read queryParameters.filters in fetchRowsemptyDataState: true, // loading spinner and empty overlay while fetching / when no rowsdialog: true, // built-in error modal when fetchRows or CRUD callbacks reject (including refetch after mutation)Use pagination so users can change pages and page size in the UI. With DataProvider, the Pagination plugin copies its pageSize and current page into every fetchRows call as queryParameters.pageSize and queryParameters.page (including initialPage when that is how the UI selects the first page). There is no pageSize field on the dataProvider object; set rows per page with pagination: { pageSize: 10 }, or pagination: true for the plugin’s default page size. With rowHeaders enabled, the DataProvider plugin uses modifyRowHeader so row headers reflect a global 1-based row index across pages (for example row 0 on page 2 with pageSize: 5 shows header 6), not only the index within the loaded slice. See the Rows pagination guide for the full configuration object.
The sketch also sets filters for server-driven column filters, emptyDataState for loading and empty overlays with asynchronous fetches, and dialog so request failures from the DataProvider can show an error message in the Dialog plugin (see Fetch hooks and loading UI). Omit or adjust any of these if you do not need that feature.
Query parameters
fetchRows receives:
| Field | Type | Description |
|---|---|---|
page | number | 1-based page index. |
pageSize | number | Rows per page: same value as the Pagination plugin’s pageSize setting when Pagination is enabled. |
sort | object or null | Object with prop (column data key) and order ('asc' or 'desc'). null when unsorted. |
filters | array or null | Server-side filter payload when the Filters plugin runs in server mode (same condition shapes as the Filters API, with prop instead of a column index). |
page and pageSize come from the Pagination plugin when it is enabled, including pagination: { pageSize: n } so your API receives n rows per request. If you omit pagination or leave Pagination off, Handsontable still calls fetchRows with a fixed page 1 and the library default page size (10) until you enable pagination or drive pages yourself via fetchData (you can pass { pageSize: n } there as an override).
Respect signal so outdated requests abort when the user sorts, filters, or changes pages quickly.
Create, update, and remove
With a complete dataProvider configuration, Handsontable sends create, update, and remove operations to your backend through three callbacks. Valid edits appear in the grid immediately; if the server rejects an update (or the mutation promise rejects), or if beforeRowsMutation returns false, affected cells roll back. Cell and column validators run before onRowsUpdate; if any cell in the batch fails, Handsontable does not call onRowsUpdate, fires afterRowsMutationError with a validation failure, and reverts the edit. If rowId resolves to null or undefined for a row, Handsontable cannot send an update or remove for that row (edits revert; remove from the UI throws). Programmatic updateRows and removeRows throw if an id is missing. Row insert from the context menu is skipped when the table already has as many rows as maxRows.
onRowsCreate
Called when the user inserts rows (for example from the context menu). Payload shape:
position:'above'or'below'.referenceRowId: anchor row id when inserting next to a row (fromrowId); may beundefinedwhen there is no anchor (for example some programmatic inserts).rowsAmount: how many rows to create in one request.
Your API should create the rows and return a promise. Handsontable refetches the current query after success.
Create, update, and remove requests are serialized: if the user triggers another mutation before the previous one finishes, work runs in order so your backend sees a single stream of operations.
onRowsUpdate
Called with an array of { id, changes, rowData }:
id— stable row id (same asrowId).changes— map of property names to new cell values.rowData— optional full row snapshot; Handsontable fills it when applying edits from the grid.
One batch usually corresponds to one user action (typing a cell, paste, autofill, clear column, and similar). Implement your PATCH or PUT logic here, then rely on the refetch that follows a successful mutation.
onRowsRemove
Called with an array of row ids to delete. After success, Handsontable refetches and may move to the previous page if the current page becomes empty.
Programmatic CRUD
From the plugin instance (hot.getPlugin('dataProvider')), you can also call createRows, updateRows, and removeRows with the same shapes as the callbacks above.
Mutation hooks
beforeRowsMutation—(operation, payload); returnfalseto cancel. For create and remove, the server callback is not invoked and there is no refetch. For update from the grid,falsereverts optimistic cell values and skipsonRowsUpdate; cell validators run only when the hook allows the mutation to continue.afterRowsMutation— runs after the server mutation callback succeeds and before the post-mutation refetch.afterRowsMutationError— runs when the mutation callback throws or rejects, when validation fails before the request, or when the refetch after a successful update fails.
operation is 'create', 'update', or 'remove'. The hook payload is a wrapper object, not the same reference as the callback argument: 'create' uses { rowsCreate } (same inner shape as onRowsCreate), 'update' uses { rows } (the array passed to onRowsUpdate), and 'remove' uses { rowsRemove } (the id array passed to onRowsRemove).
Undo stack
When onRowsUpdate is set, Handsontable skips stacking certain edit sources on the local undo stack so client undo does not fight server-backed data (including edit, paste, cut, autofill, Clear column from the context menu, and revert after a failed onRowsUpdate). See Undo/Redo for the general model.
Sorting and filtering
- Use
columnSortingfor server-backed sorting. IfmultiColumnSortingis enabled, the DataProvider plugin does not run (see Conflicting options); keep it off for server-driven data. - Enable
filtersand export conditions from the UI; whenfetchRowsis configured, Handsontable maps those conditions intoqueryParameters.filters, resets to page 1, and refetches (Filters skip client-side row trimming). If Filters are on butfetchRowsis missing (incompletedataProviderobject), filtering stays client-side on whatever rows are currently loaded. - With a complete
dataProviderconfiguration, the column menu does not offer Filter by value (only the current page is loaded, so value lists would be incomplete). Use condition-based filters (for example text, numeric, or date conditions) for server-side filtering. ProgrammaticaddCondition(..., 'by_value', ...)is ignored when server-backedfetchRowsis active.
Plugins and options that conflict with DataProvider
trimRows, manualRowMove, manualColumnMove, and multiColumnSorting each block the DataProvider plugin when the option is truthy. Those row and column features can still enable; DataProvider does not. Handsontable logs a console warning (one message for the first incompatible option the runtime reports). On the initial table load, if a conflict applies, your dataProvider setting may be cleared to false so the plugin stays off. If a conflict appears later through updateSettings, the dataProvider object is usually left in settings; the plugin remains disabled until you turn off the incompatible option. Remove or disable the conflicting options if you need server-backed fetchRows and CRUD.
Fetch hooks and loading UI
beforeDataProviderFetch— returnfalseto cancel a fetch. The argument merges query fields with optionalskipLoading(set on internal refetches after sort or CRUD). That flag is not passed tofetchRows.afterDataProviderFetch— result includesrows,totalRows,queryParameters,columnSortConfig, andfiltersConditionsStack(the latter two mirror ColumnSorting and Filters state for the query that just ran).afterDataProviderFetchError—(error, queryParameters)whenfetchRowsthrows or rejects with a non-abort error.afterDataProviderFetchAbort—(queryParameters, reason)when a fetch is superseded, aborted, or ends withAbortError.
When dialog is enabled, the Dialog plugin opens a modal if fetchRows rejects or if onRowsCreate, onRowsUpdate, or onRowsRemove rejects, including when a refetch after a successful mutation fails. The title is translated per operation (load vs create vs update vs remove). The description text prefers a string message, error, or detail from a JSON body, including when that body is nested on the error object (error.response?.data, error.data, or error.body, as with some HTTP clients). Otherwise it falls back to an Error message, a string rejection, or a generic fallback. If Dialog is disabled, use afterDataProviderFetchError for failed loads and refetches, and afterRowsMutationError for rejected mutation callbacks; you supply your own error UI.
The Empty data state / loading overlay follows DataProvider for the "loading" branch: beforeDataProviderFetch turns loading on when skipLoading is not set; afterDataProviderFetch and afterDataProviderFetchError turn it off. afterDataProviderFetchAbort does not clear loading by itself (for example when the user changes page while a request is in flight), so the overlay stays until a fetch finishes successfully or with an error. Refetches after column sort or CRUD pass skipLoading: true into beforeDataProviderFetch, so the Empty data state plugin skips the full loading overlay for those internal loads.
DataProvider plugin methods
From hot.getPlugin('dataProvider'):
fetchData— refetch with optional overrides (page,pageSize,sort,filters, and client-onlyskipLoading). Overrides are merged into the current query;pageis clamped to at least 1, and Handsontable may issue a follow-up fetch iftotalRowsfrom the server implies a lower last page than requested. After init, changing thedataProviderobject throughupdateSettingsruns the plugin’s update path and triggers a refetch when the grid is already rendered.updateSettingsand loaded rows — WhenhasExternalDataSourceistrue, Handsontable only resets the in-memory placeholder to an empty array during init or when theupdateSettingspayload includesdataordataProvider. Other keys alone (for exampleheightorcolHeaders) do not clear the current page of rows or force a refetch; the DataProvider plugin refetches when its settings change. If you updatecolumnswithoutdataordataProvider, the data map is rebuilt but the same rule avoids wiping the grid with an empty dataset by accident.getQueryParameters— currentpage,pageSize,sort,filters.getRowId— resolve the id for a visual row.createRows,updateRows,removeRows— programmatic CRUD through the same server callbacks.
Examples (REST and GraphQL)
These end-to-end patterns pair a browser grid with a small backend:
- REST (warehouse stock) — SKU / bin / quantity columns with an Express-style JSON API (
GET/PATCH/POST/DELETEon/api/stock-lines). Reference server:server-rest.mjs(the same file ships in everydata-providerexample folder). - GraphQL (support queue) — Paged open tickets, sort mapping, and mutations aligned to a helpdesk-style schema. Reference server:
server-graphql.mjs(same layout in each example folder).
The JavaScript, React, Vue 3, and Angular examples under examples/next/docs/ each run both backends on one page (default ports 4010 and 4011). See CodeSandbox examples below.
In TypeScript projects, you can import DataProviderQueryParameters, DataProviderFetchOptions, and related interfaces from handsontable/plugins/dataProvider (same pattern as the framework examples that share dataProviderClients with the REST and GraphQL clients).
When you enable filters, add the parameters your backend needs and serialize queryParameters.filters the way your storage layer expects. Handle errors with afterDataProviderFetchError and afterRowsMutationError.
CodeSandbox examples
Runnable projects under the Handsontable examples monorepo (Devbox):
End-to-end backends (Node servers + UI)
Each folder includes the same Express servers (server-rest.mjs, server-graphql.mjs, start-servers.mjs). Run npm run server, then npm run start for the framework dev server.
- JavaScript (Vite) — override URLs with
VITE_API_BASEandVITE_GRAPHQL_URL. - React (CRA) — override with
REACT_APP_API_BASEandREACT_APP_GRAPHQL_URL. - Vue 3 (Vite) — override with
VITE_API_BASEandVITE_GRAPHQL_URL. - Angular — set
restApiBaseandgraphqlUrlinsrc/environments/environment.ts.
Related guides
Related API reference
Options
Plugins
Hooks