Sync two grids
In this tutorial, you will sync edits from a master grid to a detail grid in real time. You will learn how to use afterChange, setDataAtCell(), and source guards to keep two Handsontable instances consistent without triggering infinite update loops.
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
const SOURCE_SYNC_FROM_MASTER = 'sync-from-master';
/* start:skip-in-preview */const masterData = [ { firstName: 'Mia', lastName: 'Johnson', plan: 'Starter', seats: 5, pricePerSeat: 29, lastActive: '2026-03-09' }, { firstName: 'Noah', lastName: 'Chen', plan: 'Team', seats: 12, pricePerSeat: 24, lastActive: '2026-03-10' }, { firstName: 'Ava', lastName: 'Miller', plan: 'Business', seats: 18, pricePerSeat: 21, lastActive: '2026-03-08' }, { firstName: 'Liam', lastName: 'Davis', plan: 'Enterprise', seats: 35, pricePerSeat: 19, lastActive: '2026-03-11' }, { firstName: 'Emma', lastName: 'Wilson', plan: 'Team', seats: 9, pricePerSeat: 24, lastActive: '2026-03-06' }, { firstName: 'Oliver', lastName: 'Khan', plan: 'Starter', seats: 4, pricePerSeat: 29, lastActive: '2026-03-07' }, { firstName: 'Sophia', lastName: 'Lee', plan: 'Business', seats: 22, pricePerSeat: 21, lastActive: '2026-03-05' }, { firstName: 'James', lastName: 'Patel', plan: 'Team', seats: 14, pricePerSeat: 24, lastActive: '2026-03-12' }, { firstName: 'Isabella', lastName: 'Rossi', plan: 'Starter', seats: 6, pricePerSeat: 29, lastActive: '2026-03-04' }, { firstName: 'Benjamin', lastName: 'Garcia', plan: 'Enterprise', seats: 41, pricePerSeat: 19, lastActive: '2026-03-03' }, { firstName: 'Charlotte', lastName: 'Nguyen', plan: 'Business', seats: 19, pricePerSeat: 21, lastActive: '2026-03-02' }, { firstName: 'Elijah', lastName: 'Brown', plan: 'Team', seats: 11, pricePerSeat: 24, lastActive: '2026-03-01' },];/* end:skip-in-preview */
const normalizePlanLabel = (plan) => typeof plan === 'string' ? plan.toUpperCase() : 'N/A';
const normalizeCustomer = (firstName, lastName) => [firstName, lastName].filter(Boolean).join(' ');
const normalizeMonthlyRevenue = (seats, pricePerSeat) => `$${((seats ?? 0) * (pricePerSeat ?? 0)).toFixed(2)}`;
const toDetailRow = (row) => ({ customer: normalizeCustomer(row.firstName, row.lastName), plan: normalizePlanLabel(row.plan), seats: row.seats, monthlyRevenue: normalizeMonthlyRevenue(row.seats, row.pricePerSeat), lastActive: row.lastActive ?? '',});
const appContainer = document.querySelector('#example1');
if (!appContainer) { throw new Error('Missing #example1 container.');}
appContainer.innerHTML = ` <div class="sync-grids-layout"> <section class="sync-grids-card"> <h4>Master grid (editable)</h4> <div id="master-grid"></div> </section> <section class="sync-grids-card"> <h4>Detail grid (synced)</h4> <div id="detail-grid"></div> </section> </div>`;
const masterContainer = appContainer.querySelector('#master-grid');const detailContainer = appContainer.querySelector('#detail-grid');
const detailHot = new Handsontable(detailContainer, { data: masterData.map(toDetailRow), colHeaders: ['Customer', 'Plan', 'Seats', 'Monthly revenue', 'Last active'], columns: [ { data: 'customer', readOnly: true, width: 170 }, { data: 'plan', readOnly: true, width: 130 }, { data: 'seats', readOnly: true, type: 'numeric', width: 70 }, { data: 'monthlyRevenue', readOnly: true, width: 130 }, { data: 'lastActive', readOnly: true, width: 110 }, ], rowHeaders: true, height: 260, width: '100%', autoWrapRow: true, stretchH: 'all', licenseKey: 'non-commercial-and-evaluation',});
const detailColumnMap = { customer: 0, plan: 1, seats: 2, monthlyRevenue: 3, lastActive: 4,};
const syncDetailRow = (rowIndex, rowData) => { const detailRow = toDetailRow(rowData);
// Batch updates to avoid multiple render cycles for one row sync. const detailChanges = Object.entries(detailColumnMap) .map(([prop, columnIndex]) => [rowIndex, columnIndex, detailRow[prop]]);
detailHot.setDataAtCell(detailChanges, SOURCE_SYNC_FROM_MASTER);};
const masterHot = new Handsontable(masterContainer, { data: masterData, colHeaders: ['First name', 'Last name', 'Plan', 'Seats', 'Price / seat', 'Last active'], columns: [ { data: 'firstName', type: 'text', width: 120 }, { data: 'lastName', type: 'text', width: 120 }, { data: 'plan', type: 'dropdown', source: ['Starter', 'Team', 'Business', 'Enterprise'], width: 130 }, { data: 'seats', type: 'numeric', width: 70 }, { data: 'pricePerSeat', type: 'numeric', numericFormat: { pattern: '$0,0.00' }, width: 105 }, { data: 'lastActive', type: 'date', dateFormat: 'YYYY-MM-DD', correctFormat: true, width: 130 }, ], rowHeaders: true, height: 260, width: '100%', autoWrapRow: true, stretchH: 'all', afterChange: (changes, source) => { // Ignore init/sync writes to prevent re-entrant updates. if (!changes || source === SOURCE_SYNC_FROM_MASTER || source === 'loadData') { return; }
const changedRows = new Set();
changes.forEach(([row]) => { if (typeof row === 'number') { changedRows.add(row); } });
changedRows.forEach((rowIndex) => { const rowData = masterHot.getSourceDataAtRow(rowIndex);
syncDetailRow(rowIndex, rowData); }); }, licenseKey: 'non-commercial-and-evaluation',});
// eslint-disable-next-line no-unused-varsconst hotInstances = { masterHot, detailHot };import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';
registerAllModules();
interface MasterRow { firstName: string; lastName: string; plan: string | null; seats: number; pricePerSeat: number; lastActive: string;}
interface DetailRow { customer: string; plan: string; seats: number; monthlyRevenue: string; lastActive: string;}
const SOURCE_SYNC_FROM_MASTER = 'sync-from-master';
/* start:skip-in-preview */const masterData: MasterRow[] = [ { firstName: 'Mia', lastName: 'Johnson', plan: 'Starter', seats: 5, pricePerSeat: 29, lastActive: '2026-03-09' }, { firstName: 'Noah', lastName: 'Chen', plan: 'Team', seats: 12, pricePerSeat: 24, lastActive: '2026-03-10' }, { firstName: 'Ava', lastName: 'Miller', plan: 'Business', seats: 18, pricePerSeat: 21, lastActive: '2026-03-08' }, { firstName: 'Liam', lastName: 'Davis', plan: 'Enterprise', seats: 35, pricePerSeat: 19, lastActive: '2026-03-11' }, { firstName: 'Emma', lastName: 'Wilson', plan: 'Team', seats: 9, pricePerSeat: 24, lastActive: '2026-03-06' }, { firstName: 'Oliver', lastName: 'Khan', plan: 'Starter', seats: 4, pricePerSeat: 29, lastActive: '2026-03-07' }, { firstName: 'Sophia', lastName: 'Lee', plan: 'Business', seats: 22, pricePerSeat: 21, lastActive: '2026-03-05' }, { firstName: 'James', lastName: 'Patel', plan: 'Team', seats: 14, pricePerSeat: 24, lastActive: '2026-03-12' }, { firstName: 'Isabella', lastName: 'Rossi', plan: 'Starter', seats: 6, pricePerSeat: 29, lastActive: '2026-03-04' }, { firstName: 'Benjamin', lastName: 'Garcia', plan: 'Enterprise', seats: 41, pricePerSeat: 19, lastActive: '2026-03-03' }, { firstName: 'Charlotte', lastName: 'Nguyen', plan: 'Business', seats: 19, pricePerSeat: 21, lastActive: '2026-03-02' }, { firstName: 'Elijah', lastName: 'Brown', plan: 'Team', seats: 11, pricePerSeat: 24, lastActive: '2026-03-01' },];/* end:skip-in-preview */
const normalizePlanLabel = (plan: MasterRow['plan']): string => typeof plan === 'string' ? plan.toUpperCase() : 'N/A';
const normalizeCustomer = (firstName: string | null, lastName: string | null): string => [firstName, lastName].filter(Boolean).join(' ');
const normalizeMonthlyRevenue = (seats: number | null, pricePerSeat: number | null): string => `$${((seats ?? 0) * (pricePerSeat ?? 0)).toFixed(2)}`;
const toDetailRow = (row: MasterRow): DetailRow => ({ customer: normalizeCustomer(row.firstName, row.lastName), plan: normalizePlanLabel(row.plan), seats: row.seats, monthlyRevenue: normalizeMonthlyRevenue(row.seats, row.pricePerSeat), lastActive: row.lastActive ?? '',});
const appContainer = document.querySelector('#example1') as HTMLDivElement;
if (!appContainer) { throw new Error('Missing #example1 container.');}
appContainer.innerHTML = ` <div class="sync-grids-layout"> <section class="sync-grids-card"> <h4>Master grid (editable)</h4> <div id="master-grid"></div> </section> <section class="sync-grids-card"> <h4>Detail grid (synced)</h4> <div id="detail-grid"></div> </section> </div>`;
const masterContainer = appContainer.querySelector('#master-grid') as HTMLDivElement;const detailContainer = appContainer.querySelector('#detail-grid') as HTMLDivElement;
const detailHot = new Handsontable(detailContainer, { data: masterData.map(toDetailRow), colHeaders: ['Customer', 'Plan', 'Seats', 'Monthly revenue', 'Last active'], columns: [ { data: 'customer', readOnly: true, width: 170 }, { data: 'plan', readOnly: true, width: 130 }, { data: 'seats', readOnly: true, type: 'numeric', width: 70 }, { data: 'monthlyRevenue', readOnly: true, width: 130 }, { data: 'lastActive', readOnly: true, width: 110 }, ], rowHeaders: true, height: 260, width: '100%', autoWrapRow: true, stretchH: 'all', licenseKey: 'non-commercial-and-evaluation',});
const detailColumnMap: Record<keyof DetailRow, number> = { customer: 0, plan: 1, seats: 2, monthlyRevenue: 3, lastActive: 4,};
const syncDetailRow = (rowIndex: number, rowData: MasterRow) => { const detailRow = toDetailRow(rowData);
// Batch updates into one call to avoid multiple render passes. const updates = (Object.entries(detailColumnMap) as [keyof DetailRow, number][]) .map(([prop, columnIndex]) => [rowIndex, columnIndex, detailRow[prop]] as [number, number, DetailRow[keyof DetailRow]]);
detailHot.setDataAtCell(updates, SOURCE_SYNC_FROM_MASTER);};
const masterHot = new Handsontable(masterContainer, { data: masterData, colHeaders: ['First name', 'Last name', 'Plan', 'Seats', 'Price / seat', 'Last active'], columns: [ { data: 'firstName', type: 'text', width: 120 }, { data: 'lastName', type: 'text', width: 120 }, { data: 'plan', type: 'dropdown', source: ['Starter', 'Team', 'Business', 'Enterprise'], width: 130 }, { data: 'seats', type: 'numeric', width: 70 }, { data: 'pricePerSeat', type: 'numeric', numericFormat: { pattern: '$0,0.00' }, width: 105 }, { data: 'lastActive', type: 'date', dateFormat: 'YYYY-MM-DD', correctFormat: true, width: 130 }, ], rowHeaders: true, height: 260, width: '100%', autoWrapRow: true, stretchH: 'all', afterChange: (changes, source) => { // Ignore init/sync writes to prevent re-entrant updates. if (!changes || source === SOURCE_SYNC_FROM_MASTER || source === 'loadData') { return; }
const changedRows = new Set<number>();
changes.forEach(([row]) => { if (typeof row === 'number') { changedRows.add(row); } });
changedRows.forEach((rowIndex) => { const rowData = masterHot.getSourceDataAtRow(rowIndex) as MasterRow;
syncDetailRow(rowIndex, rowData); }); }, licenseKey: 'non-commercial-and-evaluation',});
// eslint-disable-next-line no-unused-varsconst hotInstances = { masterHot, detailHot };.sync-grids-layout { display: grid; grid-template-columns: repeat(2, minmax(0, 1fr)); gap: 12px;}
.sync-grids-card { border: 1px solid var(--sl-color-gray-5); border-radius: 4px; padding: 8px; background: var(--sl-color-gray-7);}
.sync-grids-card h4 { margin: 0 0 8px; font-size: 13px; font-weight: 600; color: var(--sl-color-text);}
@media (max-width: 960px) { .sync-grids-layout { grid-template-columns: 1fr; }}Overview
This recipe shows how to keep two Handsontable instances in sync on the same page. You edit data in the master grid, and the detail grid updates immediately.
The implementation uses afterChange and batched setDataAtCell() updates, plus a source guard to avoid infinite update loops.
Difficulty: Beginner Time: ~10 minutes Libraries: None (pure Handsontable)
What you’ll build
Two grids displayed side by side:
- Master grid - editable source data.
- Detail grid - synced preview with transformed values.
When you edit a row in the master grid, the related row in the detail grid is updated instantly.
Initialize two grid containers
Render two containers inside one recipe root element and place them side by side with CSS Grid.
const appContainer = document.querySelector('#example1') as HTMLDivElement;appContainer.innerHTML = `<div class="sync-grids-layout"><section class="sync-grids-card"><h4>Master grid (editable)</h4><div id="master-grid"></div></section><section class="sync-grids-card"><h4>Detail grid (synced)</h4><div id="detail-grid"></div></section></div>`;Create a detail model
Use a helper function to map master rows into preview rows. This lets you sync only selected columns and transform values before display.
const toDetailRow = (row: MasterRow): DetailRow => ({customer: `${row.firstName} ${row.lastName}`,plan: row.plan.toUpperCase(),seats: row.seats,monthlyRevenue: `$${(row.seats * row.pricePerSeat).toFixed(2)}`,});Initialize detail grid
Create the detail table with
readOnly: trueso it acts as a live preview.const detailHot = new Handsontable(detailContainer, {data: masterData.map(toDetailRow),readOnly: true,// ...});Sync updates with
afterChangeUse
afterChangeon the master instance. For each changed row, map the detail values and apply them in a single batchedsetDataAtCell()call.afterChange: (changes, source) => {if (!changes || source === 'sync-from-master' || source === 'loadData') {return;}changes.forEach(([row, prop, _oldValue, newValue]) => {// Update only mapped columns.});}The source check prevents re-entrant updates if synced writes trigger hooks, and batching keeps each row sync to one render pass.
How it works - complete flow
- You edit a value in the master grid.
afterChangereceives row, column, and new value.- The handler updates only mapped fields in the detail row.
- The detail grid receives updates via
setDataAtCell(..., 'sync-from-master'). - The source guard ignores sync-originated updates, so no infinite loop occurs.
Why this pattern is useful
- Keep one grid editable and another focused on read-only presentation.
- Sync only selected fields, not the whole dataset.
- Add value formatting or derived columns in one place.
- Avoid expensive full-table refreshes by patching changed cells only.
What you learned
- How to use
afterChangeon one Handsontable instance to detect edits and propagate them to a second instance. - How to use
setDataAtCell()with a customsourcestring to apply updates without triggering an infinite re-entry loop. - How a source guard (
if (source === 'sync-from-master') return) prevents the synced writes from firing theafterChangehook again. - How to batch multiple cell updates into a single
setDataAtCell()call to keep each sync to one render pass.
Next steps
- Extend the sync to work in both directions so either grid can serve as the master.
- Add a field mapping function to transform values (for example, format currency in the detail grid) before applying the sync.
- Explore the undo/redo recipe to let users revert synchronized changes.