Skip to content

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.

TypeScript
/* 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 again
const 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 modules
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-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 collaborator
hot.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 it
hot.setCellMeta(1, 2, 'className', 'is-locked-by-remote-user');
hot.render();
// mirror a comment left by another collaborator
hot.getPlugin('comments').setCommentAtCell(1, 2, 'Reviewed - looks good.');
// mirror a merged area created by another collaborator
hot.getPlugin('mergeCells').merge(0, 0, 0, 1);
// mirror a border added by another collaborator
hot.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.

Core methods

Hooks

Plugins