Handsontable Renderer Development
Function signature
Renderers are pure functions with no class or state:
function myRenderer(hotInstance, TD, row, col, prop, value, cellProperties) {
baseRenderer.apply(this, arguments);
// Modify TD element here
}
Always call baseRenderer first. It applies common properties: readonly CSS class, invalid CSS class, ARIA attributes, and other standard cell setup.
Key rules
- Stateless and read-only. Renderers only modify the TD element's DOM content and attributes. Never store state, attach event listeners, or mutate data.
- Use
fastInnerText(TD, value) from src/helpers/dom/element.ts for setting cell text content. It is XSS-safe and cross-browser optimized.
- Append or clear through
getCellContentRoot(TD), never TD itself, when a renderer manages the cell's children (empty(...), appendChild(...), insertBefore(x, root.firstChild)). A row rendered at an exact height keeps the content in a div.htCellClip wrapper (a table cell cannot be shorter than its in-flow content); the helper returns that wrapper when present and the cell otherwise. fastInnerText/fastInnerHTML do this on their own. Models: checkboxRenderer, autocompleteRenderer, multiSelectRenderer.
- Never use
innerHTML without sanitization. All user-provided content must be escaped to prevent XSS.
- No event listeners. If you need interactivity, that belongs in an editor or a plugin, not a renderer.
- ARIA attributes.
baseRenderer handles standard ARIA. If your renderer changes the cell's role or state, update ARIA attributes accordingly.
File structure
src/renderers/{rendererName}/
{rendererName}.ts # Renderer function
index.ts # Re-exports
Registry: src/renderers/registry.ts.
Registration
import { registerRenderer } from '../../renderers/registry';
registerRenderer('myRenderer', myRenderer);
Reference implementations
src/renderers/baseRenderer/baseRenderer.ts - Must be called by every renderer.
src/renderers/textRenderer/textRenderer.ts - Simplest renderer, good starting template.
src/renderers/htmlRenderer/htmlRenderer.ts - Renders raw HTML (use with caution).
src/renderers/numericRenderer/numericRenderer.ts - Formatting with numeral.js.
Performance
Renderers are called for every cell in the viewport on every render cycle (both fast and slow renders). They must be highly optimized:
- Keep logic minimal - avoid DOM-heavy operations
- Never read layout properties inside a renderer (
getBoundingClientRect, offsetWidth) - causes layout thrashing
- Avoid object allocations and complex string concatenations in the hot path
- The simpler the renderer, the better
- A slow derived value (chart markup, a parsed document) belongs in a cache keyed by the data record or the cell coordinates, never by the
TD: the engine keeps a fixed set of TD elements and rewrites them as you scroll, so a TD-keyed cache misses on almost every call (issue #13446). Two traps when building that cache: getSourceDataAtRow() returns a copy of the row on every call, so it can never be a WeakMap key - read the record from your own data array by toPhysicalRow(row) (the cell value itself IS handed over by reference); and afterRender does not fire for scroll draws - count or refresh per-draw state in afterViewRender. Worked example: docs/content/recipes/performance/expensive-cell-renderer/.
Common mistakes
- Forgetting to call
baseRenderer first, which skips readonly/invalid CSS and ARIA setup.
- Caching renderer output on the
TD element (a WeakMap keyed by TD, or a property on it) - see Performance above.
- Adding event listeners in a renderer (use editors or plugins instead).
- Using
innerHTML with unsanitized user input.
- Writing children straight into
TD (TD.appendChild, TD.insertBefore(x, TD.firstChild), empty(TD)) instead of getCellContentRoot(TD) — on an exact-height row the clipping wrapper is then rebuilt every draw, and a node left outside it grows the row back.
- Mutating
cellProperties or source data inside a renderer.
- Not handling
null or undefined values gracefully.
1---2name: handsontable-renderer-dev3description: Use when creating or modifying a Handsontable cell renderer function that controls how cell content is displayed in the DOM - pure functions that take cell data and modify TD element4---56# Handsontable Renderer Development78## Function signature910Renderers are **pure functions** with no class or state:1112```js13function myRenderer(hotInstance, TD, row, col, prop, value, cellProperties) {14 baseRenderer.apply(this, arguments);15 // Modify TD element here16}17```1819Always call `baseRenderer` first. It applies common properties: readonly CSS class, invalid CSS class, ARIA attributes, and other standard cell setup.2021## Key rules2223- **Stateless and read-only.** Renderers only modify the TD element's DOM content and attributes. Never store state, attach event listeners, or mutate data.24- **Use `fastInnerText(TD, value)`** from `src/helpers/dom/element.ts` for setting cell text content. It is XSS-safe and cross-browser optimized.25- **Append or clear through `getCellContentRoot(TD)`**, never `TD` itself, when a renderer manages the cell's children (`empty(...)`, `appendChild(...)`, `insertBefore(x, root.firstChild)`). A row rendered at an exact height keeps the content in a `div.htCellClip` wrapper (a table cell cannot be shorter than its in-flow content); the helper returns that wrapper when present and the cell otherwise. `fastInnerText`/`fastInnerHTML` do this on their own. Models: `checkboxRenderer`, `autocompleteRenderer`, `multiSelectRenderer`.26- **Never use `innerHTML`** without sanitization. All user-provided content must be escaped to prevent XSS.27- **No event listeners.** If you need interactivity, that belongs in an editor or a plugin, not a renderer.28- **ARIA attributes.** `baseRenderer` handles standard ARIA. If your renderer changes the cell's role or state, update ARIA attributes accordingly.2930## File structure3132```33src/renderers/{rendererName}/34 {rendererName}.ts # Renderer function35 index.ts # Re-exports36```3738Registry: `src/renderers/registry.ts`.3940## Registration4142```js43import { registerRenderer } from '../../renderers/registry';44registerRenderer('myRenderer', myRenderer);45```4647## Reference implementations4849- `src/renderers/baseRenderer/baseRenderer.ts` - Must be called by every renderer.50- `src/renderers/textRenderer/textRenderer.ts` - Simplest renderer, good starting template.51- `src/renderers/htmlRenderer/htmlRenderer.ts` - Renders raw HTML (use with caution).52- `src/renderers/numericRenderer/numericRenderer.ts` - Formatting with numeral.js.5354## Performance5556Renderers are called **for every cell in the viewport on every render cycle** (both fast and slow renders). They must be highly optimized:57- Keep logic minimal - avoid DOM-heavy operations58- Never read layout properties inside a renderer (`getBoundingClientRect`, `offsetWidth`) - causes layout thrashing59- Avoid object allocations and complex string concatenations in the hot path60- The simpler the renderer, the better61- A slow derived value (chart markup, a parsed document) belongs in a cache keyed by the **data record or the cell coordinates**, never by the `TD`: the engine keeps a fixed set of `TD` elements and rewrites them as you scroll, so a `TD`-keyed cache misses on almost every call (issue #13446). Two traps when building that cache: `getSourceDataAtRow()` returns a **copy** of the row on every call, so it can never be a `WeakMap` key - read the record from your own data array by `toPhysicalRow(row)` (the cell `value` itself IS handed over by reference); and `afterRender` does not fire for scroll draws - count or refresh per-draw state in `afterViewRender`. Worked example: `docs/content/recipes/performance/expensive-cell-renderer/`.6263## Common mistakes6465- Forgetting to call `baseRenderer` first, which skips readonly/invalid CSS and ARIA setup.66- Caching renderer output on the `TD` element (a `WeakMap` keyed by `TD`, or a property on it) - see Performance above.67- Adding event listeners in a renderer (use editors or plugins instead).68- Using `innerHTML` with unsanitized user input.69- Writing children straight into `TD` (`TD.appendChild`, `TD.insertBefore(x, TD.firstChild)`, `empty(TD)`) instead of `getCellContentRoot(TD)` — on an exact-height row the clipping wrapper is then rebuilt every draw, and a node left outside it grows the row back.70- Mutating `cellProperties` or source data inside a renderer.71- Not handling `null` or `undefined` values gracefully.