Handling collaboration and simultaneous editing
Coordinate edits between multiple users by detecting active edit state, intercepting local changes, and syncing structural and non-data changes across clients.
Handsontable doesn’t ship a transport layer or a conflict-resolution algorithm. To build collaborative editing, you send local changes to your own backend (for example, a WebSocket server) and apply changes from other collaborators back into the grid. This guide covers the hooks and methods you use on both sides of that flow.
The examples on this page simulate a remote collaborator locally, so they run without a real backend. In your app, replace the simulated call with the message you receive from your collaboration server.
Avoid overwriting a cell that’s being edited
Applying a remote change to a cell while the local user is still typing in it discards their in-progress edit. Before applying a remote change, check whether the target cell’s editor is open with getActiveEditor() and isOpened(). If it is, wait until the local edit finishes, then apply the remote change.
Use the beforeChange hook to read local edits and send them to other collaborators. Give changes that come from another collaborator a distinct source value, so your beforeChange handler doesn’t broadcast them again.
/* file: app.component.ts */import { Component, OnDestroy, OnInit, ViewChild } from '@angular/core';import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';import Handsontable from 'handsontable/base';
// marks a change as coming from another collaborator, so it isn't broadcast againconst REMOTE_SOURCE = 'remotePeer';
@Component({ selector: 'example1-collaboration', standalone: true, imports: [HotTableModule], template: ` <div class="example-controls-container"> <p class="controls">{{ statusText }}</p> </div> <div> <hot-table [data]="hotData" [settings]="gridSettings"></hot-table> </div>`,})export class AppComponent implements OnInit, OnDestroy { @ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;
statusText = 'A remote update to the first row arrives in 3 seconds.';
private timeoutId?: ReturnType<typeof setTimeout>;
readonly hotData = [ ['Update onboarding flow', 'Ana García', 'In progress'], ['Fix invoice rounding bug', 'James Okafor', 'Blocked'], ['Write Q3 release notes', 'Li Wei', 'In progress'], ['Migrate auth service', 'Sofia Rossi', 'Done'], ['Design empty states', 'Diego Fernández', 'In progress'], ];
readonly gridSettings: GridSettings = { colHeaders: ['Task', 'Assignee', 'Status'], rowHeaders: true, height: 'auto', beforeChange: (changes: (Handsontable.CellChange | null)[], source: Handsontable.ChangeSource | string) => { if (source === REMOTE_SOURCE || !changes) { return; }
changes.forEach((change) => { if (!change) { return; }
const [row, column, , newValue] = change;
// send the local edit to your collaboration backend here console.log('Broadcasting local edit:', row, column, newValue); }); }, };
ngOnInit(): void { // simulate an update coming from another collaborator - start editing the Status // cell in the first row before the timeout fires to see the update wait for you this.timeoutId = setTimeout(() => this.applyRemoteChange(0, 2, 'Done'), 3000); }
ngOnDestroy(): void { clearTimeout(this.timeoutId); }
applyRemoteChange(row: number, column: number, value: string): void { const hot = this.hotTable?.hotInstance; const editor = hot?.getActiveEditor(); const editingSameCell = editor?.isOpened() && editor.row === row && editor.col === column;
if (editingSameCell) { // don't overwrite a cell the local user is editing right now - // check again shortly, and apply the change once the local edit finishes this.timeoutId = setTimeout(() => this.applyRemoteChange(row, column, value), 300);
return; }
hot?.setDataAtCell(row, column, value, REMOTE_SOURCE); this.statusText = 'A collaborator marked "Update onboarding flow" as Done.'; }}/* 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';
// register Handsontable's modulesregisterAllModules();
export const appConfig: ApplicationConfig = { providers: [ provideZoneChangeDetection({ eventCoalescing: true }), { provide: HOT_GLOBAL_CONFIG, useValue: { license: NON_COMMERCIAL_LICENSE } as HotGlobalConfig, }, ],};/* end-file */<div> <example1-collaboration></example1-collaboration></div>A simulated remote update to the Status column of the first row arrives 3 seconds after the example loads. Start editing that cell before then, and the remote change waits until you finish.
Guard and replay structural changes
Row and column changes need the same two-way handling as cell data. Use beforeCreateRow, beforeRemoveRow, beforeCreateCol, and beforeRemoveCol to read local structural changes and forward them to other collaborators. Each hook receives the source argument you passed in, so you can skip re-broadcasting a change that a collaborator sent you.
To apply a structural change from another collaborator, call alter() with the same source value:
const configurationOptions = { beforeCreateRow(index, amount, source) { if (source === 'remotePeer') { // let a structural change from another collaborator through return; }
// send the local structural change to your collaboration backend here }, beforeRemoveRow(index, amount, physicalRows, source) { if (source === 'remotePeer') { return; }
// send the local structural change to your collaboration backend here },};
// apply a structural change received from another collaboratorhot.alter('insert_row_below', 2, 1, 'remotePeer');Sync cell metadata, comments, merged cells, and borders
Collaborators also share state that isn’t part of the cell value. setCellMeta() writes arbitrary metadata (for example, a className that flags a cell as locked by another user), but it doesn’t repaint the grid on its own - call render() afterward.
The Comments, MergeCells, and CustomBorders plugins expose methods you can call with data received from another collaborator:
// share arbitrary metadata, then repaint to apply ithot.setCellMeta(1, 2, 'className', 'is-locked-by-remote-user');hot.render();
// mirror a comment left by another collaboratorhot.getPlugin('comments').setCommentAtCell(1, 2, 'Reviewed - looks good.');
// mirror a merged area created by another collaboratorhot.getPlugin('mergeCells').merge(0, 0, 0, 1);
// mirror a border added by another collaboratorhot.getPlugin('customBorders').setBorders([[1, 1, 1, 1]], { start: { width: 2, color: '#2563eb' } });The Comments plugin stores comments as cell meta, so setCommentAtCell() and removeCommentAtCell() also trigger beforeSetCellMeta and afterSetCellMeta - handle comment changes there if you’re already syncing cell meta through those hooks.
Result
After completing this guide, your grid detects when a remote update would overwrite a cell the local user is actively editing, broadcasts local data and structural changes to other collaborators, and mirrors remote changes to cell data, rows, columns, comments, merged cells, and borders.
Related articles
Related API reference
Core methods
Hooks
Plugins