Skip to content

Real-time cell updates via WebSocket

In this tutorial, you will connect Handsontable to a WebSocket and update individual cells in real time. You will learn how to use setDataAtCell to apply streaming updates without re-rendering the entire grid.

JavaScript
import Handsontable from 'handsontable/base';
import { registerAllModules } from 'handsontable/registry';
registerAllModules();
/* start:skip-in-preview */
const stockData = [
{ symbol: 'AAPL', company: 'Apple Inc.', price: 189.25, change: 1.45, volume: 52341200, marketCap: '2.94T' },
{ symbol: 'MSFT', company: 'Microsoft Corp.', price: 415.80, change: -0.72, volume: 18920400, marketCap: '3.08T' },
{ symbol: 'GOOG', company: 'Alphabet Inc.', price: 175.40, change: 2.13, volume: 21780000, marketCap: '2.19T' },
{ symbol: 'AMZN', company: 'Amazon.com Inc.', price: 198.60, change: -1.30, volume: 34560000, marketCap: '2.09T' },
{ symbol: 'NVDA', company: 'NVIDIA Corp.', price: 875.35, change: 14.20, volume: 41230000, marketCap: '2.15T' },
{ symbol: 'META', company: 'Meta Platforms Inc.', price: 512.90, change: 3.55, volume: 15670000, marketCap: '1.30T' },
{ symbol: 'TSLA', company: 'Tesla Inc.', price: 248.75, change: -5.60, volume: 98120000, marketCap: '793B' },
{ symbol: 'BRK', company: 'Berkshire Hathaway', price: 3890.00, change: 12.00, volume: 3450000, marketCap: '876B' },
];
/* end:skip-in-preview */
const container = document.querySelector('#example1');
const hot = new Handsontable(container, {
data: stockData,
colHeaders: ['Symbol', 'Company', 'Price ($)', 'Change ($)', 'Volume', 'Market Cap'],
columns: [
{ data: 'symbol', readOnly: true },
{ data: 'company', readOnly: true, width: 180 },
{ data: 'price', type: 'numeric', numericFormat: { pattern: '0,0.00' } },
{ data: 'change', type: 'numeric', numericFormat: { pattern: '0,0.00' } },
{ data: 'volume', type: 'numeric', numericFormat: { pattern: '0,0' } },
{ data: 'marketCap', readOnly: true },
],
rowHeaders: true,
height: 'auto',
width: '100%',
stretchH: 'all',
licenseKey: 'non-commercial-and-evaluation',
});
// Flash a cell to draw attention when its value changes.
hot.addHook('afterChange', (changes, source) => {
if (source !== 'external' || !changes) {
return;
}
changes.forEach(([row]) => {
// Columns 2 (price) and 3 (change) are the ones updated by the simulation.
[2, 3].forEach((col) => {
const td = hot.getCell(row, col);
if (td) {
td.classList.remove('ht-cell-flash');
// Trigger reflow so removing then adding the class restarts the animation.
void td.offsetWidth;
td.classList.add('ht-cell-flash');
td.addEventListener('animationend', () => td.classList.remove('ht-cell-flash'), { once: true });
}
});
});
});
// Simulate a WebSocket feed with setInterval.
// In production, replace this with a real WebSocket connection:
// const ws = new WebSocket('wss://your-feed.example.com');
// ws.onmessage = (event) => { const msg = JSON.parse(event.data); applyUpdate(msg); };
const intervalId = setInterval(() => {
const row = Math.floor(Math.random() * stockData.length);
const basePrice = stockData[row].price;
const newPrice = parseFloat((basePrice + (Math.random() - 0.5) * 4).toFixed(2));
const newChange = parseFloat((newPrice - basePrice + stockData[row].change).toFixed(2));
// Pass 'external' as the source so afterChange can distinguish this update
// from direct user edits. This avoids triggering undo history for feed data.
hot.setDataAtRowProp(row, 'price', newPrice, 'external');
hot.setDataAtRowProp(row, 'change', newChange, 'external');
}, 1500);
// Clean up the interval when the user leaves the page to prevent stale updates.
window.addEventListener('beforeunload', () => {
clearInterval(intervalId);
// If using a real WebSocket, close it here:
// ws.close();
});
CSS
/* Flash animation applied to cells updated by an external data source (e.g., a WebSocket feed). */
@keyframes cellFlash {
0% {
background-color: rgba(255, 220, 0, 0.75);
}
100% {
background-color: transparent;
}
}
.ht-cell-flash {
animation: cellFlash 0.8s ease-out;
}

