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 { useRef } from 'react';import { HotTable } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import './example1.css';
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 detailColumnMap = { customer: 0, plan: 1, seats: 2, monthlyRevenue: 3, lastActive: 4,};
const ExampleComponent = () => { const masterHotRef = useRef(null); const detailHotRef = useRef(null);
const syncDetailRow = (rowIndex, rowData) => { const detailHot = detailHotRef.current?.hotInstance;
if (!detailHot) { return; }
const detailRow = toDetailRow(rowData); const detailChanges = Object.entries(detailColumnMap).map(([prop, columnIndex]) => [ rowIndex, columnIndex, detailRow[prop], ]);
detailHot.setDataAtCell(detailChanges, SOURCE_SYNC_FROM_MASTER); };
const handleMasterAfterChange = (changes, source) => { // Ignore init/sync writes to prevent re-entrant updates. if (!changes || source === SOURCE_SYNC_FROM_MASTER || source === 'loadData') { return; }
const masterHot = masterHotRef.current?.hotInstance;
if (!masterHot) { 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); }); };
return ( <div className="sync-grids-layout"> <section className="sync-grids-card"> <h4>Master grid (editable)</h4> <HotTable ref={masterHotRef} 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={handleMasterAfterChange} licenseKey="non-commercial-and-evaluation" /> </section> <section className="sync-grids-card"> <h4>Detail grid (synced)</h4> <HotTable ref={detailHotRef} 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" /> </section> </div> );};
export default ExampleComponent;import { useRef } from 'react';import { HotTable, HotTableRef } from '@handsontable/react-wrapper';import { registerAllModules } from 'handsontable/registry';import './example1.css';
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 */
type MasterRow = { firstName: string; lastName: string; plan: string; seats: number; pricePerSeat: number; lastActive: string;};
type DetailRow = { customer: string; plan: string; seats: number; monthlyRevenue: string; lastActive: string;};
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 detailColumnMap: Record<keyof DetailRow, number> = { customer: 0, plan: 1, seats: 2, monthlyRevenue: 3, lastActive: 4,};
const ExampleComponent = () => { const masterHotRef = useRef<HotTableRef>(null); const detailHotRef = useRef<HotTableRef>(null);
const syncDetailRow = (rowIndex: number, rowData: MasterRow): void => { const detailHot = detailHotRef.current?.hotInstance;
if (!detailHot) { return; }
const detailRow = toDetailRow(rowData); const detailChanges = (Object.entries(detailColumnMap) as [keyof DetailRow, number][]).map( ([prop, columnIndex]) => [rowIndex, columnIndex, detailRow[prop]] as [number, number, unknown] );
detailHot.setDataAtCell(detailChanges, SOURCE_SYNC_FROM_MASTER); };
const handleMasterAfterChange = (changes: [number, string | number, unknown, unknown][] | null, source: string): void => { // Ignore init/sync writes to prevent re-entrant updates. if (!changes || source === SOURCE_SYNC_FROM_MASTER || source === 'loadData') { return; }
const masterHot = masterHotRef.current?.hotInstance;
if (!masterHot) { 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); }); };
return ( <div className="sync-grids-layout"> <section className="sync-grids-card"> <h4>Master grid (editable)</h4> <HotTable ref={masterHotRef} 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={handleMasterAfterChange} licenseKey="non-commercial-and-evaluation" /> </section> <section className="sync-grids-card"> <h4>Detail grid (synced)</h4> <HotTable ref={detailHotRef} 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" /> </section> </div> );};
export default ExampleComponent;.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.