Auto-save changes to a backend
In this tutorial, you will build an auto-save flow that sends grid edits to a backend after a debounce delay. You will learn how to use afterChange, dirty row tracking, and save status feedback to give users real-time confirmation of their changes.
import { useRef, useState } from 'react';import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const data = [ { id: 1, product: 'Keyboard', stock: 14, price: 89, status: 'active' }, { id: 2, product: 'Monitor', stock: 5, price: 249, status: 'active' }, { id: 3, product: 'Dock', stock: 22, price: 139, status: 'draft' }, { id: 4, product: 'Webcam', stock: 9, price: 119, status: 'active' }, { id: 5, product: 'Headset', stock: 16, price: 99, status: 'paused' },];
const statusLabels = { idle: 'No pending changes', saving: 'Saving...', saved: 'Saved ✓', error: 'Error',};
const statusColors = { idle: '#616161', saving: '#1a42e8', saved: '#117a1f', error: '#c62828',};
const saveRowsToBackend = (rows) => { return new Promise((resolve) => setTimeout(resolve, 450)).then(() => { // Replace this with fetch('/api/products', { method: 'PATCH', body: ... }) in production. // eslint-disable-next-line no-console console.log('PATCH /api/products', rows); });};
const ExampleComponent = () => { const hotRef = useRef(null); const [saveStatus, setSaveStatus] = useState('idle'); const dirtyRowsRef = useRef(new Set()); const saveTimeoutRef = useRef(null); const saveRequestCounterRef = useRef(0);
const handleAfterChange = (changes, source) => { if (!changes || source === 'loadData') { return; }
const hot = hotRef.current?.hotInstance;
if (!hot) { return; }
changes.forEach(([visualRow, _prop, oldValue, newValue]) => { if (oldValue !== newValue) { const physicalRow = hot.toPhysicalRow(visualRow);
if (physicalRow !== null && physicalRow >= 0) { dirtyRowsRef.current.add(physicalRow); } } });
if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); }
saveTimeoutRef.current = setTimeout(() => { const physicalRows = Array.from(dirtyRowsRef.current);
if (physicalRows.length === 0) { return; }
const requestId = ++saveRequestCounterRef.current; const visualRows = physicalRows .map((physicalRow) => hot.toVisualRow(physicalRow)) .filter((row) => row !== null);
hot.validateRows(visualRows, (valid) => { if (!valid) { if (requestId === saveRequestCounterRef.current) { setSaveStatus('error'); }
return; }
const rowsToSave = physicalRows .map((physicalRow) => hot.getSourceDataAtRow(physicalRow)) .filter((row) => row !== undefined && row !== null);
dirtyRowsRef.current.clear(); setSaveStatus('saving');
void saveRowsToBackend(rowsToSave) .then(() => { if (requestId === saveRequestCounterRef.current) { setSaveStatus('saved'); } }) .catch(() => { physicalRows.forEach((physicalRow) => dirtyRowsRef.current.add(physicalRow));
if (requestId === saveRequestCounterRef.current) { setSaveStatus('error'); } }); }); }, 800); };
return ( <> <div style={{ marginBottom: '8px', fontFamily: 'Arial, sans-serif', fontSize: '13px', fontWeight: '600', color: statusColors[saveStatus], }} > {statusLabels[saveStatus]} </div> <HotTable ref={hotRef} data={data} colHeaders={['ID', 'Product', 'Stock', 'Price', 'Status']} columns={[ { data: 'id', type: 'numeric', readOnly: true, width: 70 }, { data: 'product', type: 'text', width: 180 }, { data: 'stock', type: 'numeric', width: 90 }, { data: 'price', type: 'numeric', numericFormat: { pattern: '$0,0.00' }, width: 110 }, { data: 'status', type: 'text', width: 120 }, ]} stretchH="all" height="auto" afterChange={handleAfterChange} licenseKey="non-commercial-and-evaluation" /> </> );};
export default ExampleComponent;import { useRef, useState } from 'react';import { HotTable, HotTableRef } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const data = [ { id: 1, product: 'Keyboard', stock: 14, price: 89, status: 'active' }, { id: 2, product: 'Monitor', stock: 5, price: 249, status: 'active' }, { id: 3, product: 'Dock', stock: 22, price: 139, status: 'draft' }, { id: 4, product: 'Webcam', stock: 9, price: 119, status: 'active' }, { id: 5, product: 'Headset', stock: 16, price: 99, status: 'paused' },];
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
const statusLabels: Record<SaveStatus, string> = { idle: 'No pending changes', saving: 'Saving...', saved: 'Saved ✓', error: 'Error',};
const statusColors: Record<SaveStatus, string> = { idle: '#616161', saving: '#1a42e8', saved: '#117a1f', error: '#c62828',};
const saveRowsToBackend = (rows: unknown[]): Promise<void> => { return new Promise<void>((resolve) => setTimeout(resolve, 450)).then(() => { // Replace this with fetch('/api/products', { method: 'PATCH', body: ... }) in production. // eslint-disable-next-line no-console console.log('PATCH /api/products', rows); });};
const ExampleComponent = () => { const hotRef = useRef<HotTableRef>(null); const [saveStatus, setSaveStatus] = useState<SaveStatus>('idle'); const dirtyRowsRef = useRef(new Set<number>()); const saveTimeoutRef = useRef<ReturnType<typeof setTimeout> | null>(null); const saveRequestCounterRef = useRef(0);
const handleAfterChange = ( changes: [number, string | number, unknown, unknown][] | null, source: string ): void => { if (!changes || source === 'loadData') { return; }
const hot = hotRef.current?.hotInstance;
if (!hot) { return; }
changes.forEach(([visualRow, _prop, oldValue, newValue]) => { if (oldValue !== newValue) { const physicalRow = hot.toPhysicalRow(visualRow);
if (physicalRow !== null && physicalRow >= 0) { dirtyRowsRef.current.add(physicalRow); } } });
if (saveTimeoutRef.current) { clearTimeout(saveTimeoutRef.current); }
saveTimeoutRef.current = setTimeout(() => { const physicalRows = Array.from(dirtyRowsRef.current);
if (physicalRows.length === 0) { return; }
const requestId = ++saveRequestCounterRef.current; const visualRows = physicalRows .map((physicalRow) => hot.toVisualRow(physicalRow)) .filter((row): row is number => row !== null);
hot.validateRows(visualRows, (valid) => { if (!valid) { if (requestId === saveRequestCounterRef.current) { setSaveStatus('error'); }
return; }
const rowsToSave = physicalRows .map((physicalRow) => hot.getSourceDataAtRow(physicalRow)) .filter((row) => row !== undefined && row !== null);
dirtyRowsRef.current.clear(); setSaveStatus('saving');
void saveRowsToBackend(rowsToSave) .then(() => { if (requestId === saveRequestCounterRef.current) { setSaveStatus('saved'); } }) .catch(() => { physicalRows.forEach((physicalRow) => dirtyRowsRef.current.add(physicalRow));
if (requestId === saveRequestCounterRef.current) { setSaveStatus('error'); } }); }); }, 800); };
return ( <> <div style={{ marginBottom: '8px', fontFamily: 'Arial, sans-serif', fontSize: '13px', fontWeight: '600', color: statusColors[saveStatus], }} > {statusLabels[saveStatus]} </div> <HotTable ref={hotRef} data={data} colHeaders={['ID', 'Product', 'Stock', 'Price', 'Status']} columns={[ { data: 'id', type: 'numeric', readOnly: true, width: 70 }, { data: 'product', type: 'text', width: 180 }, { data: 'stock', type: 'numeric', width: 90 }, { data: 'price', type: 'numeric', numericFormat: { pattern: '$0,0.00' }, width: 110 }, { data: 'status', type: 'text', width: 120 }, ]} stretchH="all" height="auto" afterChange={handleAfterChange} licenseKey="non-commercial-and-evaluation" /> </> );};
export default ExampleComponent;Overview
This recipe shows how to auto-save edited rows to a backend with afterChange, an 800 ms debounce, and row-level dirty tracking. It sends only modified rows, ignores loadData changes, and reports save status in the UI.
Difficulty: Intermediate
Time: ~20 minutes
Libraries: None (mock backend included)
What You’ll Build
A grid that:
- Tracks edited rows in a dirty set.
- Batches rapid edits into one debounced save request.
- Sends only changed rows to a backend save function.
- Shows save state as Saving…, Saved ✓, or Error.
- Ignores
loadDataupdates so initial data loading does not trigger saves.
Register modules and create sample data
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';registerAllModules();const data = [{ id: 1, product: 'Keyboard', stock: 14, price: 89, status: 'active' },{ id: 2, product: 'Monitor', stock: 5, price: 249, status: 'active' },{ id: 3, product: 'Dock', stock: 22, price: 139, status: 'draft' },{ id: 4, product: 'Webcam', stock: 9, price: 119, status: 'active' },{ id: 5, product: 'Headset', stock: 16, price: 99, status: 'paused' },];Use object rows with a stable primary key (
id) so each payload can identify records.Add a save status element
const statusEl = document.querySelector('#save-status');function setSaveStatus(state: 'idle' | 'saving' | 'saved' | 'error') {if (!statusEl) {return;}const labels = {idle: 'No pending changes',saving: 'Saving...',saved: 'Saved ✓',error: 'Error',};statusEl.textContent = labels[state];statusEl.dataset.state = state;}This keeps save feedback separate from table logic.
Add a backend save function
async function saveRowsToBackend(rows) {await new Promise((resolve) => setTimeout(resolve, 450));// Replace this with fetch('/api/products', { method: 'PATCH', body: ... }) in production.// eslint-disable-next-line no-consoleconsole.log('PATCH /api/products', rows);}Use a mock promise so the recipe works without extra setup.
Track dirty rows and debounce saves
const dirtyRows = new Set<number>();let saveTimeout: ReturnType<typeof setTimeout> | null = null;let saveRequestCounter = 0;function queueSave() {if (saveTimeout) {clearTimeout(saveTimeout);}saveTimeout = setTimeout(async () => {const physicalRows = Array.from(dirtyRows);if (physicalRows.length === 0) {return;}const requestId = ++saveRequestCounter;const rowsToSave = physicalRows.map((physicalRow) => hot.getSourceDataAtRow(physicalRow)).filter((row): row is RowData => row !== undefined && row !== null);dirtyRows.clear();setSaveStatus('saving');try {await saveRowsToBackend(rowsToSave);if (requestId === saveRequestCounter) {setSaveStatus('saved');}} catch (_error) {physicalRows.forEach((physicalRow) => dirtyRows.add(physicalRow));if (requestId === saveRequestCounter) {setSaveStatus('error');}}}, 800);}The debounce batches fast edits into one request, and the dirty set prevents duplicate row saves.
Use
afterChangeand ignoreloadDataafterChange(changes, source) {if (!changes || source === 'loadData') {return;}changes.forEach(([visualRow, _prop, oldValue, newValue]) => {if (oldValue !== newValue) {const physicalRow = hot.toPhysicalRow(visualRow as number);if (typeof physicalRow === 'number') {dirtyRows.add(physicalRow);}}});queueSave();}This limits auto-save behavior to user edits and other non-load update sources.
Complete working example
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';registerAllModules();type RowData = {id: number;product: string;stock: number;price: number;status: 'active' | 'draft' | 'paused';};const data: RowData[] = [{ id: 1, product: 'Keyboard', stock: 14, price: 89, status: 'active' },{ id: 2, product: 'Monitor', stock: 5, price: 249, status: 'active' },{ id: 3, product: 'Dock', stock: 22, price: 139, status: 'draft' },{ id: 4, product: 'Webcam', stock: 9, price: 119, status: 'active' },{ id: 5, product: 'Headset', stock: 16, price: 99, status: 'paused' },];const container = document.querySelector('#example1');if (container instanceof HTMLElement) {const statusEl = document.createElement('div');statusEl.id = 'save-status';container.before(statusEl);const dirtyRows = new Set<number>();let saveTimeout: ReturnType<typeof setTimeout> | null = null;let saveRequestCounter = 0;const setSaveStatus = (state: 'idle' | 'saving' | 'saved' | 'error') => {const labels = {idle: 'No pending changes',saving: 'Saving...',saved: 'Saved ✓',error: 'Error',};statusEl.textContent = labels[state];statusEl.dataset.state = state;};const saveRowsToBackend = async (rows: RowData[]) => {await new Promise((resolve) => setTimeout(resolve, 450));// eslint-disable-next-line no-consoleconsole.log('PATCH /api/products', rows);};const hot = new Handsontable(container, {data,rowHeaders: true,colHeaders: ['ID', 'Product', 'Stock', 'Price', 'Status'],columns: [{ data: 'id', type: 'numeric', readOnly: true, width: 70 },{ data: 'product', type: 'text', width: 180 },{ data: 'stock', type: 'numeric', width: 90 },{ data: 'price', type: 'numeric', numericFormat: { pattern: '$0,0.00' }, width: 110 },{ data: 'status', type: 'text', width: 120 },],stretchH: 'all',height: 'auto',licenseKey: 'non-commercial-and-evaluation',afterChange(changes, source) {if (!changes || source === 'loadData') {return;}changes.forEach(([visualRow, _prop, oldValue, newValue]) => {if (oldValue !== newValue) {const physicalRow = hot.toPhysicalRow(visualRow as number);if (typeof physicalRow === 'number') {dirtyRows.add(physicalRow);}}});if (saveTimeout) {clearTimeout(saveTimeout);}saveTimeout = setTimeout(async () => {const physicalRows = Array.from(dirtyRows);if (physicalRows.length === 0) {return;}const requestId = ++saveRequestCounter;const rowsToSave = physicalRows.map((physicalRow) => hot.getSourceDataAtRow(physicalRow)).filter((row): row is RowData => row !== undefined && row !== null);dirtyRows.clear();setSaveStatus('saving');try {await saveRowsToBackend(rowsToSave);if (requestId === saveRequestCounter) {setSaveStatus('saved');}} catch (_error) {physicalRows.forEach((physicalRow) => dirtyRows.add(physicalRow));if (requestId === saveRequestCounter) {setSaveStatus('error');}}}, 800);},});// Demonstrate that loadData updates do not trigger save requests.hot.loadData(data.map((row) => ({ ...row })));setSaveStatus('idle');}
How It Works - Save lifecycle
- User edits one or more cells.
afterChangecaptures changed visual rows, but skipssource === 'loadData'.- The debounce timer resets on each new edit.
- After 800 ms without edits, only dirty rows are collected and sent.
- The UI status changes from Saving… to Saved ✓ (or Error on failure).
Production tips
- Send stable IDs and changed fields only if your API accepts partial row updates.
- Replace the mock save with authenticated
fetchcalls and server-side validation. - Add retry or backoff logic for transient network failures.
- Show the last successful save timestamp for better user confidence.
What you learned
- How to use
afterChangeto react to grid edits and skip system-generated changes by checking thesourceargument. - How debouncing limits the number of save requests when the user edits many cells in quick succession.
- How dirty row tracking lets you send only changed rows instead of the full dataset.
- How to provide visual feedback with a save status element that reflects idle, saving, saved, and error states.
Next steps
- Replace the mock save with a real
fetchcall to your API endpoint. - Add undo/redo with a custom UI to let users revert changes before they are auto-saved.
- Explore server-side data with NestJS for a full server-driven CRUD approach with the
dataProviderplugin.