Powerful Cell Extensions (itemRender)
Build Canvas charts, facility-load heatmaps, status gauges, and row-level actions in one grid with itemRender.
import * as React from 'react';
import { BGrid, BGridColumn, BGridDataItem } from 'beautiful-grid';
import DataGridContainer from '../components/DataGridContainer';
import { useContainerSize } from '../hooks/useContainerSize';
import './ItemRenderExample.css';
type CenterStatus = '정상' | '관찰' | '대응 필요';
interface FulfillmentCenter {
centerId: string;
centerName: string;
region: string;
status: CenterStatus;
throughput: number[];
utilization: number[];
sla: number;
alerts: number;
updatedAt: string;
}
const initialRows: BGridDataItem<FulfillmentCenter>[] = [
[
'FC-SEO-01',
'서울 동부 센터',
'수도권',
'정상',
[72, 78, 76, 84, 88, 91, 96, 102],
[42, 51, 64, 72, 78, 83, 76, 69, 62, 58, 47, 39],
98,
0,
'10:42',
],
[
'FC-GMP-02',
'김포 허브',
'수도권',
'관찰',
[96, 92, 99, 104, 101, 112, 108, 118],
[55, 62, 74, 88, 94, 96, 91, 85, 72, 61, 53, 48],
91,
2,
'10:41',
],
[
'FC-ICN-01',
'인천 항공 센터',
'수도권',
'정상',
[64, 70, 74, 72, 79, 81, 86, 89],
[31, 37, 42, 48, 55, 61, 66, 63, 54, 46, 39, 34],
96,
0,
'10:40',
],
[
'FC-DAE-01',
'대전 중앙 허브',
'충청권',
'대응 필요',
[122, 119, 116, 111, 106, 101, 95, 88],
[68, 78, 86, 92, 97, 99, 96, 91, 84, 76, 67, 59],
82,
5,
'10:39',
],
[
'FC-BUS-02',
'부산 남부 센터',
'영남권',
'정상',
[51, 55, 54, 61, 65, 69, 72, 76],
[28, 34, 39, 45, 52, 58, 62, 57, 49, 42, 35, 30],
97,
0,
'10:38',
],
[
'FC-DAE-03',
'대구 라스트마일',
'영남권',
'관찰',
[83, 87, 91, 96, 94, 101, 98, 104],
[44, 52, 61, 73, 84, 89, 86, 79, 68, 57, 49, 41],
89,
3,
'10:37',
],
[
'FC-GWJ-01',
'광주 서부 센터',
'호남권',
'정상',
[46, 48, 52, 55, 58, 63, 61, 67],
[25, 29, 35, 41, 48, 54, 59, 55, 46, 38, 32, 27],
95,
0,
'10:36',
],
[
'FC-JEJ-01',
'제주 배송 거점',
'제주권',
'관찰',
[38, 41, 39, 45, 51, 48, 56, 53],
[22, 28, 34, 47, 64, 72, 68, 55, 43, 35, 29, 24],
90,
1,
'10:35',
],
].map(([centerId, centerName, region, status, throughput, utilization, sla, alerts, updatedAt]) => ({
values: {
centerId: String(centerId),
centerName: String(centerName),
region: String(region),
status: status as CenterStatus,
throughput: throughput as number[],
utilization: utilization as number[],
sla: Number(sla),
alerts: Number(alerts),
updatedAt: String(updatedAt),
},
}));
const statusTone: Record<CenterStatus, string> = {
정상: 'healthy',
관찰: 'watch',
'대응 필요': 'critical',
};
const CenterIdentity = React.memo(function CenterIdentity({ values }: { values: FulfillmentCenter }) {
return (
<div className='item-render-center'>
<span className='item-render-center__mark' aria-hidden='true'>
{values.region.slice(0, 1)}
</span>
<span className='item-render-center__copy'>
<strong>{values.centerName}</strong>
<small>
{values.centerId} · {values.region}
</small>
</span>
</div>
);
});
const SparklineCanvas = React.memo(function SparklineCanvas({
values,
status,
}: {
values: number[];
status: CenterStatus;
}) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const latest = values[values.length - 1];
const previous = values[values.length - 2];
const delta = latest - previous;
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
const width = 118;
const height = 28;
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const color = status === '대응 필요' ? '#dc2626' : status === '관찰' ? '#d97706' : '#2563eb';
const min = Math.min(...values);
const max = Math.max(...values);
const range = Math.max(1, max - min);
const points = values.map((value, index) => ({
x: 2 + (index / (values.length - 1)) * (width - 4),
y: height - 3 - ((value - min) / range) * (height - 7),
}));
context.beginPath();
points.forEach((point, index) =>
index === 0 ? context.moveTo(point.x, point.y) : context.lineTo(point.x, point.y),
);
context.lineTo(points[points.length - 1].x, height - 2);
context.lineTo(points[0].x, height - 2);
context.closePath();
const gradient = context.createLinearGradient(0, 0, 0, height);
gradient.addColorStop(0, `${color}42`);
gradient.addColorStop(1, `${color}05`);
context.fillStyle = gradient;
context.fill();
context.beginPath();
points.forEach((point, index) =>
index === 0 ? context.moveTo(point.x, point.y) : context.lineTo(point.x, point.y),
);
context.strokeStyle = color;
context.lineWidth = 1.8;
context.lineJoin = 'round';
context.lineCap = 'round';
context.stroke();
const last = points[points.length - 1];
context.beginPath();
context.arc(last.x, last.y, 2.5, 0, Math.PI * 2);
context.fillStyle = color;
context.fill();
}, [status, values]);
return (
<div className='item-render-trend'>
<canvas
ref={canvasRef}
className='item-render-trend__canvas'
role='img'
aria-label={`최근 8개 구간 처리량 ${values.join(', ')}`}
/>
<span className='item-render-trend__metric'>
<strong>{latest}</strong>
<small className={delta >= 0 ? 'is-up' : 'is-down'}>
{delta >= 0 ? '+' : ''}
{delta}
</small>
</span>
</div>
);
});
const UtilizationCanvas = React.memo(function UtilizationCanvas({ values }: { values: number[] }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
const peak = Math.max(...values);
React.useEffect(() => {
const canvas = canvasRef.current;
if (!canvas) return;
const context = canvas.getContext('2d');
if (!context) return;
const width = 144;
const height = 18;
const dpr = Math.max(1, window.devicePixelRatio || 1);
canvas.width = Math.round(width * dpr);
canvas.height = Math.round(height * dpr);
context.setTransform(dpr, 0, 0, dpr, 0, 0);
context.clearRect(0, 0, width, height);
const gap = 2;
const blockWidth = (width - gap * (values.length - 1)) / values.length;
values.forEach((value, index) => {
const lightness = 96 - Math.min(1, value / 100) * 48;
context.fillStyle = `hsl(${value >= 90 ? 4 : value >= 75 ? 36 : 216} 82% ${lightness}%)`;
context.beginPath();
context.roundRect(index * (blockWidth + gap), 1, blockWidth, height - 2, 2);
context.fill();
});
}, [values]);
return (
<div className='item-render-heatmap'>
<canvas
ref={canvasRef}
className='item-render-heatmap__canvas'
role='img'
aria-label={`시간대별 설비 부하 ${values.join(', ')} 퍼센트`}
/>
<span>
최고 <strong>{peak}%</strong>
</span>
</div>
);
});
const SlaGauge = React.memo(function SlaGauge({ value }: { value: number }) {
const tone = value >= 95 ? 'healthy' : value >= 88 ? 'watch' : 'critical';
return (
<div
className='item-render-gauge'
role='progressbar'
aria-label='출고 SLA 달성률'
aria-valuemin={0}
aria-valuemax={100}
aria-valuenow={value}
>
<span
className={`item-render-gauge__ring item-render-gauge__ring--${tone}`}
style={{ '--gauge-value': `${value * 3.6}deg` } as React.CSSProperties}
>
<strong>{value}</strong>
</span>
<span className='item-render-gauge__label'>
SLA<small>{value >= 95 ? '안정' : value >= 88 ? '주의' : '위험'}</small>
</span>
</div>
);
});
function ItemRenderExample() {
const [rows, setRows] = React.useState(initialRows);
const [onlyAttention, setOnlyAttention] = React.useState(false);
const [selectedCenter, setSelectedCenter] = React.useState('행을 선택하면 센터 정보가 표시됩니다.');
const containerRef = React.useRef<HTMLDivElement>(null);
const { width, height } = useContainerSize(containerRef);
const acknowledgeAlerts = React.useCallback((centerId: string) => {
setRows(current =>
current.map(row =>
row.values.centerId === centerId ? { ...row, values: { ...row.values, alerts: 0, status: '정상' } } : row,
),
);
}, []);
const columns = React.useMemo<BGridColumn<FulfillmentCenter>[]>(
() => [
{
key: 'centerName',
label: '물류 거점',
width: 195,
itemRender: ({ values }) => <CenterIdentity values={values} />,
getClipboardText: ({ values }) => `${values.centerName} (${values.centerId})`,
},
{
key: 'status',
label: '운영 상태',
width: 105,
align: 'center',
itemRender: ({ value }) => (
<span className={`item-render-status item-render-status--${statusTone[value as CenterStatus]}`}>
<i aria-hidden='true' />
{String(value)}
</span>
),
},
{
key: 'throughput',
label: '시간당 처리량 추이',
width: 205,
itemRender: ({ values }) => <SparklineCanvas values={values.throughput} status={values.status} />,
getClipboardText: ({ values }) => `${values.throughput[values.throughput.length - 1]} orders/h`,
},
{
key: 'utilization',
label: '12시간 설비 부하',
width: 205,
itemRender: ({ value }) => <UtilizationCanvas values={value as number[]} />,
getClipboardText: ({ value }) => (value as number[]).join(','),
},
{
key: 'sla',
label: '출고 SLA',
width: 125,
align: 'center',
itemRender: ({ value }) => <SlaGauge value={Number(value)} />,
getClipboardText: ({ value }) => `${value}%`,
},
{
key: 'alerts',
label: '이상 대응',
width: 150,
align: 'center',
itemRender: ({ values }) =>
values.alerts > 0 ? (
<button
type='button'
className='item-render-action'
aria-label={`${values.centerName} 알림 ${values.alerts}건 확인 처리`}
onClick={event => {
event.stopPropagation();
acknowledgeAlerts(values.centerId);
}}
>
알림 {values.alerts}건 확인
</button>
) : (
<span className='item-render-clear'>이상 없음</span>
),
getClipboardText: ({ values }) => (values.alerts > 0 ? `알림 ${values.alerts}건` : '이상 없음'),
},
{ key: 'updatedAt', label: '갱신', width: 80, align: 'center' },
],
[acknowledgeAlerts],
);
const displayedRows = React.useMemo(
() => (onlyAttention ? rows.filter(row => row.values.alerts > 0) : rows),
[onlyAttention, rows],
);
return (
<div className='item-render-example'>
<div className='item-render-example__toolbar'>
<div>
<span className='item-render-example__eyebrow'>FULFILLMENT CONTROL TOWER</span>
<strong>셀 안에 운영 대시보드를 구성합니다</strong>
<small>Canvas 2종 · 복합 React UI · 행 단위 액션</small>
</div>
<button
type='button'
className='item-render-example__filter'
aria-pressed={onlyAttention}
onClick={() => setOnlyAttention(value => !value)}
>
<span aria-hidden='true' />
이상 거점만 보기
<b>{rows.filter(row => row.values.alerts > 0).length}</b>
</button>
</div>
<DataGridContainer ref={containerRef} className='item-render-example__grid-container'>
<BGrid<FulfillmentCenter>
className='item-render-dashboard-grid'
width={width}
height={height}
columns={columns}
data={displayedRows}
rowKey='centerId'
headerHeight={38}
itemHeight={44}
itemPadding={4}
frozenColumnIndex={1}
variant='vertical-bordered'
cellSelectionOptions={{ enabled: true }}
cellNavigationOptions={{ enabled: true, defaultActiveCell: { rowIndex: 0, columnIndex: 0 } }}
onClick={({ item }) =>
setSelectedCenter(`${item.values.centerName} · SLA ${item.values.sla}% · ${item.values.updatedAt} 갱신`)
}
status={{
content: `${displayedRows.length}개 거점 · ${onlyAttention ? '이상 대응 대상' : '전체 운영 현황'}`,
}}
/>
</DataGridContainer>
<div className='item-render-example__footer' aria-live='polite'>
<span className='item-render-example__live-dot' aria-hidden='true' />
{selectedCenter}
</div>
</div>
);
}
export default ItemRenderExample;.item-render-example {
--item-render-ink: #0f172a;
--item-render-muted: #64748b;
display: flex;
min-height: 0;
flex-direction: column;
gap: 10px;
color: var(--item-render-ink);
}
.item-render-example__toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 12px 14px;
border: 1px solid #dbe2ea;
border-radius: 12px;
background: linear-gradient(135deg, #f8fafc 0%, #eff6ff 100%);
}
.item-render-example__toolbar > div,
.item-render-example__toolbar strong,
.item-render-example__toolbar small {
display: block;
}
.item-render-example__eyebrow {
display: block;
margin-bottom: 2px;
color: #2563eb;
font-size: 9px;
font-weight: 800;
letter-spacing: 0.1em;
}
.item-render-example__toolbar strong {
font-size: 14px;
}
.item-render-example__toolbar small {
margin-top: 2px;
color: var(--item-render-muted);
font-size: 11px;
}
.item-render-example__filter {
display: inline-flex;
min-height: 36px;
flex: 0 0 auto;
align-items: center;
gap: 7px;
padding: 0 10px;
border: 1px solid #cbd5e1;
border-radius: 9px;
color: #334155;
background: #ffffff;
font: inherit;
font-size: 12px;
font-weight: 650;
cursor: pointer;
}
.item-render-example__filter:hover,
.item-render-example__filter[aria-pressed='true'] {
border-color: #93c5fd;
color: #1d4ed8;
background: #eff6ff;
}
.item-render-example__filter > span,
.item-render-example__live-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: #f59e0b;
}
.item-render-example__filter b {
display: inline-grid;
min-width: 19px;
height: 19px;
place-items: center;
border-radius: 999px;
color: #ffffff;
background: #2563eb;
font-size: 10px;
}
.item-render-example .item-render-example__grid-container {
height: 430px;
}
.item-render-example__footer {
display: flex;
min-height: 22px;
align-items: center;
gap: 7px;
color: var(--item-render-muted);
font-size: 11px;
}
.item-render-example__live-dot {
flex: 0 0 auto;
background: #22c55e;
box-shadow: 0 0 0 3px #dcfce7;
}
[role='grid'].item-render-dashboard-grid .item-render-center,
[role='grid'].item-render-dashboard-grid .item-render-trend,
[role='grid'].item-render-dashboard-grid .item-render-heatmap,
[role='grid'].item-render-dashboard-grid .item-render-gauge {
display: flex;
width: 100%;
min-width: 0;
align-items: center;
line-height: normal;
}
[role='grid'].item-render-dashboard-grid .item-render-center {
gap: 9px;
}
.item-render-center__mark {
display: inline-grid;
width: 30px;
height: 30px;
flex: 0 0 auto;
place-items: center;
border: 1px solid #bfdbfe;
border-radius: 8px;
color: #1d4ed8;
background: #eff6ff;
font-size: 11px;
font-weight: 800;
}
.item-render-center__copy,
.item-render-center__copy strong,
.item-render-center__copy small {
display: block;
min-width: 0;
}
.item-render-center__copy strong {
overflow: hidden;
color: #0f172a;
font-size: 12px;
text-overflow: ellipsis;
}
.item-render-center__copy small {
margin-top: 2px;
overflow: hidden;
color: #64748b;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 9px;
text-overflow: ellipsis;
}
.item-render-status {
display: inline-flex;
min-width: 68px;
align-items: center;
justify-content: center;
gap: 5px;
padding: 3px 7px;
border-radius: 999px;
font-size: 10px;
font-weight: 750;
line-height: normal;
}
.item-render-status i {
width: 5px;
height: 5px;
border-radius: 50%;
background: currentColor;
}
.item-render-status--healthy {
color: #15803d;
background: #dcfce7;
}
.item-render-status--watch {
color: #b45309;
background: #fef3c7;
}
.item-render-status--critical {
color: #b91c1c;
background: #fee2e2;
}
[role='grid'].item-render-dashboard-grid .item-render-trend {
gap: 8px;
}
.item-render-trend__canvas {
width: 118px;
height: 28px;
flex: 0 0 auto;
}
.item-render-trend__metric,
.item-render-trend__metric strong,
.item-render-trend__metric small {
display: block;
}
.item-render-trend__metric strong {
color: #0f172a;
font-family: ui-monospace, SFMono-Regular, Menlo, monospace;
font-size: 12px;
}
.item-render-trend__metric small {
margin-top: 1px;
font-size: 9px;
font-weight: 700;
}
.item-render-trend__metric .is-up {
color: #2563eb;
}
.item-render-trend__metric .is-down {
color: #dc2626;
}
[role='grid'].item-render-dashboard-grid .item-render-heatmap {
gap: 8px;
}
.item-render-heatmap__canvas {
width: 144px;
height: 18px;
flex: 0 0 auto;
}
.item-render-heatmap > span {
color: #64748b;
font-size: 9px;
}
.item-render-heatmap strong {
display: block;
color: #0f172a;
font-size: 10px;
}
[role='grid'].item-render-dashboard-grid .item-render-gauge {
justify-content: center;
gap: 7px;
}
.item-render-gauge__ring {
position: relative;
display: inline-grid;
width: 32px;
height: 32px;
flex: 0 0 auto;
place-items: center;
border-radius: 50%;
background: conic-gradient(#2563eb var(--gauge-value), #e2e8f0 0);
}
.item-render-gauge__ring::after {
position: absolute;
inset: 4px;
border-radius: 50%;
content: '';
background: #ffffff;
}
.item-render-gauge__ring--watch {
background: conic-gradient(#d97706 var(--gauge-value), #e2e8f0 0);
}
.item-render-gauge__ring--critical {
background: conic-gradient(#dc2626 var(--gauge-value), #e2e8f0 0);
}
.item-render-gauge__ring strong {
position: relative;
z-index: 1;
color: #0f172a;
font-size: 9px;
}
.item-render-gauge__label,
.item-render-gauge__label small {
display: block;
text-align: left;
}
.item-render-gauge__label {
color: #475569;
font-size: 9px;
font-weight: 700;
}
.item-render-gauge__label small {
margin-top: 1px;
color: #94a3b8;
font-size: 8px;
}
.item-render-action {
min-height: 28px;
padding: 0 9px;
border: 1px solid #fecaca;
border-radius: 7px;
color: #b91c1c;
background: #fff7f7;
font: inherit;
font-size: 10px;
font-weight: 700;
line-height: normal;
cursor: pointer;
}
.item-render-action:hover {
border-color: #fca5a5;
background: #fee2e2;
}
.item-render-action:focus-visible,
.item-render-example__filter:focus-visible {
outline: 2px solid #2563eb;
outline-offset: 2px;
}
.item-render-clear {
color: #15803d;
font-size: 10px;
font-weight: 700;
line-height: normal;
}
@media (max-width: 640px) {
.item-render-example__toolbar {
align-items: stretch;
flex-direction: column;
}
.item-render-example__filter {
justify-content: center;
}
.item-render-example .item-render-example__grid-container {
height: 410px;
}
}import * as React from 'react';
import './DataGridContainer.css';
interface DataGridContainerProps extends React.HTMLAttributes<HTMLDivElement> {
children?: React.ReactNode;
}
/**
* Keeps a DataGrid in a measured, fixed layout box.
*
* BGrid's rendered root is absolutely positioned within this relative
* container. This makes a ResizeObserver measurement authoritative when a
* surrounding flex or grid layout shrinks as well as when it expands.
*/
const DataGridContainer = React.forwardRef<HTMLDivElement, DataGridContainerProps>(
({ className, ...rest }, ref) => (
<div ref={ref} className={`data-grid-container ${className ?? ''}`.trim()} {...rest} />
),
);
DataGridContainer.displayName = 'DataGridContainer';
export default DataGridContainer;.data-grid-container {
position: relative;
width: 100%;
height: 400px;
overflow: hidden;
font-size: 13px;
}
.data-grid-container > .bgrid-root {
position: absolute;
inset: 0;
}import * as React from 'react';
export function useContainerSize(ref: React.MutableRefObject<HTMLElement | null>, additionalDeps: unknown[] = []) {
const [width, setWidth] = React.useState(0);
const [height, setHeight] = React.useState(0);
const resizeObserver = React.useRef(
new ResizeObserver(entries => {
if (entries.length !== 1) {
throw new Error('Invalid Container length');
}
const [entry] = entries;
const { width, height } = entry.contentRect;
setWidth(width);
setHeight(height);
}),
);
React.useEffect(() => {
if (!ref.current) return;
const observer = resizeObserver.current;
const element = ref.current;
setWidth(element.clientWidth);
setHeight(element.clientHeight);
observer.observe(element);
return () => {
observer.unobserve(element);
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [...additionalDeps, ref]);
return {
width,
height,
};
}Extend a text cell into a small application
itemRender is more than a string formatter. It receives the current row’s values, the cell’s value, row and column indexes, and editing controls, and it can return any React node. This makes it possible to place visualizations and interactions inside a cell that would be difficult to build with a conventional formatter-based Grid.
The logistics-control example above renders the following UI from the same row data:
| Cell | Implementation | Why it is harder than a text cell |
|---|---|---|
| Fulfillment center | Composite cell with an icon, name, and code | Combines several row fields rather than displaying a single value. |
| Throughput trend | High-resolution Canvas sparkline | Converts an array to coordinates and renders at the device pixel ratio. |
| Facility load | 12-segment Canvas heatmap | Calculates colors and blocks dynamically from numeric ranges. |
| Fulfillment SLA | CSS circular gauge with status text | Combines a value, status, and accessible text in one component. |
| Exception response | Button that changes row state | Manages the event boundary between cell clicks and button clicks. |
Click Show exception centers only to filter the displayed rows through React state. Acknowledge N alerts immutably updates only the corresponding row. The Canvas cells are not continuously animated; they redraw only when their data changes.
Core pattern: return a component from the callback
Do not call Hooks inside itemRender itself. Return a React component that uses the Hooks instead. This follows the Rules of Hooks and keeps Canvas lifecycle and memoization independent.
const SparklineCanvas = React.memo(function SparklineCanvas({ values }: { values: number[] }) {
const canvasRef = React.useRef<HTMLCanvasElement>(null);
React.useEffect(() => {
const canvas = canvasRef.current;
const context = canvas?.getContext('2d');
if (!canvas || !context) return;
// Account for devicePixelRatio, then convert values to coordinates and draw them.
}, [values]);
return <canvas ref={canvasRef} role='img' aria-label={`Throughput ${values.join(', ')}`} />;
});
const columns: BGridColumn<FulfillmentCenter>[] = [
{
key: 'throughput',
label: 'Hourly throughput trend',
width: 205,
itemRender: ({ values }) => <SparklineCanvas values={values.throughput} />,
getClipboardText: ({ values }) => `${values.throughput.at(-1)} orders/h`,
},
];
The source panel on this page contains the complete implementation, including Canvas coordinate calculations, color ranges, and action buttons.
Rendering context
| Property | Example use |
|---|---|
value |
Build a chart or gauge from the value referenced by the current column.key. |
values |
Combine several fields from the same row into cells such as name + code or value + status. |
item |
Inspect Grid row state such as status and checked alongside values. |
index, columnIndex |
Include the position in an accessible name or per-cell diagnostic data. |
handleSave, handleCancel, handleMove |
Control a custom editing flow for an editable column. |
Display components and editing UI have different responsibilities. Use itemRender for rich idle-state display, and keep input and save lifecycle in editor or the editing-control functions.
Using Canvas with virtual scrolling
BeautifulGrid virtual scrolling keeps only the rows needed for the current viewport in the DOM. Canvas cells can therefore mount when they enter the viewport and unmount when they leave it.
- Precompute repeated work such as coordinate conversion or color ranges, or isolate it in a small component.
- Use
useMemofor column arrays,useCallbackfor event handlers, andReact.memofor expensive cell components. - Prefer a single redraw when data changes over a continuous
requestAnimationFrameloop. - Match the Canvas CSS size and backing-store pixel size to
devicePixelRatioto preserve sharpness. - Set a stable row height that fits the content, and test actual scrolling at the target data scale.
Many Canvas cells are not inherently slow, but each one has its own graphics context. Verify that the application mounts only the cells needed within the virtual-scroll range rather than placing thousands of Canvas elements in the DOM at once.
Define search, clipboard, and accessibility text separately
Rendered DOM or Canvas pixels do not automatically become the Grid’s search or clipboard text. When the visual presentation differs from the data’s meaning, define the following contracts as well:
getClipboardText: return a user-readable string instead of an array or object.getSearchText: when using Grid search, provide a searchable value or label that represents the chart.- Give each Canvas
role="img"and anaria-labelthat summarizes the data. - Do not communicate status through color alone; include text such as
Normal,Watch, orAction required. - Give buttons inside cells a specific
aria-label, and callevent.stopPropagation()when they should not also trigger the row click.
Suitable use cases and boundaries
itemRender is especially useful in Grids for operations monitoring, production-equipment status, portfolio changes, quality-inspection results, and inventory risk—cases where row comparison and compact visualizations are both important.
Large charts spanning multiple cells, free-form dashboards, and high-frame-rate animations are better placed in a separate chart area. The strength of itemRender is increasing information density and interactivity without losing each row’s context.