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.
/* file: app.component.ts */import { Component, NgZone, ViewChild, inject } from '@angular/core';import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';import Handsontable from 'handsontable/base';
type RowData = { id: number; product: string; stock: number; price: number; status: 'active' | 'draft' | 'paused';};
type SaveStatus = 'idle' | 'saving' | 'saved' | 'error';
const STATUS_LABELS: Record<SaveStatus, string> = { idle: 'No pending changes', saving: 'Saving...', saved: 'Saved ✓', error: 'Error',};
const STATUS_COLORS: Record<SaveStatus, string> = { idle: '#616161', saving: '#1a42e8', saved: '#117a1f', error: '#c62828',};
@Component({ standalone: true, imports: [HotTableModule], selector: 'example1-auto-save-backend', template: ` <div style="margin-bottom: 8px; font-family: Arial, sans-serif; font-size: 13px; font-weight: 600;" [style.color]="statusColor" >{{ statusLabel }}</div> <hot-table [data]="hotData" [settings]="hotSettings"></hot-table> `,})export class AppComponent { @ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;
private readonly ngZone = inject(NgZone);
saveStatus: SaveStatus = 'idle';
get statusLabel(): string { return STATUS_LABELS[this.saveStatus]; }
get statusColor(): string { return STATUS_COLORS[this.saveStatus]; }
readonly hotData: 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' }, ];
private dirtyRows = new Set<number>(); private saveTimeout: ReturnType<typeof setTimeout> | null = null; private saveRequestCounter = 0;
private saveRowsToBackend(rows: RowData[]): Promise<void> { return new Promise((resolve) => setTimeout(resolve, 450)).then(() => { // Replace with fetch('/api/products', { method: 'PATCH', body: ... }) in production. // eslint-disable-next-line no-console console.log('PATCH /api/products', rows); }); }
readonly hotSettings: GridSettings = { 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', afterChange: (changes: Handsontable.CellChange[] | null, source: Handsontable.ChangeSource) => { if (!changes || source === 'loadData') { return; }
const hot = this.hotTable?.hotInstance;
if (!hot) { return; }
changes.forEach(([visualRow, _prop, oldValue, newValue]) => { if (oldValue !== newValue) { const physicalRow = hot.toPhysicalRow(visualRow as number);
if (physicalRow !== null && physicalRow >= 0) { this.dirtyRows.add(physicalRow); } } });
if (this.saveTimeout) { clearTimeout(this.saveTimeout); }
this.saveTimeout = setTimeout(() => { const physicalRows = Array.from(this.dirtyRows);
if (physicalRows.length === 0) { return; }
const requestId = ++this.saveRequestCounter; const visualRows = physicalRows .map((physicalRow) => hot.toVisualRow(physicalRow)) .filter((row): row is number => row !== null);
hot.validateRows(visualRows, (valid) => { if (!valid) { if (requestId === this.saveRequestCounter) { this.ngZone.run(() => { this.saveStatus = 'error'; }); }
return; }
const rowsToSave = physicalRows .map((physicalRow) => hot.getSourceDataAtRow(physicalRow)) .filter((row): row is RowData => row !== undefined && row !== null);
this.dirtyRows.clear(); this.ngZone.run(() => { this.saveStatus = 'saving'; });
void this.saveRowsToBackend(rowsToSave) .then(() => { if (requestId === this.saveRequestCounter) { this.ngZone.run(() => { this.saveStatus = 'saved'; }); } }) .catch(() => { physicalRows.forEach((physicalRow) => this.dirtyRows.add(physicalRow));
if (requestId === this.saveRequestCounter) { this.ngZone.run(() => { this.saveStatus = 'error'; }); } }); }); }, 800); }, };}/* end-file */
/* file: app.config.ts */import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core';import { registerAllModules } from 'handsontable/registry';import { HOT_GLOBAL_CONFIG, HotGlobalConfig, NON_COMMERCIAL_LICENSE } from '@handsontable/angular-wrapper';
registerAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <example1-auto-save-backend></example1-auto-save-backend></div>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.