Skip to content

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.

TypeScript
/* 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 */
HTML
<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 loadData updates so initial data loading does not trigger saves.
  1. 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.

  2. 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.

  3. 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-console
    console.log('PATCH /api/products', rows);
    }

    Use a mock promise so the recipe works without extra setup.

  4. 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.

  5. Use afterChange and ignore loadData

    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);
    }
    }
    });
    queueSave();
    }

    This limits auto-save behavior to user edits and other non-load update sources.

  6. 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-console
    console.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

  1. User edits one or more cells.
  2. afterChange captures changed visual rows, but skips source === 'loadData'.
  3. The debounce timer resets on each new edit.
  4. After 800 ms without edits, only dirty rows are collected and sent.
  5. 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 fetch calls 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 afterChange to react to grid edits and skip system-generated changes by checking the source argument.
  • 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