Cache the output of an expensive cell renderer
In this tutorial, you will make an expensive custom renderer cheap by computing its output once per data record and reusing it on every later render. You will learn why a cache keyed by the td element does not work, how to key it by the record instead, and how to invalidate it when the data changes.
Renderer calls: 0 | Computations: 0
import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { baseRenderer, textRenderer } from 'handsontable/renderers';
registerAllModules();
/* start:skip-in-preview */const CUSTOMERS = ['Acme Corp', 'Vertex Industries', 'Harbor Goods', 'Alpine Supply Co.', 'Northwind Traders', 'Lumen Retail'];const REGIONS = ['North', 'South', 'East', 'West'];
// Twelve monthly sales figures per customer, generated deterministically so every load looks the same.function createRows(count) { const rows = [];
for (let index = 0; index < count; index += 1) { const sales = []; let seed = (index + 1) * 7919;
for (let month = 0; month < 12; month += 1) { seed = (seed * 48271) % 2147483647; sales.push(1000 + (seed % 9000)); }
rows.push({ id: 1001 + index, customer: CUSTOMERS[index % CUSTOMERS.length], region: REGIONS[index % REGIONS.length], sales, }); }
return rows;}/* end:skip-in-preview */
const data = createRows(300);const stats = { rendererCalls: 0, computations: 0 };
// The slow part of the renderer: a text sparkline plus the change from the first to the last month.// It is a pure function -- the same input always gives the same output -- so its result can be cached.function buildTrend(sales) { const glyphs = '▁▂▃▄▅▆▇█'; const min = Math.min(...sales); const max = Math.max(...sales); const range = max - min || 1; const bars = sales .map((value) => glyphs[Math.round(((value - min) / range) * (glyphs.length - 1))]) .join(''); const change = Math.round(((sales[sales.length - 1] - sales[0]) / sales[0]) * 100);
return `${bars} ${change >= 0 ? '+' : ''}${change}%`;}
// One cache entry per data record, never per `td`: the grid reuses a `td` for a different record// as you scroll. A WeakMap releases the entry together with the record. Each entry remembers the// input it was computed from, so a replaced array is computed again.const trendCache = new WeakMap();
const trendRenderer = (instance, td, row, col, prop, value, cellProperties) => { baseRenderer(instance, td, row, col, prop, value, cellProperties); stats.rendererCalls += 1;
// `row` is a visual index, and `data` is in physical order, so translate before the lookup. // Read the record from your own array: `getSourceDataAtRow()` returns a copy of the row on // every call, which can never be a WeakMap key. Keep this closure and the grid's data source // the same array -- after `updateData()`, point it at the new one. const record = data[instance.toPhysicalRow(row)]; const sales = value;
// A row without a record (a `minSpareRows` row, or one mid-`alter()`) and an empty cell both // reach the renderer. Neither can be cached: `undefined` is not a valid WeakMap key. if (!record || !Array.isArray(sales) || sales.length === 0) { textRenderer(instance, td, row, col, prop, '—', cellProperties);
return; }
let entry = trendCache.get(record);
if (!entry || entry.input !== sales) { stats.computations += 1; entry = { input: sales, output: buildTrend(sales) }; trendCache.set(record, entry); }
// Write through the built-in text renderer rather than setting `td.textContent`. On a row with // an exact height the engine keeps the cell's content inside a wrapper, and this writes into // that wrapper instead of replacing it. textRenderer(instance, td, row, col, prop, entry.output, cellProperties);};
const container = document.querySelector('#example1');const statsElement = document.querySelector('#render-stats');
const hot = new Handsontable(container, { data, colHeaders: ['ID', 'Customer', 'Region', 'Trend (12 months)'], columns: [ { data: 'id', type: 'numeric', width: 70, readOnly: true }, { data: 'customer', type: 'text', width: 170 }, { data: 'region', type: 'text', width: 90 }, { data: 'sales', renderer: trendRenderer, width: 190, readOnly: true }, ], rowHeaders: true, height: 320, width: '100%', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation', // Runs once per draw, scroll draws included, after every cell renderer. Keep DOM writes like // this one out of the renderer. afterViewRender() { statsElement.textContent = `Renderer calls: ${stats.rendererCalls} | Computations: ${stats.computations}`; },});
document.querySelector('#render-again-btn').addEventListener('click', () => { hot.render();});
document.querySelector('#update-row-btn').addEventListener('click', () => { // Bring the first row into view, so its renderer runs and the counter shows the single // recomputation. A cell outside the rendered band is not painted at all. hot.scrollViewportTo({ row: 0 });
// Replace the array instead of changing it in place: the renderer compares inputs by identity. const nextSales = data[0].sales.map((value, month) => Math.round(value * (1 + month / 10)));
hot.setSourceDataAtCell(0, 'sales', nextSales);});import Handsontable from 'handsontable/base';import { registerAllModules } from 'handsontable/registry';import { BaseRenderer, baseRenderer, textRenderer } from 'handsontable/renderers';
registerAllModules();
type OrderRow = { id: number; customer: string; region: string; sales: number[];};
/* start:skip-in-preview */const CUSTOMERS = ['Acme Corp', 'Vertex Industries', 'Harbor Goods', 'Alpine Supply Co.', 'Northwind Traders', 'Lumen Retail'];const REGIONS = ['North', 'South', 'East', 'West'];
// Twelve monthly sales figures per customer, generated deterministically so every load looks the same.function createRows(count: number): OrderRow[] { const rows: OrderRow[] = [];
for (let index = 0; index < count; index += 1) { const sales: number[] = []; let seed = (index + 1) * 7919;
for (let month = 0; month < 12; month += 1) { seed = (seed * 48271) % 2147483647; sales.push(1000 + (seed % 9000)); }
rows.push({ id: 1001 + index, customer: CUSTOMERS[index % CUSTOMERS.length], region: REGIONS[index % REGIONS.length], sales, }); }
return rows;}/* end:skip-in-preview */
const data = createRows(300);const stats = { rendererCalls: 0, computations: 0 };
// The slow part of the renderer: a text sparkline plus the change from the first to the last month.// It is a pure function -- the same input always gives the same output -- so its result can be cached.function buildTrend(sales: number[]): string { const glyphs = '▁▂▃▄▅▆▇█'; const min = Math.min(...sales); const max = Math.max(...sales); const range = max - min || 1; const bars = sales .map((value) => glyphs[Math.round(((value - min) / range) * (glyphs.length - 1))]) .join(''); const change = Math.round(((sales[sales.length - 1] - sales[0]) / sales[0]) * 100);
return `${bars} ${change >= 0 ? '+' : ''}${change}%`;}
// One cache entry per data record, never per `td`: the grid reuses a `td` for a different record// as you scroll. A WeakMap releases the entry together with the record. Each entry remembers the// input it was computed from, so a replaced array is computed again.const trendCache = new WeakMap<OrderRow, { input: number[]; output: string }>();
const trendRenderer: BaseRenderer = (instance, td, row, col, prop, value, cellProperties) => { baseRenderer(instance, td, row, col, prop, value, cellProperties); stats.rendererCalls += 1;
// `row` is a visual index, and `data` is in physical order, so translate before the lookup. // Read the record from your own array: `getSourceDataAtRow()` returns a copy of the row on // every call, which can never be a WeakMap key. Keep this closure and the grid's data source // the same array -- after `updateData()`, point it at the new one. const record = data[instance.toPhysicalRow(row)]; const sales = value;
// A row without a record (a `minSpareRows` row, or one mid-`alter()`) and an empty cell both // reach the renderer. Neither can be cached: `undefined` is not a valid WeakMap key. if (!record || !Array.isArray(sales) || sales.length === 0) { textRenderer(instance, td, row, col, prop, '—', cellProperties);
return; }
let entry = trendCache.get(record);
if (!entry || entry.input !== sales) { stats.computations += 1; entry = { input: sales, output: buildTrend(sales) }; trendCache.set(record, entry); }
// Write through the built-in text renderer rather than setting `td.textContent`. On a row with // an exact height the engine keeps the cell's content inside a wrapper, and this writes into // that wrapper instead of replacing it. textRenderer(instance, td, row, col, prop, entry.output, cellProperties);};
const container = document.querySelector<HTMLElement>('#example1')!;const statsElement = document.querySelector<HTMLElement>('#render-stats')!;
const hot = new Handsontable(container, { data, colHeaders: ['ID', 'Customer', 'Region', 'Trend (12 months)'], columns: [ { data: 'id', type: 'numeric', width: 70, readOnly: true }, { data: 'customer', type: 'text', width: 170 }, { data: 'region', type: 'text', width: 90 }, { data: 'sales', renderer: trendRenderer, width: 190, readOnly: true }, ], rowHeaders: true, height: 320, width: '100%', autoWrapRow: true, licenseKey: 'non-commercial-and-evaluation', // Runs once per draw, scroll draws included, after every cell renderer. Keep DOM writes like // this one out of the renderer. afterViewRender() { statsElement.textContent = `Renderer calls: ${stats.rendererCalls} | Computations: ${stats.computations}`; },});
document.querySelector('#render-again-btn')!.addEventListener('click', () => { hot.render();});
document.querySelector('#update-row-btn')!.addEventListener('click', () => { // Bring the first row into view, so its renderer runs and the counter shows the single // recomputation. A cell outside the rendered band is not painted at all. hot.scrollViewportTo({ row: 0 });
// Replace the array instead of changing it in place: the renderer compares inputs by identity. const nextSales = data[0].sales.map((value, month) => Math.round(value * (1 + month / 10)));
hot.setSourceDataAtCell(0, 'sales', nextSales);});<div class="example-controls-container"> <div class="controls"> <button id="render-again-btn" type="button">Render again</button> <button id="update-row-btn" type="button">Update the first row</button> </div></div><div id="example1"></div><p id="render-stats" style="margin: 8px 0 0; font-size: 13px; color: #666;">Renderer calls: 0 | Computations: 0</p>Overview
Difficulty: Intermediate Time: ~15 minutes
Handsontable calls a cell renderer for every rendered cell on every render — after each scroll, sort, edit, or render() call. A renderer that computes something slow from the cell’s data — a chart, a parsed document, a formatted summary — repeats that work for the same record again and again. A cache fixes that, but only if you pick the right key.
What you’ll build
An order grid with 300 customers, where the Trend column turns twelve monthly sales figures into a text sparkline and a percent change. The grid:
- Counts renderer calls and computations, and shows both counters under the grid.
- Computes each record’s trend once, however often the grid renders.
- Recomputes a record only when its
salesarray is replaced. - Has a Render again button, which repaints the grid without a single new computation, and an Update the first row button, which triggers exactly one.
Before you begin
- You need a working Handsontable installation. See the Getting started guide.
- Read Understanding rendering to know what a render covers and why direct DOM changes disappear.
- You should be familiar with custom renderer functions.
Step 1 — Write the slow computation as a plain function
Keep the expensive work in a function that takes the data in and returns the output, with nothing else in between:
function buildTrend(sales) { const glyphs = '▁▂▃▄▅▆▇█'; const min = Math.min(...sales); const max = Math.max(...sales); const range = max - min || 1; const bars = sales .map((value) => glyphs[Math.round(((value - min) / range) * (glyphs.length - 1))]) .join(''); const change = Math.round(((sales[sales.length - 1] - sales[0]) / sales[0]) * 100);
return `${bars} ${change >= 0 ? '+' : ''}${change}%`;}What’s happening: buildTrend() is a pure function. The same input always gives the same output, and it reads nothing from the grid or the DOM. That is what makes its result safe to cache. In this recipe the computation is small so the demo stays responsive; in your app it is whatever costs the most in your renderer.
Step 2 — Count what the renderer does
Two counters make the effect of the cache visible:
const stats = { rendererCalls: 0, computations: 0 };The renderer increments rendererCalls on every call and computations only when it runs buildTrend(). The afterViewRender hook writes both counters under the grid:
afterViewRender() { statsElement.textContent = `Renderer calls: ${stats.rendererCalls} | Computations: ${stats.computations}`;}Why afterViewRender, and why not write the counters from inside the renderer? afterViewRender runs once per draw, after every cell renderer has finished, and it also runs for the draws that scrolling triggers — which afterRender does not report. A renderer runs once per cell, so a DOM write there would run hundreds of times per draw.
Step 3 — Key the cache by the data record, not by the td
const trendCache = new WeakMap();The cache is a WeakMap whose keys are the row objects of your data source. It holds one entry per record, and each entry remembers the input it was computed from:
{ input: sales, output: buildTrend(sales) }Why not key the cache by the td element? Handsontable renders only the visible cells plus a small buffer, and it keeps a fixed set of td elements for them. A vertical scroll keeps a row’s td elements with the row while the row stays rendered, but nothing else does: a horizontal scroll, a row that leaves the rendered area and comes back, and any scroll in a grid that scrolls with the page all hand the record a different td. A cache keyed by the td — a WeakMap of td to output, or a property set on the element — therefore misses on every one of those, and the expensive work runs again for cells that were computed a moment ago. The record is the thing that stays the same between renders, so it is the key.
Why a WeakMap? When a record leaves the data set and nothing else references it, its cache entry is released with it. A plain Map would keep every record alive.
Step 4 — Use the cache in the renderer
const trendRenderer = (instance, td, row, col, prop, value, cellProperties) => { baseRenderer(instance, td, row, col, prop, value, cellProperties); stats.rendererCalls += 1;
const record = data[instance.toPhysicalRow(row)]; const sales = value;
if (!record || !Array.isArray(sales) || sales.length === 0) { textRenderer(instance, td, row, col, prop, '—', cellProperties);
return; }
let entry = trendCache.get(record);
if (!entry || entry.input !== sales) { stats.computations += 1; entry = { input: sales, output: buildTrend(sales) }; trendCache.set(record, entry); }
textRenderer(instance, td, row, col, prop, entry.output, cellProperties);};What’s happening:
baseRenderer()applies the standard cell classes, so the cell still reacts toreadOnly, validation, and the rest of the cell meta.rowis a visual index, and yourdataarray is in physical order, sotoPhysicalRow()translates it before the lookup. Without the translation, sorting the grid would pair a cell with the wrong record. Read the record from your own array:getSourceDataAtRow()returns a copy of the row on every call, so aWeakMapkeyed by its result never hits.- The guard covers the two rows that have no record to key on: a spare row added by
minSpareRows, and a row the renderer sees whilealter()is still running.undefinedis not a validWeakMapkey, so caching one throws and takes the whole draw down with it. An empty cell is caught by the same check. valueis the cell’s value — here, thesalesarray, handed to the renderer as the same array that sits in the record, not a copy. If the record has an entry and the entry was computed from this same array, the renderer reuses the output. Otherwise it computes, stores, and moves on.- The renderer always writes the cell, on the cached path as well as the computed one, because the grid resets a
tdbefore it runs a renderer. Only what the renderer writes back survives. - The write goes through the built-in
textRendererrather thantd.textContent = …. On a row whose height is exact, the engine keeps the cell’s content inside a wrapper it reuses between draws.textContentwould replace that wrapper, and the engine would build it again on the next draw.textRendererwrites into it instead.
The renderer closes over data, so keep that binding current. This renderer indexes the same array it gave the grid. If you later swap the data set with updateData() or loadData(), the grid holds the new array while the closure still points at the old one, and every lookup returns a stale record or undefined — with no error. Point the closure at the new array in the same step, or key the cache by the cell value instead, which needs no closure at all (see Variations).
Attach the renderer to the column:
columns: [ { data: 'id', type: 'numeric', width: 70, readOnly: true }, { data: 'customer', type: 'text', width: 170 }, { data: 'region', type: 'text', width: 90 }, { data: 'sales', renderer: trendRenderer, width: 190, readOnly: true },],Step 5 — Invalidate when the data changes
The entry stores the input it was computed from, so the cache invalidates itself when the input is replaced. The Update the first row button replaces the first record’s sales array:
const nextSales = data[0].sales.map((value, month) => Math.round(value * (1 + month / 10)));
hot.setSourceDataAtCell(0, 'sales', nextSales);The button first calls scrollViewportTo({ row: 0 }), because a cell outside the rendered band is not painted at all — without it, a reader who has scrolled away sees no counter change. setSourceDataAtCell() then writes the new array into the record and renders the grid. On that render, entry.input !== sales is true for that one record, and computations grows by one. Every other cell hits the cache.
Replace, do not mutate. The renderer compares inputs by identity. A change made in place, such as data[0].sales[11] = 5000, leaves the array’s identity unchanged, so the cached output stays stale. Either replace the array, as above, or delete the entry by hand before you render:
trendCache.delete(data[0]);hot.render();The same rule applies to a primitive value: the input !== value check compares two strings or two numbers by value, so an edited cell is recomputed on its own.
How it works — complete flow
- First render. The renderer runs for every rendered cell of the Trend column, and
computationsequals the number of rendered rows: every record is new to the cache. - Scroll down. The renderer runs again for every cell in the new viewport, but
computationsgrows only by the rows that were not rendered before. - Scroll back up.
rendererCallskeeps growing;computationsdoes not move. Every record on screen is already in the cache. - Render again.
rendererCallsgrows by the number of rendered cells;computationsstays where it was. - Update the first row. The grid scrolls back to the top and
computationsgrows by exactly one.
Variations
-
The value is an object. When the cell’s value is itself an object or an array, you can key the
WeakMapby the value and skip the record lookup: the grid hands the renderer the value itself, not a copy. A replaced value is then a new key, and the old entry is released with the old value. -
The renderer mounts a component. If your renderer mounts a framework component or builds a large DOM subtree, let the
tdown the container. On each call, reuse the container the cell already holds and update it with the record the cell now shows; create one only for a cell that has none. A vertical scroll keeps a row’stdelements with the row, so the containers of the rows that stay rendered are not touched at all, and a row that enters takes over the container a leaving row left behind. The Angular wrapper’sHotCellRendererComponentrenders its component cells this way: one component pertd, its inputs updated on every call. Three rules make that safe:- Update in place, never rebuild. Set the component’s inputs, or rewrite the subtree’s text and attributes, from the record the renderer receives. Tearing the container down and mounting a new one on every call is the cost you are trying to avoid.
- Key by the
td, not by the coordinates. A container keyed by row and column has to be moved between cells as the band moves, and a cell in a frozen column is drawn twice, once in the master table and once in the overlay clone, so one container cannot serve both. A container owned by thetdhas one parent for its whole life. - Make the container the cell’s only child. Clear the cell before you append. A container placed next to whatever the cell already holds leaves the cell with two children, and the engine’s own content wrapper is then rebuilt on every draw.
Hold the containers in a
WeakMapkeyed by thetd, so a cell element the grid drops releases its container with it. -
Skip the cells that did not change. The
renderModeoption set to'onChange'lets a render skip cells whose data, meta, and position did not change since their last paint. A vertical scroll then paints only the rows that enter the rendered area, plus the cells of merged blocks; a horizontal scroll, and any scroll in a grid that scrolls with the page, still repaint the rendered cells, so the cache in this recipe is still what saves the computation there. The two combine well: the option removes the renderer call, the cache removes the computation.
What you learned
- A renderer runs for every rendered cell on every render, so a slow computation inside it runs far more often than the data changes.
- A
tdfollows its row only through a vertical scroll; a horizontal scroll and a row that comes back into view give the record anothertd, so atdis the wrong cache key for a computed value. The data record, or the cell coordinates, is the right one. - A
WeakMapkeyed by the record releases entries together with the records. - Storing the input next to the output makes the cache invalidate itself when the input is replaced. In-place mutation keeps the same identity, so it needs an explicit
delete(). - How to translate a visual row index to a physical one before reading the data source.
- Why a renderer needs a guard for a row with no record: a
WeakMapcannot takeundefinedas a key, and a throw inside a renderer takes the whole draw down.
Next steps
- If your renderer only formats the value — units, dates, text transforms — use the
valueFormatteroption instead of a custom renderer. - Reduce the number of renders with
batch()when you change many cells at once. See Batch operations. - Skip the cells that did not change, on renders and on vertical scrolls, with
renderMode: 'onChange'. - For a renderer that draws an SVG chart in the cell, see the Sparkline cell renderer recipe.