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.
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();});/* 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.
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 as189.25rather than a raw float. - The
dataoption 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 usesetDataAtRowProporsetDataAtCellinstead.
- Symbol, Company, and Market Cap are marked
Flash cells when data arrives
Register an
afterChangehook before starting the feed. The hook receives every change together with thesourcestring 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 returnsnullwhen the row is outside the current viewport (virtual rendering), so theif (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
afterChangeinstead of updating the DOM directly?The hook fires after Handsontable has already rendered the new value to the DOM. Reading
tdfrom the hook is safe because the cell is guaranteed to be up to date at that point.- The guard
Add the flash CSS animation
Create a CSS file with a
@keyframesrule 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-outmeans 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-flashis scoped narrowly — it does not conflict with any built-in Handsontable CSS.
- The animation starts at a semi-transparent yellow (
Simulate the WebSocket feed with setInterval
In a production app you would open a real WebSocket. For this recipe, a
setIntervalsends a random update every 1.5 seconds. The simulation is clearly a stand-in — replace thesetIntervalblock 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 atrowin thepricecolumn.
Why
setDataAtRowPropinstead ofloadData?loadDatareplaces 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 siblingsetDataAtCell) 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
beforeChangeif needed). - Identify the origin of a change when logging or debugging.
Clean up on page unload
Always release resources when the user navigates away. For a
setInterval, callclearInterval. For a real WebSocket, callws.close().window.addEventListener('beforeunload', () => {clearInterval(intervalId);// ws.close(); // Uncomment when using a real WebSocket.});What’s happening:
- The
beforeunloadevent fires just before the browser unloads the page. clearIntervalstops the simulation so no further updates are attempted after the page is gone.- Without this cleanup, the interval callback could attempt to call
hot.setDataAtRowPropon a destroyed Handsontable instance.
- The
How It Works - Complete Flow
- Page loads - Handsontable renders the initial stock data.
afterChangehook registered - any future change tagged'external'will trigger the flash.setIntervalstarts - every 1.5 seconds a random row is selected.setDataAtRowPropcalled - Handsontable updates the cell value and re-renders only that cell.afterChangefires with source'external'- the hook locates the<td>element and addsht-cell-flash.- CSS animation plays - the cell flashes yellow and fades to transparent over 0.8 seconds.
animationendfires - theht-cell-flashclass is removed, ready for the next update.- User navigates away -
beforeunloadclears the interval, preventing stale callbacks.
What you learned
- Use
setDataAtCellorsetDataAtRowPropto update individual cells without a full re-render. - Pass a custom source string (
'external') to distinguish programmatic updates from user edits inafterChangeand other hooks. - Retrieve a live
<td>element withhot.getCell(row, col)and apply a CSS animation class to highlight the change. - Clean up intervals and WebSocket connections in a
beforeunloadlistener.
Next steps
- Connect to a real WebSocket by replacing
setIntervalwithnew WebSocket(url)and parsing JSON messages inws.onmessage. - Add a
beforeChangehook to discard stale feed updates if the user has started editing the same cell. - Use the
afterChangehook 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.