Overview

Difficulty: Intermediate Time: ~15 minutes

This recipe shows how to push individual cell updates into Handsontable from an external data source — such as a WebSocket feed — without re-rendering the entire grid on every event. The technique is applicable to any streaming data: stock prices, IoT sensors, live sports scores, and so on.

What You’ll Build

A live stock-price grid that:

  • Receives price updates from a simulated WebSocket feed every 1.5 seconds.
  • Applies each update to a single cell using hot.setDataAtRowProp().
  • Tags every programmatic update with the source string 'external' so user edits remain distinguishable.
  • Briefly flashes updated cells with a yellow highlight using a CSS animation.
  • Cleans up the interval (or WebSocket connection) when the page is unloaded.

Before you begin

You need a working Handsontable installation. If you are starting from scratch, follow the Quick start guide first.

No additional libraries are required for this recipe.

  1. Set up the grid with financial data

    Start by creating a Handsontable instance with stock-market data. The grid has six columns: Symbol, Company, Price, Change, Volume, and Market Cap.

    import Handsontable from 'handsontable/base';
    import { registerAllModules } from 'handsontable/registry';
    registerAllModules();
    const stockData = [
    { symbol: 'AAPL', company: 'Apple Inc.', price: 189.25, change: 1.45, volume: 52341200, marketCap: '2.94T' },
    // ... more rows
    ];
    const hot = new Handsontable(document.querySelector('#example1'), {
    data: stockData,
    colHeaders: ['Symbol', 'Company', 'Price ($)', 'Change ($)', 'Volume', 'Market Cap'],
    columns: [
    { data: 'symbol', readOnly: true },
    { data: 'company', readOnly: true, width: 180 },
    { data: 'price', type: 'numeric', numericFormat: { pattern: '0,0.00' } },
    { data: 'change', type: 'numeric', numericFormat: { pattern: '0,0.00' } },
    { data: 'volume', type: 'numeric', numericFormat: { pattern: '0,0' } },
    { data: 'marketCap', readOnly: true },
    ],
    rowHeaders: true,
    height: 'auto',
    stretchH: 'all',
    licenseKey: 'non-commercial-and-evaluation',
    });

    What’s happening:

    • Symbol, Company, and Market Cap are marked readOnly: true — the feed never updates them.
    • Price and Change use type: 'numeric' with number formatting so values render as 189.25 rather than a raw float.
    • The data option points to an array of plain objects (stockData). Handsontable holds a reference to this array, so changing the array directly would bypass the rendering pipeline — always use setDataAtRowProp or setDataAtCell instead.
  2. Flash cells when data arrives

    Register an afterChange hook before starting the feed. The hook receives every change together with the source string that identifies who made the change.

    hot.addHook('afterChange', (changes, source) => {
    if (source !== 'external' || !changes) {
    return;
    }
    changes.forEach(([row]) => {
    [2, 3].forEach((col) => {
    const td = hot.getCell(row, col);
    if (td) {
    td.classList.remove('ht-cell-flash');
    void td.offsetWidth; // Force reflow to restart the animation.
    td.classList.add('ht-cell-flash');
    td.addEventListener('animationend', () => td.classList.remove('ht-cell-flash'), { once: true });
    }
    });
    });
    });

    What’s happening:

    • The guard source !== 'external' skips all changes that the user makes themselves. Without this guard, every keystroke in a cell would also trigger the flash animation.
    • hot.getCell(row, col) returns the live <td> DOM element. It returns null when the row is outside the current viewport (virtual rendering), so the if (td) check is required.
    • Removing the class, forcing a reflow (void td.offsetWidth), and then re-adding it restarts the CSS animation even if the cell was already flashing from a previous update.
    • The { once: true } event listener option automatically removes the listener after the animation ends, preventing memory leaks when cells update repeatedly.

    Why use afterChange instead of updating the DOM directly?

    The hook fires after Handsontable has already rendered the new value to the DOM. Reading td from the hook is safe because the cell is guaranteed to be up to date at that point.

  3. Add the flash CSS animation

    Create a CSS file with a @keyframes rule that fades a yellow background to transparent.

    @keyframes cellFlash {
    0% { background-color: rgba(255, 220, 0, 0.75); }
    100% { background-color: transparent; }
    }
    .ht-cell-flash {
    animation: cellFlash 0.8s ease-out;
    }

    What’s happening:

    • The animation starts at a semi-transparent yellow (rgba(255, 220, 0, 0.75)) and eases to transparent over 0.8 seconds.
    • ease-out means the flash fades faster at the start and slows toward the end, which feels natural for a “data just arrived” signal.
    • The class name ht-cell-flash is scoped narrowly — it does not conflict with any built-in Handsontable CSS.
  4. Simulate the WebSocket feed with setInterval

    In a production app you would open a real WebSocket. For this recipe, a setInterval sends a random update every 1.5 seconds. The simulation is clearly a stand-in — replace the setInterval block with a WebSocket when you connect to a real feed.

    // Simulation -- replace with a real WebSocket in production:
    // const ws = new WebSocket('wss://your-feed.example.com');
    // ws.onmessage = (event) => { const msg = JSON.parse(event.data); applyUpdate(msg); };
    const intervalId = setInterval(() => {
    const row = Math.floor(Math.random() * stockData.length);
    const basePrice = stockData[row].price;
    const newPrice = parseFloat((basePrice + (Math.random() - 0.5) * 4).toFixed(2));
    const newChange = parseFloat((newPrice - basePrice + stockData[row].change).toFixed(2));
    hot.setDataAtRowProp(row, 'price', newPrice, 'external');
    hot.setDataAtRowProp(row, 'change', newChange, 'external');
    }, 1500);

    What’s happening:

    • A random row index is picked on each tick.
    • A new price is calculated by adding a small random delta (between -2 and +2) to the current price.
    • hot.setDataAtRowProp(row, 'price', newPrice, 'external') updates the cell at row in the price column.

    Why setDataAtRowProp instead of loadData?

    loadData replaces the entire data set and triggers a full re-render of every cell. For streaming data with dozens of updates per second, a full re-render on every tick would degrade performance noticeably and would reset the scroll position. setDataAtRowProp (and its sibling setDataAtCell) renders only the affected cell, leaving everything else untouched.

    Why pass 'external' as the fourth argument?

    Handsontable propagates the source string to every hook that fires as a result of the change — including afterChange, beforeChange, and the undo/redo stack. Tagging programmatic updates as 'external' lets you:

    • Skip the flash animation for user edits (the guard in Step 2).
    • Exclude feed updates from the undo history (you can filter the source in beforeChange if needed).
    • Identify the origin of a change when logging or debugging.
  5. Clean up on page unload

    Always release resources when the user navigates away. For a setInterval, call clearInterval. For a real WebSocket, call ws.close().

    window.addEventListener('beforeunload', () => {
    clearInterval(intervalId);
    // ws.close(); // Uncomment when using a real WebSocket.
    });

    What’s happening:

    • The beforeunload event fires just before the browser unloads the page.
    • clearInterval stops the simulation so no further updates are attempted after the page is gone.
    • Without this cleanup, the interval callback could attempt to call hot.setDataAtRowProp on a destroyed Handsontable instance.

