Renderer Capabilities
How custom cell renderers are drawn — final in every frame, scrolling or not — and the options for high-frequency cells and the rendering window.
Any column can draw its cells with a custom renderer (renderer — DOM,
React, or imperative React). Every cell, custom or not, is drawn final in the frame that shows it: while the grid
scrolls, a cell entering view shows its real renderer, its formatted value and its style rules, never a placeholder
or a stand-in. When scrolling stops there is nothing left to redraw.
How the grid keeps scrolling cheap
The work a cell needs — its value (getters and formulas), its formatter, readonly state, insight decorations and tooltip — is prepared once per row version and kept, then copied into the DOM by every frame that draws the cell. A scroll frame therefore does a lookup and a few guarded DOM writes per entering cell:
- Rows that stay in view while the grid scrolls only move.
- A cell whose prepared content and state (focus, selection, editing) did not change is not touched.
- A data change re-prepares only the cells of the rows it changed, and they are drawn in the next frame — scrolling or not.
Renderers follow the same rule:
| Renderer | How a cell entering view is drawn |
|---|---|
| None (text) | Its prepared text is written in place. |
| DOM | The cell's renderer instance gets update() with the new row (or mount() the first time a cell uses it). |
| React | The cell's component re-renders with the new props; all React cells of a frame commit together, once, in that frame. |
imperativeReact | ref.current.update() is called directly, bypassing React's scheduler (see below). |
const SparklineRenderer = {
mount(container: HTMLElement, params: { value: unknown }) {
const bar = document.createElement('div');
container.appendChild(bar);
const paint = (value: unknown) => {
bar.style.width = `${Number(value) || 0}%`;
};
paint(params.value);
return { update: (next: { value: unknown }) => paint(next.value) };
},
};
const column = { field: 'trend', header: 'Trend', renderer: { kind: 'dom', renderer: SparklineRenderer } };A DOM renderer's update() is a direct DOM write, the cheapest kind of custom cell. Built-in
column types (badges, people, progress, ratings, links) are DOM renderers.
Renderers also receive isScrolling and phase ('initial', 'scroll', 'edit', …). They are informational:
a renderer is updated when its value, row, column, or editing, focus or selection state changes — not because a
scroll started or ended — and its content must not depend on them.
Imperative renderers for high-frequency cells
For cells that change many times per second, such as a ticking price, even a React state update per tick is
too much. Declare the column as kind: 'imperativeReact'. The grid then calls ref.current.update() directly on a
forwardRef component that exposes ImperativeCellHandle, bypassing React's scheduler.
import * as React from 'react';
import type { CellRendererProps, ImperativeCellHandle } from '@witbybit/wit-grid-react';
type Row = { id: string; price: number };
const LivePrice = React.forwardRef<ImperativeCellHandle<Row>, CellRendererProps<Row, number>>((props, ref) => {
const spanRef = React.useRef<HTMLSpanElement>(null);
React.useImperativeHandle(ref, () => ({
update(next) {
if (spanRef.current) spanRef.current.textContent = next.formattedValue || String(next.value);
},
}));
return <span ref={spanRef}>{props.formattedValue || props.value}</span>;
});
const column = { field: 'price', header: 'Price', renderer: { kind: 'imperativeReact', component: LivePrice } };Use this only where ordinary updates show dropped frames under your real data volume: your component owns its DOM updates. The Realtime Dashboard example shows all three strategies side by side — an imperative React "Change %" column, a DOM "Price" column, and a plain React "Volume" column.
The rendering window
The grid renders the rows and columns in view plus a margin around them (overscan), so a scroll step lands on
cells that are already drawn. The performance option sizes that margin:
| Key | Default | Effect |
|---|---|---|
rowOverscanPx | 400 | Pixels of rows rendered above and below the viewport. |
colBuffer | 2 | Columns rendered left and right of the viewport. |
overscanAdaptive | false | Grows the margin on the side the grid is scrolling toward, in proportion to scroll speed. |
maxRenderedRows | 500 | Hard cap on rendered rows (maxRenderedCells, default 20,000, caps cells). |
import { Grid } from '@witbybit/wit-grid-react';
type Row = { id: string; value: number };
export function FastScrollingGrid({ rows }: { rows: Row[] }) {
return (
<Grid
rows={rows}
columns={[{ field: 'value', header: 'Value' }]}
getRowId={(row) => row.id}
performance={{ rowOverscanPx: 200, overscanAdaptive: true, maxRenderedRows: 60, maxRenderedCells: 2_000 }}
/>
);
}Scroll from user input is drawn in the same frame it happens. A scroll set from script — scrollTop assigned inside
requestAnimationFrame, a scroll animation, an auto-scroll — is drawn one frame later, so each step must land inside
the margin. If a scripted scroll moves further per frame than rowOverscanPx, turn on overscanAdaptive, which
extends the leading edge while the grid is moving and shrinks it again once it stops. It is off by default because it
renders more rows and cells during fast scrolls.
When runtimeLimits bind, the overscan gives way first: rows and columns in view are always kept, and only the
margin shrinks.
Choosing a renderer
- Text, numbers, dates, options, people, progress, ratings → a column type or a
format: no custom renderer needed. - Something custom that writes a few DOM nodes → a DOM renderer.
- A component from your React design system → a React renderer.
- Data changes many times per second and a React update per tick drops frames →
kind: 'imperativeReact'.
Last updated on