Load data from a REST API
This tutorial shows how to fetch JSON from a REST API and populate Handsontable after initialization. It starts the grid with data: [], shows a loading message, then displays success or error feedback in the UI.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const STATUS_LOADING = 'Loading users...';const STATUS_READY = 'Loaded users from REST API.';const STATUS_ERROR = 'Failed to load users. Try again.';
const container = document.querySelector('#example1');
if (!container) { throw new Error('Missing #example1 element.');}
const status = document.createElement('p');const retryButton = document.createElement('button');
status.textContent = STATUS_LOADING;status.style.margin = '0';status.style.fontFamily = 'Arial, sans-serif';status.style.fontSize = '14px';
retryButton.type = 'button';retryButton.textContent = 'Retry';retryButton.hidden = true;retryButton.style.marginBottom = '0';
const statusBar = document.createElement('div');const gridContainer = document.createElement('div');
statusBar.style.display = 'flex';statusBar.style.gap = '12px';statusBar.style.alignItems = 'center';statusBar.style.marginBottom = '8px';
container.appendChild(statusBar);statusBar.appendChild(status);statusBar.appendChild(retryButton);container.appendChild(gridContainer);
const hot = new Handsontable(gridContainer, { data: [], colHeaders: ['ID', 'Name', 'Username', 'Email', 'City', 'Company'], columns: [ { data: 'id', type: 'numeric', width: 70 }, { data: 'name', type: 'text', width: 190 }, { data: 'username', type: 'text', width: 150 }, { data: 'email', type: 'text', width: 220 }, { data: 'city', type: 'text', width: 140 }, { data: 'company', type: 'text', width: 180 }, ], rowHeaders: true, height: 360, width: '100%', stretchH: 'all', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation',});
function setUiState({ loading = false, hasError = false, message = '' } = {}) { status.textContent = message; status.style.color = hasError ? 'var(--ht-cell-error-foreground-color, #c62828)' : 'var(--ht-foreground-color, #202124)'; retryButton.hidden = !hasError; retryButton.disabled = loading;}
function mapUsersToGridRows(users) { return users.map((user) => ({ id: user.id, name: user.name, username: user.username, email: user.email, city: user.address?.city ?? '', company: user.company?.name ?? '', }));}
async function loadUsers() { setUiState({ loading: true, message: STATUS_LOADING });
try { const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) { throw new Error(`Request failed with status: ${response.status}`); }
const users = await response.json();
hot.loadData(mapUsersToGridRows(users)); setUiState({ message: STATUS_READY }); } catch (_error) { hot.loadData([]); setUiState({ hasError: true, message: STATUS_ERROR }); }}
retryButton.addEventListener('click', () => { loadUsers();});
loadUsers();import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
type ApiUser = { id: number; name: string; username: string; email: string; address?: { city?: string }; company?: { name?: string };};
const STATUS_LOADING = 'Loading users...';const STATUS_READY = 'Loaded users from REST API.';const STATUS_ERROR = 'Failed to load users. Try again.';
const rootContainer = document.querySelector('#example1') as HTMLDivElement;
const statusBar = document.createElement('div');const statusLabel = document.createElement('p');const retryButton = document.createElement('button');const gridContainer = document.createElement('div');
statusBar.style.display = 'flex';statusBar.style.gap = '12px';statusBar.style.alignItems = 'center';statusBar.style.marginBottom = '8px';
statusLabel.style.margin = '0';statusLabel.style.fontFamily = 'Arial, sans-serif';statusLabel.style.fontSize = '14px';
retryButton.type = 'button';retryButton.textContent = 'Retry';retryButton.hidden = true;
rootContainer.appendChild(statusBar);statusBar.appendChild(statusLabel);statusBar.appendChild(retryButton);rootContainer.appendChild(gridContainer);
const hot = new Handsontable(gridContainer, { data: [], colHeaders: ['ID', 'Name', 'Username', 'Email', 'City', 'Company'], columns: [ { data: 'id', type: 'numeric', width: 70 }, { data: 'name', type: 'text', width: 190 }, { data: 'username', type: 'text', width: 150 }, { data: 'email', type: 'text', width: 220 }, { data: 'city', type: 'text', width: 140 }, { data: 'company', type: 'text', width: 180 }, ], rowHeaders: true, height: 360, width: '100%', stretchH: 'all', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation',});
function setUiState({ loading = false, hasError = false, message = '',}: { loading?: boolean; hasError?: boolean; message?: string;}) { statusLabel.textContent = message; statusLabel.style.color = hasError ? 'var(--ht-cell-error-foreground-color, #c62828)' : 'var(--ht-foreground-color, #202124)'; retryButton.hidden = !hasError; retryButton.disabled = loading;}
function mapUsersToGridRows(users: ApiUser[]) { return users.map((user) => ({ id: user.id, name: user.name, username: user.username, email: user.email, city: user.address?.city ?? '', company: user.company?.name ?? '', }));}
async function loadUsers() { setUiState({ loading: true, message: STATUS_LOADING });
try { const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) { throw new Error(`Request failed with status: ${response.status}`); }
const users = (await response.json()) as ApiUser[];
hot.loadData(mapUsersToGridRows(users)); setUiState({ message: STATUS_READY }); } catch (_error) { hot.loadData([]); setUiState({ hasError: true, message: STATUS_ERROR }); }}
retryButton.addEventListener('click', () => { loadUsers();});
loadUsers();What this recipe covers
- Fetching data from
https://jsonplaceholder.typicode.com/users. - Initializing Handsontable with an empty dataset.
- Filling the table with
hot.loadData()when the response arrives. - Showing loading, success, and error states in the interface.
- Defining a column configuration that matches API fields.
How it works
- Create Handsontable with
data: []. - Create a status element and retry button above the grid.
- Start
loadUsers()and set status to loading. - Fetch users and map nested fields (
company.name,address.city) to flat row objects. - Call
hot.loadData(rows)and show a success message. - If the request fails, show an error message and keep the table empty.
Using updateData() to preserve sorting and other states
The first example resets all grid state on every data load — column sort order, selection, and column order all go back to defaults. This is fine when no user state exists yet, but it creates a jarring experience in a running app where the user has already sorted or filtered the data.
hot.updateData() replaces the dataset while keeping every registered grid state intact. The second example demonstrates this: sort any column by clicking its header, then click Refresh. The sort order survives the data update.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const STATUS_LOADING = 'Loading users...';const STATUS_READY = 'Users loaded. Sort a column, then click "Refresh" to see that the column sort order is preserved.';const STATUS_REFRESHING = 'Refreshing...';const STATUS_REFRESHED = 'Data refreshed -- column sort order was preserved.';const STATUS_ERROR = 'Failed to load users. Try again.';
const container = document.querySelector('#example2');
if (!container) { throw new Error('Missing #example2 element.');}
const statusBar = document.createElement('div');const status = document.createElement('p');const refreshButton = document.createElement('button');const retryButton = document.createElement('button');const gridContainer = document.createElement('div');
statusBar.style.display = 'flex';statusBar.style.gap = '12px';statusBar.style.alignItems = 'center';statusBar.style.marginBottom = '8px';
status.style.margin = '0';status.style.fontFamily = 'Arial, sans-serif';status.style.fontSize = '14px';
refreshButton.type = 'button';refreshButton.textContent = 'Refresh';refreshButton.hidden = true;refreshButton.style.marginBottom = '0';
retryButton.type = 'button';retryButton.textContent = 'Retry';retryButton.hidden = true;retryButton.style.marginBottom = '0';
container.appendChild(statusBar);statusBar.appendChild(status);statusBar.appendChild(refreshButton);statusBar.appendChild(retryButton);container.appendChild(gridContainer);
// Step 1: Initialize the grid with columnSorting enabled and an empty dataset.const hot = new Handsontable(gridContainer, { data: [], colHeaders: ['ID', 'Name', 'Username', 'Email', 'City', 'Company'], columns: [ { data: 'id', type: 'numeric', width: 70 }, { data: 'name', type: 'text', width: 190 }, { data: 'username', type: 'text', width: 150 }, { data: 'email', type: 'text', width: 220 }, { data: 'city', type: 'text', width: 140 }, { data: 'company', type: 'text', width: 180 }, ], // Enables clickable sort indicators on column headers. columnSorting: true, rowHeaders: true, height: 360, width: '100%', stretchH: 'all', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation',});
// Step 2: A helper that keeps the toolbar consistent with the current request state.function setUiState({ loading = false, hasError = false, message = '' } = {}) { status.textContent = message; status.style.color = hasError ? 'var(--ht-cell-error-foreground-color, #c62828)' : 'var(--ht-foreground-color, #202124)'; // Show "Retry" only when there is an error. retryButton.hidden = !hasError; // Show "Refresh" only when the grid has data and no error is active. refreshButton.hidden = hasError || loading; refreshButton.disabled = loading; retryButton.disabled = loading;}
// Step 3: Map the API response to flat row objects that match the column definitions.function mapUsersToGridRows(users) { return users.map((user) => ({ id: user.id, name: user.name, username: user.username, email: user.email, city: user.address?.city ?? '', company: user.company?.name ?? '', }));}
// Step 4: Shared fetch helper used by both initialLoad() and refreshUsers().async function fetchUsers() { const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) { throw new Error(`Request failed with status: ${response.status}`); }
return response.json();}
// Step 5: Initial load uses loadData(), which resets all grid states.// This is correct for a first load -- there is no existing state to preserve.async function initialLoad() { setUiState({ loading: true, message: STATUS_LOADING });
try { const users = await fetchUsers();
hot.loadData(mapUsersToGridRows(users)); setUiState({ message: STATUS_READY }); } catch (_error) { hot.loadData([]); setUiState({ hasError: true, message: STATUS_ERROR }); }}
// Step 6: Subsequent refreshes use updateData(), which replaces the data// without resetting column sort order, selection, or column order.async function refreshUsers() { setUiState({ loading: true, message: STATUS_REFRESHING });
try { const users = await fetchUsers();
hot.updateData(mapUsersToGridRows(users)); setUiState({ message: STATUS_REFRESHED }); } catch (_error) { // On error, do not clear the grid -- the existing data is still valid. setUiState({ hasError: true, message: STATUS_ERROR }); }}
refreshButton.addEventListener('click', () => { refreshUsers();});
retryButton.addEventListener('click', () => { initialLoad();});
initialLoad();import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
type ApiUser = { id: number; name: string; username: string; email: string; address?: { city?: string }; company?: { name?: string };};
const STATUS_LOADING = 'Loading users...';const STATUS_READY = 'Users loaded. Sort a column, then click "Refresh" to see that the column sort order is preserved.';const STATUS_REFRESHING = 'Refreshing...';const STATUS_REFRESHED = 'Data refreshed -- column sort order was preserved.';const STATUS_ERROR = 'Failed to load users. Try again.';
const rootContainer = document.querySelector('#example2') as HTMLDivElement;
const statusBar = document.createElement('div');const statusLabel = document.createElement('p');const refreshButton = document.createElement('button');const retryButton = document.createElement('button');const gridContainer = document.createElement('div');
statusBar.style.display = 'flex';statusBar.style.gap = '12px';statusBar.style.alignItems = 'center';statusBar.style.marginBottom = '8px';
statusLabel.style.margin = '0';statusLabel.style.fontFamily = 'Arial, sans-serif';statusLabel.style.fontSize = '14px';
refreshButton.type = 'button';refreshButton.textContent = 'Refresh';refreshButton.hidden = true;refreshButton.style.marginBottom = '0';
retryButton.type = 'button';retryButton.textContent = 'Retry';retryButton.hidden = true;retryButton.style.marginBottom = '0';
rootContainer.appendChild(statusBar);statusBar.appendChild(statusLabel);statusBar.appendChild(refreshButton);statusBar.appendChild(retryButton);rootContainer.appendChild(gridContainer);
// Step 1: Initialize the grid with columnSorting enabled and an empty dataset.const hot = new Handsontable(gridContainer, { data: [], colHeaders: ['ID', 'Name', 'Username', 'Email', 'City', 'Company'], columns: [ { data: 'id', type: 'numeric', width: 70 }, { data: 'name', type: 'text', width: 190 }, { data: 'username', type: 'text', width: 150 }, { data: 'email', type: 'text', width: 220 }, { data: 'city', type: 'text', width: 140 }, { data: 'company', type: 'text', width: 180 }, ], // Enables clickable sort indicators on column headers. columnSorting: true, rowHeaders: true, height: 360, width: '100%', stretchH: 'all', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation',});
// Step 2: A helper that keeps the toolbar consistent with the current request state.function setUiState({ loading = false, hasError = false, message = '',}: { loading?: boolean; hasError?: boolean; message?: string;}) { statusLabel.textContent = message; statusLabel.style.color = hasError ? 'var(--ht-cell-error-foreground-color, #c62828)' : 'var(--ht-foreground-color, #202124)'; // Show "Retry" only when there is an error. retryButton.hidden = !hasError; // Show "Refresh" only when the grid has data and no error is active. refreshButton.hidden = hasError || loading; refreshButton.disabled = loading; retryButton.disabled = loading;}
// Step 3: Map the API response to flat row objects that match the column definitions.function mapUsersToGridRows(users: ApiUser[]) { return users.map((user) => ({ id: user.id, name: user.name, username: user.username, email: user.email, city: user.address?.city ?? '', company: user.company?.name ?? '', }));}
// Step 4: Shared fetch helper used by both initialLoad() and refreshUsers().async function fetchUsers(): Promise<ApiUser[]> { const response = await fetch('https://jsonplaceholder.typicode.com/users');
if (!response.ok) { throw new Error(`Request failed with status: ${response.status}`); }
return response.json() as Promise<ApiUser[]>;}
// Step 5: Initial load uses loadData(), which resets all grid states.// This is correct for a first load -- there is no existing state to preserve.async function initialLoad() { setUiState({ loading: true, message: STATUS_LOADING });
try { const users = await fetchUsers();
hot.loadData(mapUsersToGridRows(users)); setUiState({ message: STATUS_READY }); } catch (_error) { hot.loadData([]); setUiState({ hasError: true, message: STATUS_ERROR }); }}
// Step 6: Subsequent refreshes use updateData(), which replaces the data// without resetting column sort order, selection, or column order.async function refreshUsers() { setUiState({ loading: true, message: STATUS_REFRESHING });
try { const users = await fetchUsers();
hot.updateData(mapUsersToGridRows(users)); setUiState({ message: STATUS_REFRESHED }); } catch (_error) { // On error, do not clear the grid -- the existing data is still valid. setUiState({ hasError: true, message: STATUS_ERROR }); }}
refreshButton.addEventListener('click', () => { refreshUsers();});
retryButton.addEventListener('click', () => { initialLoad();});
initialLoad();What the second example covers
- Enabling
columnSortingso the user can sort by any column header. - Using
hot.loadData()for the first fetch — there is no existing state to preserve. - Using
hot.updateData()for every subsequent refresh to keep column sort order, selection, and column order intact. - Extracting a shared
fetchUsers()helper that both functions call. - Keeping the “Refresh” button hidden until the grid has data, and the “Retry” button visible only on error.
loadData() vs updateData()
Both methods replace the grid’s dataset. The difference is what they reset:
| Method | Resets sort order | Resets selection | Resets column order | Use when |
|---|---|---|---|---|
loadData() | Yes | Yes | Yes | Initial load, schema change, or hard reset |
updateData() | No | No | No | Periodic refresh or live-data feed |
Enable column sorting
Add
columnSorting: trueto the grid options. Handsontable renders a sort-indicator arrow in each column header, and the sort state is preserved byupdateData().const hot = new Handsontable(gridContainer, {data: [],// ...column definitions...columnSorting: true,licenseKey: 'non-commercial-and-evaluation',});What’s happening:
- Clicking a column header cycles through ascending, descending, and no sort.
- The sort state is one of the registered states that
updateData()leaves untouched. loadData(), by contrast, resets the sort state to “no sort” on every call.
Add a “Refresh” button to the toolbar
Create a second button next to the status label and add it to the status bar.
const refreshButton = document.createElement('button');refreshButton.type = 'button';refreshButton.textContent = 'Refresh';refreshButton.hidden = true; // hidden until the initial load succeedsrefreshButton.style.marginBottom = '0';statusBar.appendChild(refreshButton);What’s happening:
- The button starts hidden so it does not appear during the initial loading phase.
setUiState()makes it visible only when the grid contains data and no error is active.
Track four UI states
The second example has one more status constant than the first — a “Refreshing…” state shown while a refresh is in progress.
const STATUS_LOADING = 'Loading users...';const STATUS_READY = 'Users loaded. Sort a column, then click "Refresh" to see that the column sort order is preserved.';const STATUS_REFRESHING = 'Refreshing...';const STATUS_REFRESHED = 'Data refreshed -- column sort order was preserved.';const STATUS_ERROR = 'Failed to load users. Try again.';What’s happening:
STATUS_LOADING— shown during the first fetch.STATUS_READY— shown after the first load; prompts the user to sort a column.STATUS_REFRESHING— shown while a refresh request is in flight.STATUS_REFRESHED— shown after a successful refresh; confirms that sort order was kept.STATUS_ERROR— shown when any request fails.
Update
setUiState()to manage both buttonsThe helper controls the “Refresh” button alongside the existing “Retry” button.
function setUiState({ loading = false, hasError = false, message = '' } = {}) {status.textContent = message;status.style.color = hasError? 'var(--ht-cell-error-foreground-color, #c62828)': 'var(--ht-foreground-color, #202124)';retryButton.hidden = !hasError; // visible only on errorrefreshButton.hidden = hasError || loading; // visible only when data is readyrefreshButton.disabled = loading;retryButton.disabled = loading;}What’s happening:
refreshButton.hidden = hasError || loading— the Refresh button appears only in the “ready” or “refreshed” state.retryButton.hidden = !hasError— the Retry button appears only on error.- Both buttons are disabled while any request is in progress to prevent double-clicks.
Extract
fetchUsers()as a shared helperBoth
initialLoad()andrefreshUsers()need to call the same endpoint. Extract the fetch logic into its own function so neither handler duplicates it.async function fetchUsers() {const response = await fetch('https://jsonplaceholder.typicode.com/users');if (!response.ok) {throw new Error(`Request failed with status: ${response.status}`);}return response.json();}What’s happening:
- The function throws when the HTTP status is not
2xx, so callers can catch it in a unifiedcatchblock. - The
mapUsersToGridRows()helper (unchanged from the first example) runs in each caller, keepingfetchUsers()focused on the network concern only.
- The function throws when the HTTP status is not
Use
loadData()for the initial fetchOn the first load there is no state to preserve, so
loadData()is the right choice. It also performs a clean reset if the user retries after an error.async function initialLoad() {setUiState({ loading: true, message: STATUS_LOADING });try {const users = await fetchUsers();hot.loadData(mapUsersToGridRows(users));setUiState({ message: STATUS_READY });} catch (_error) {hot.loadData([]);setUiState({ hasError: true, message: STATUS_ERROR });}}What’s happening:
- Set the UI to the “Loading…” state and disable both buttons.
- Fetch users from the API.
- Call
hot.loadData()— resets all states (sort order, selection) and renders the fresh data. - On success, show
STATUS_READY, which prompts the user to sort a column before refreshing. - On error, clear the grid with
hot.loadData([])and show the Retry button.
Use
updateData()for subsequent refreshesWhen the user clicks Refresh, call
hot.updateData()instead ofhot.loadData(). The grid replaces its data while keeping the sort order exactly as the user left it.async function refreshUsers() {setUiState({ loading: true, message: STATUS_REFRESHING });try {const users = await fetchUsers();hot.updateData(mapUsersToGridRows(users));setUiState({ message: STATUS_REFRESHED });} catch (_error) {// On error, do not clear the grid -- the existing data is still valid.setUiState({ hasError: true, message: STATUS_ERROR });}}What’s happening:
- Set the UI to “Refreshing…” and hide the Refresh button.
- Fetch users from the API.
- Call
hot.updateData()— replaces the data while keeping column sort order, selection, and column order intact. - On success, show
STATUS_REFRESHEDand make the Refresh button available again. - On error, show the Retry button. The grid keeps its current data because
updateData()was never called — no data is lost.
Key difference from
loadData():loadData()firesbeforeLoadData/afterLoadDatahooks and resets all registered state.updateData()firesbeforeUpdateData/afterUpdateDatahooks and preserves all registered state.
Using dataProvider for automatic pagination and sorting
The first two examples manage the fetch lifecycle yourself: you call loadData() or updateData() at the right time and maintain loading state manually. Handsontable’s dataProvider option flips this model — you provide a fetchRows function and three CRUD callbacks, and the plugin drives everything else: initial load, pagination, column sorting, request cancellation, and loading overlays.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
// Step 1: Cache the full response after the first request.// Every subsequent fetchRows call (page change, column sort) reuses this// without hitting the network again.let cachedRows = null;
async function loadAllRows(signal) { if (cachedRows !== null) { return cachedRows; }
const response = await fetch('https://jsonplaceholder.typicode.com/users', { signal });
if (!response.ok) { throw new Error(`HTTP ${response.status}`); }
const users = await response.json();
cachedRows = users.map((u) => ({ id: u.id, name: u.name, username: u.username, email: u.email, city: u.address?.city ?? '', company: u.company?.name ?? '', }));
return cachedRows;}
const container = document.querySelector('#example3');
if (!container) { throw new Error('Missing #example3 element.');}
const statusBar = document.createElement('div');const status = document.createElement('p');const gridContainer = document.createElement('div');
statusBar.style.display = 'flex';statusBar.style.alignItems = 'center';statusBar.style.marginBottom = '8px';
status.style.margin = '0';status.style.fontFamily = 'Arial, sans-serif';status.style.fontSize = '14px';status.textContent = 'Loading...';
container.appendChild(statusBar);statusBar.appendChild(status);container.appendChild(gridContainer);
new Handsontable(gridContainer, { // Step 2: Use dataProvider instead of a static data array. // Handsontable calls fetchRows automatically on init, page change, and sort change. dataProvider: { // rowId tells the plugin which field holds the stable row identity. // It is required for CRUD callbacks and internal refetch tracking. rowId: 'id',
// Step 3: fetchRows receives current query parameters and an AbortSignal. // Return { rows, totalRows } so the Pagination plugin knows the total row count. async fetchRows({ page, pageSize, sort }, { signal }) { let rows = await loadAllRows(signal);
// Step 4: Apply server-side sort from query parameters. // In production, pass sort.prop and sort.order to your API instead. if (sort) { rows = [...rows].sort((a, b) => { const av = a[sort.prop]; const bv = b[sort.prop]; const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sort.order === 'asc' ? cmp : -cmp; }); }
// Step 5: Apply server-side pagination from query parameters. // In production, pass page and pageSize to your API instead. const start = (page - 1) * pageSize;
return { rows: rows.slice(start, start + pageSize), totalRows: rows.length, }; },
// Step 6: CRUD callbacks. jsonplaceholder is read-only, so these are no-ops. // Replace with POST / PATCH / DELETE calls to a real API. onRowsCreate: async () => {}, onRowsUpdate: async () => {}, onRowsRemove: async () => {}, },
colHeaders: ['ID', 'Name', 'Username', 'Email', 'City', 'Company'], columns: [ { data: 'id', type: 'numeric', width: 70, readOnly: true }, { data: 'name', type: 'text', width: 190, readOnly: true }, { data: 'username', type: 'text', width: 150, readOnly: true }, { data: 'email', type: 'text', width: 220, readOnly: true }, { data: 'city', type: 'text', width: 140, readOnly: true }, { data: 'company', type: 'text', width: 180, readOnly: true }, ],
// Step 7: Enable the Pagination plugin and pass pageSize so the plugin knows // how many rows to request. columnSorting enables server-driven sort. // emptyDataState shows a loading overlay while fetchRows is in flight. pagination: { pageSize: 5 }, columnSorting: true, emptyDataState: true,
rowHeaders: true, height: 360, width: '100%', stretchH: 'all', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation',
// Step 8: Use fetch hooks to update the status label. // skipLoading is true for internal refetches (after sort) -- skip the "Loading..." message // in those cases so the grid does not flash a spinner on every column header click. beforeDataProviderFetch: ({ skipLoading }) => { if (!skipLoading) { status.textContent = 'Loading...'; status.style.color = 'var(--ht-foreground-color, #202124)'; } }, afterDataProviderFetch: () => { status.textContent = 'Loaded from REST API via dataProvider.'; status.style.color = 'var(--ht-foreground-color, #202124)'; }, afterDataProviderFetchError: (error) => { status.textContent = `Error: ${error.message}`; status.style.color = 'var(--ht-notification-error-accent, #c62828)'; },});import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import type { DataProviderQueryParameters, DataProviderFetchOptions, DataProviderBeforeFetchParameters,} from 'handsontable/plugins/dataProvider';
registerAllModules();
type UserRow = { id: number; name: string; username: string; email: string; city: string; company: string;};
// Step 1: Cache the full response after the first request.// Every subsequent fetchRows call (page change, column sort) reuses this// without hitting the network again.let cachedRows: UserRow[] | null = null;
async function loadAllRows(signal: AbortSignal): Promise<UserRow[]> { if (cachedRows !== null) { return cachedRows; }
const response = await fetch('https://jsonplaceholder.typicode.com/users', { signal });
if (!response.ok) { throw new Error(`HTTP ${response.status}`); }
const users = (await response.json()) as Array<{ id: number; name: string; username: string; email: string; address?: { city?: string }; company?: { name?: string }; }>;
cachedRows = users.map((u) => ({ id: u.id, name: u.name, username: u.username, email: u.email, city: u.address?.city ?? '', company: u.company?.name ?? '', }));
return cachedRows;}
const rootContainer = document.querySelector('#example3') as HTMLDivElement;
const statusBar = document.createElement('div');const statusLabel = document.createElement('p');const gridContainer = document.createElement('div');
statusBar.style.display = 'flex';statusBar.style.alignItems = 'center';statusBar.style.marginBottom = '8px';
statusLabel.style.margin = '0';statusLabel.style.fontFamily = 'Arial, sans-serif';statusLabel.style.fontSize = '14px';statusLabel.style.color = 'var(--ht-foreground-color, #202124)';statusLabel.textContent = 'Loading...';
rootContainer.appendChild(statusBar);statusBar.appendChild(statusLabel);rootContainer.appendChild(gridContainer);
new Handsontable(gridContainer, { // Step 2: Use dataProvider instead of a static data array. // Handsontable calls fetchRows automatically on init, page change, and sort change. dataProvider: { // rowId tells the plugin which field holds the stable row identity. // It is required for CRUD callbacks and internal refetch tracking. rowId: 'id',
// Step 3: fetchRows receives current query parameters and an AbortSignal. // Return { rows, totalRows } so the Pagination plugin knows the total row count. async fetchRows( { page, pageSize, sort }: DataProviderQueryParameters, { signal }: DataProviderFetchOptions ) { let rows = await loadAllRows(signal);
// Step 4: Apply server-side sort from query parameters. // In production, pass sort.prop and sort.order to your API instead. if (sort) { rows = [...rows].sort((a, b) => { const av = a[sort.prop as keyof UserRow]; const bv = b[sort.prop as keyof UserRow]; const cmp = av < bv ? -1 : av > bv ? 1 : 0;
return sort.order === 'asc' ? cmp : -cmp; }); }
// Step 5: Apply server-side pagination from query parameters. // In production, pass page and pageSize to your API instead. const start = (page - 1) * pageSize;
return { rows: rows.slice(start, start + pageSize), totalRows: rows.length, }; },
// Step 6: CRUD callbacks. jsonplaceholder is read-only, so these are no-ops. // Replace with POST / PATCH / DELETE calls to a real API. onRowsCreate: async () => {}, onRowsUpdate: async () => {}, onRowsRemove: async () => {}, },
colHeaders: ['ID', 'Name', 'Username', 'Email', 'City', 'Company'], columns: [ { data: 'id', type: 'numeric', width: 70, readOnly: true }, { data: 'name', type: 'text', width: 190, readOnly: true }, { data: 'username', type: 'text', width: 150, readOnly: true }, { data: 'email', type: 'text', width: 220, readOnly: true }, { data: 'city', type: 'text', width: 140, readOnly: true }, { data: 'company', type: 'text', width: 180, readOnly: true }, ],
// Step 7: Enable the Pagination plugin and pass pageSize so the plugin knows // how many rows to request. columnSorting enables server-driven sort. // emptyDataState shows a loading overlay while fetchRows is in flight. pagination: { pageSize: 5 }, columnSorting: true, emptyDataState: true,
rowHeaders: true, height: 360, width: '100%', stretchH: 'all', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation',
// Step 8: Use fetch hooks to update the status label. // skipLoading is true for internal refetches (after sort) -- skip the "Loading..." message // in those cases so the grid does not flash a spinner on every column header click. beforeDataProviderFetch: ({ skipLoading }: DataProviderBeforeFetchParameters) => { if (!skipLoading) { statusLabel.textContent = 'Loading...'; statusLabel.style.color = 'var(--ht-foreground-color, #202124)'; } }, afterDataProviderFetch: () => { statusLabel.textContent = 'Loaded from REST API via dataProvider.'; statusLabel.style.color = 'var(--ht-foreground-color, #202124)'; }, afterDataProviderFetchError: (error: Error) => { statusLabel.textContent = `Error: ${error.message}`; statusLabel.style.color = 'var(--ht-notification-error-accent, #c62828)'; },});What the third example covers
- Configuring
dataProviderwithrowId,fetchRows, and CRUD callbacks. - Sorting rows in
fetchRowsfrom thesortquery parameter before returning a page. - Slicing rows in
fetchRowsusing thepageandpageSizequery parameters. - Enabling
pagination,columnSorting, andemptyDataStateso the plugin drives navigation and loading overlays. - Using
beforeDataProviderFetch,afterDataProviderFetch, andafterDataProviderFetchErrorhooks for status feedback. - Passing an
AbortSignaltofetchso superseded requests are cancelled automatically.
When to use each approach
| Approach | When to use |
|---|---|
loadData() | One-shot load on page init; or when you want to reset all grid state (sort, selection) together with the data. |
updateData() | Periodic refresh where the user’s sort order, selection, and column layout must survive the data update. |
dataProvider | Backend-driven pagination, sorting, and filtering; CRUD that round-trips to a server; any dataset too large to keep in the browser. |
Cache the remote response
The
dataProviderplugin callsfetchRowsevery time the user changes page or clicks a sort header. If the API does not support server-side pagination or sorting (as withjsonplaceholder), fetch the full dataset once and reuse it.let cachedRows = null;async function loadAllRows(signal) {if (cachedRows !== null) {return cachedRows;}const response = await fetch('https://jsonplaceholder.typicode.com/users', { signal });if (!response.ok) {throw new Error(`HTTP ${response.status}`);}const users = await response.json();cachedRows = users.map((u) => ({id: u.id, name: u.name, username: u.username,email: u.email,city: u.address?.city ?? '',company: u.company?.name ?? '',}));return cachedRows;}What’s happening:
cachedRowsstarts asnull. The first call hits the network; every later call returns the same array.signalis theAbortSignalfromfetchRows— passing it tofetchcancels the network request if the user sorts or changes pages before the current fetch finishes.- The raw API response is mapped to flat row objects that match your
columnsdatakeys.
In production: Pass
page,pageSize,sort, andfiltersdirectly to your API query string — no client-side caching needed. The client-side sort and slice shown in Steps 4 and 5 replace the server query.Replace the
dataarray with adataProviderobjectInstead of passing
data: []and callinghot.loadData(), pass adataProviderobject. Handsontable ignores anydataarray when the provider is complete.new Handsontable(gridContainer, {dataProvider: {rowId: 'id',fetchRows: ...,onRowsCreate: async () => {},onRowsUpdate: async () => {},onRowsRemove: async () => {},},// ...});What’s happening:
rowId: 'id'tells the plugin which property on each row object carries the stable row identity. It is required for the CRUD callbacks, refetch bookkeeping, andmodifyRowHeadernumbering across pages.- All five keys (
rowId,fetchRows,onRowsCreate,onRowsUpdate,onRowsRemove) must be valid for the configuration to be complete. If any are missing, the plugin stays enabled but the affected operations no-op.
Implement
fetchRowsfetchRowsis the only required async function. It receivesqueryParametersand an options object that carries anAbortSignal.async fetchRows({ page, pageSize, sort }, { signal }) {const rows = await loadAllRows(signal);// ...return { rows: pagedSlice, totalRows: rows.length };}What’s happening:
page— 1-based page index driven by the Pagination plugin.pageSize— rows per page; matchespagination: { pageSize: 5 }in grid options.sort—{ prop, order }object when a column header is sorted, ornullwhen unsorted.{ signal }— AbortSignal from the plugin. Pass it to yourfetchcall so superseded requests are cancelled.- Return
{ rows, totalRows }.totalRowsis the unsliced count — the Pagination plugin uses it to calculate the page count and navigation controls.
Apply sort from query parameters
When
sortis non-null, sort a copy of the rows array before slicing. Never mutate the cached array.if (sort) {rows = [...rows].sort((a, b) => {const av = a[sort.prop];const bv = b[sort.prop];const cmp = av < bv ? -1 : av > bv ? 1 : 0;return sort.order === 'asc' ? cmp : -cmp;});}What’s happening:
sort.propis thedatakey of the sorted column (for example'name'or'email').sort.orderis'asc'or'desc'.- The spread
[...rows]prevents mutating the cached array. Sorting is referentially transparent — the cache always holds the original order.
In production: You would not sort in the browser at all. Pass
sort.propandsort.orderas query parameters to your API (?_sort=name&_order=ascforjsonplaceholder-style, or your own convention).Apply pagination from query parameters
Slice the (sorted) array using
pageandpageSize.const start = (page - 1) * pageSize;return {rows: rows.slice(start, start + pageSize),totalRows: rows.length,};What’s happening:
pageis 1-based, so page 1 starts at index 0, page 2 starts at indexpageSize, and so on.totalRowsis the length of the full sorted array — not the slice. The Pagination plugin uses this to render the correct total page count.
In production: Pass
pageandpageSizeto your API (?_page=1&_limit=5forjsonplaceholder) and return the server’s owntotalorcountfield astotalRows.Provide CRUD callbacks
All three callbacks must be valid functions for the
dataProviderconfiguration to be complete. For a read-only API, return resolved promises without calling the server.onRowsCreate: async () => {},onRowsUpdate: async () => {},onRowsRemove: async () => {},What’s happening:
- The plugin serializes CRUD calls — a second mutation waits for the first to finish.
- After each callback resolves, the plugin automatically refetches the current page.
- These no-ops accept any user edit from the context menu but discard it on the next refetch. For a truly read-only grid, set
readOnly: trueon each column to prevent edits in the first place (as this example does).
In production: Implement
POST,PATCH, andDELETEcalls here. See the Server-side data guide for payload shapes and the full CRUD lifecycle.Enable
pagination,columnSorting, andemptyDataStatepagination: { pageSize: 5 },columnSorting: true,emptyDataState: true,What’s happening:
pagination: { pageSize: 5 }enables the Pagination plugin and sets 5 rows per page. The plugin renders navigation controls below the grid and passespageandpageSizeinto everyfetchRowscall.columnSorting: trueenables server-driven single-column sorting. Clicking a header setssortin the nextfetchRowscall. (multiColumnSortingis incompatible withdataProvider— usecolumnSortingonly.)emptyDataState: truerenders a loading overlay whilefetchRowsis in flight and an “empty” state when the response contains zero rows.
Use fetch hooks for status feedback
beforeDataProviderFetch: ({ skipLoading }) => {if (!skipLoading) {status.textContent = 'Loading...';}},afterDataProviderFetch: () => {status.textContent = 'Loaded from REST API via dataProvider.';},afterDataProviderFetchError: (error) => {status.textContent = `Error: ${error.message}`;status.style.color = 'var(--ht-cell-error-foreground-color, #c62828)';},What’s happening:
beforeDataProviderFetchfires before every fetch. TheskipLoadingflag istruefor internal refetches triggered after a sort or CRUD operation — skip updating the status label in those cases so the label does not flash “Loading…” on every column header click.afterDataProviderFetchfires after a successful fetch. Update the status label to confirm data was loaded.afterDataProviderFetchErrorfires whenfetchRowsrejects with a non-abort error. Update the status label with the error message. If you also setdialog: truein the grid options, the built-in Dialog plugin shows an error modal in addition to this hook.
Related
What you learned
- How to initialize Handsontable with
data: []and populate it after an async fetch withhot.loadData(). - How
hot.loadData()resets all grid state andhot.updateData()preserves column sort order, selection, and column order on refreshes. - How to use
beforeDataProviderFetch,afterDataProviderFetch, andafterDataProviderFetchErrorhooks for status feedback duringdataProviderfetches. - How the
dataProviderarchitecture handles pagination, server-side sorting, and CRUD automatically — removing the need for manual data management.
Next steps
- Explore Load data from a GraphQL API for the same patterns with a GraphQL backend.
- Explore Auto-save changes to a backend to persist grid edits automatically after a debounce delay.