Skip to content

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.

TypeScript
/* file: app.component.ts */
import { Component, ElementRef, ViewChild } from '@angular/core';
import { GridSettings, HotTableComponent, HotTableModule } from '@handsontable/angular-wrapper';
import { BaseRenderer, baseRenderer, textRenderer } from 'handsontable/renderers';
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);
};
@Component({
standalone: true,
imports: [HotTableModule],
selector: 'example1-expensive-cell-renderer',
styles: [`
.render-stats {
margin: 8px 0 0;
font-size: 13px;
color: #666;
}
`],
template: `
<div class="example-controls-container">
<div class="controls">
<button type="button" (click)="renderAgain()">Render again</button>
<button type="button" (click)="updateFirstRow()">Update the first row</button>
</div>
</div>
<hot-table [data]="data" [settings]="gridSettings"></hot-table>
<p #statsLine class="render-stats">Renderer calls: 0 | Computations: 0</p>
`,
})
export class AppComponent {
@ViewChild(HotTableComponent, { static: false }) readonly hotTable!: HotTableComponent;
@ViewChild('statsLine', { static: true }) readonly statsLine!: ElementRef<HTMLParagraphElement>;
readonly data = data;
readonly gridSettings: GridSettings = {
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,
// Runs once per draw, scroll draws included, after every cell renderer. The grid runs outside
// Angular's zone, and the first draw happens during `ngAfterViewInit`. Write the counters
// straight into the element instead of into a bound property: a bound property set from here
// costs a whole-application change-detection pass on every draw, and on the first draw it
// raises NG0100 (ExpressionChangedAfterItHasBeenCheckedError).
afterViewRender: () => {
this.statsLine.nativeElement.textContent =
`Renderer calls: ${stats.rendererCalls} | Computations: ${stats.computations}`;
},
};
renderAgain(): void {
this.hotTable?.hotInstance?.render();
}
updateFirstRow(): void {
const hot = this.hotTable?.hotInstance;
if (!hot) {
return;
}
// 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);
}
}
/* 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';
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-expensive-cell-renderer></example1-expensive-cell-renderer></div>

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 sales array 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

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:

  1. baseRenderer() applies the standard cell classes, so the cell still reacts to readOnly, validation, and the rest of the cell meta.
  2. row is a visual index, and your data array is in physical order, so toPhysicalRow() 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 a WeakMap keyed by its result never hits.
  3. 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 while alter() is still running. undefined is not a valid WeakMap key, so caching one throws and takes the whole draw down with it. An empty cell is caught by the same check.
  4. value is the cell’s value — here, the sales array, 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.
  5. The renderer always writes the cell, on the cached path as well as the computed one, because the grid resets a td before it runs a renderer. Only what the renderer writes back survives.
  6. The write goes through the built-in textRenderer rather than td.textContent = …. On a row whose height is exact, the engine keeps the cell’s content inside a wrapper it reuses between draws. textContent would replace that wrapper, and the engine would build it again on the next draw. textRenderer writes 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

  1. First render. The renderer runs for every rendered cell of the Trend column, and computations equals the number of rendered rows: every record is new to the cache.
  2. Scroll down. The renderer runs again for every cell in the new viewport, but computations grows only by the rows that were not rendered before.
  3. Scroll back up. rendererCalls keeps growing; computations does not move. Every record on screen is already in the cache.
  4. Render again. rendererCalls grows by the number of rendered cells; computations stays where it was.
  5. Update the first row. The grid scrolls back to the top and computations grows 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 WeakMap by 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 td own 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’s td elements 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’s HotCellRendererComponent renders its component cells this way: one component per td, its inputs updated on every call. Three rules make that safe:

    1. 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.
    2. 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 the td has one parent for its whole life.
    3. 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 WeakMap keyed by the td, so a cell element the grid drops releases its container with it.

  • Skip the cells that did not change. The renderMode option 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 td follows its row only through a vertical scroll; a horizontal scroll and a row that comes back into view give the record another td, so a td is the wrong cache key for a computed value. The data record, or the cell coordinates, is the right one.
  • A WeakMap keyed 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 WeakMap cannot take undefined as 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 valueFormatter option 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.