How It Works - Complete Flow

  1. Page loads - Handsontable renders the initial stock data.
  2. afterChange hook registered - any future change tagged 'external' will trigger the flash.
  3. setInterval starts - every 1.5 seconds a random row is selected.
  4. setDataAtRowProp called - Handsontable updates the cell value and re-renders only that cell.
  5. afterChange fires with source 'external' - the hook locates the <td> element and adds ht-cell-flash.
  6. CSS animation plays - the cell flashes yellow and fades to transparent over 0.8 seconds.
  7. animationend fires - the ht-cell-flash class is removed, ready for the next update.
  8. User navigates away - beforeunload clears the interval, preventing stale callbacks.

What you learned

  • Use setDataAtCell or setDataAtRowProp to update individual cells without a full re-render.
  • Pass a custom source string ('external') to distinguish programmatic updates from user edits in afterChange and other hooks.
  • Retrieve a live <td> element with hot.getCell(row, col) and apply a CSS animation class to highlight the change.
  • Clean up intervals and WebSocket connections in a beforeunload listener.

Next steps

  • Connect to a real WebSocket by replacing setInterval with new WebSocket(url) and parsing JSON messages in ws.onmessage.
  • Add a beforeChange hook to discard stale feed updates if the user has started editing the same cell.
  • Use the afterChange hook to log all external updates to a separate audit trail.
  • Combine this technique with Conditional cell formatting to color cells red or green based on whether the price rose or fell.