Chapter 26 of 34

Framework components as cells

A real React or Angular component per cell – created only for visible cells.

After this chapter you can

  • wrap a React component as renderer
  • implement ComponentRendererIf in Angular
  • know the lifecycle

Why components?

A cell renderer builds DOM by hand. If your design system already has a <StatusChip>, a tooltip or a menu button as a component, you can put the component itself into the cell. Each wrapper package has an adapter that turns a component into a CellRendererIf.

The shared model of the framework demos accepts optional body renderers per property:

src/model/order-table.ts
import { CellRendererIf, ColumnDef, TableFactory, TableModelIf } from "@guiexpert/table";
import { createOrders, Order } from "../data/orders";
/**
* The orders table used by the framework demos (episodes 006–010) – one model for every framework.
* Episode 026: optional body renderers per property, e.g. a framework component for "status".
*/
export function createOrderTableModel(
rowCount = 100_000,
bodyRenderers: Partial<Record<keyof Order, CellRendererIf>> = {}): TableModelIf {
const rows = createOrders(rowCount);
if (!Object.keys(bodyRenderers).length) return TableFactory.createTableModel({ rows });
const properties = Object.keys(rows[0] ?? {}) as (keyof Order)[];
const columnDefs = properties.map(property => ColumnDef.create({
property, headerLabel: property.toUpperCase(), bodyRenderer: bodyRenderers[property]
}));
return TableFactory.createTableModel({ rows, columnDefs });
}

React

frameworks/react/src/StatusChip.tsx
// Episode 026 – a React component as cell renderer. Hooks work: it is rendered as a real element.
import { useState } from "react";
interface CellProps { cellValue: string; rowIndex: number }
export function StatusChip({ cellValue, rowIndex }: CellProps) {
const [hover, setHover] = useState(false);
return (
<span className={`status-badge status-${cellValue}`}
title={`row ${rowIndex + 1}`}
onMouseEnter={() => setHover(true)} onMouseLeave={() => setHover(false)}>
{hover ? "● " : ""}{cellValue}
</span>
);
}
frameworks/react/src/App.tsx
// Episode 007 – the same orders model, rendered by the React wrapper.
import { ComponentRendererWrapper, GuiexpertTable } from "@guiexpert/react-table";
import { GeMouseEvent, TableApi, TableOptions } from "@guiexpert/table";
import { useMemo, useState } from "react";
import { createOrderTableModel } from "./model/order-table";
import { StatusChip } from "./StatusChip";
export function App() {
const [rows, setRows] = useState(100_000);
const [info, setInfo] = useState("");
// a NEW model object rebuilds the table → keep model and options stable
const model = useMemo(() => createOrderTableModel(rows, {
status: new ComponentRendererWrapper(StatusChip) // episode 026: React component per cell
}), [rows]);
const options = useMemo(() => ({ ...new TableOptions() }), []);
const onReady = (api: TableApi) =>
setInfo(`${api.getBodyModel().getRowCount().toLocaleString("en")} orders`);
const onClick = (evt: GeMouseEvent) =>
setInfo(`row ${evt.rowIndex}, column ${evt.columnIndex}`);
return (
<>
<header className="app-header">
<b>React</b>
<button onClick={() => setRows(rows === 100_000 ? 1_000 : 100_000)}>
{rows === 100_000 ? "1,000 rows" : "100,000 rows"}
</button>
<span id="info">{info}</span>
</header>
<div className="table-div">
<GuiexpertTable
tableModel={model} tableOptions={options}
tableReady={onReady} mouseClicked={onClick}
/>
</div>
</>
);
}

The component gets cellValue, rowIndex, columnIndex, areaIdent and areaModel as props. It is rendered as a real React element – hooks like useState work.

The status column: a React component per visible cell (hover shows the dot).
The status column: a React component per visible cell (hover shows the dot).

Angular

The component implements ComponentRendererIf – the table calls setData() instead of setting inputs:

frameworks/angular/src/app/status-chip.ts
// Episode 026 – an Angular component as cell renderer: implement ComponentRendererIf.setData().
import { ChangeDetectionStrategy, Component, signal } from "@angular/core";
import { ComponentRendererIf } from "@guiexpert/angular-table";
import { AreaIdent, AreaModelIf, RendererCleanupFnType } from "@guiexpert/table";
@Component({
selector: "app-status-chip",
changeDetection: ChangeDetectionStrategy.OnPush,
template: `<span class="status-badge status-{{ status() }}" [title]="'row ' + row()">{{ status() }}</span>`
})
export class StatusChip implements ComponentRendererIf<StatusChip> {
readonly status = signal("");
readonly row = signal(0);
setData(rowIndex: number, _columnIndex: number, _areaIdent: AreaIdent,
_areaModel: AreaModelIf, cellValue: any): RendererCleanupFnType | undefined {
this.status.set(cellValue);
this.row.set(rowIndex + 1);
return undefined;
}
}

RenderWrapperFactory.create(Component, changeDetectorRef) builds the renderer once:

frameworks/angular/src/app/app.ts
// Episode 009 – the same orders model, rendered by the Angular wrapper (Angular 14–22, here 22).
import { ChangeDetectorRef, Component, computed, inject, signal } from "@angular/core";
import { RenderWrapperFactory, TableComponent } from "@guiexpert/angular-table";
import { GeMouseEvent, TableApi, TableOptions } from "@guiexpert/table";
import { createOrderTableModel } from "./model/order-table";
import { StatusChip } from "./status-chip";
@Component({
selector: "app-root",
imports: [TableComponent],
template: `
<header class="app-header">
<b>Angular 22</b>
<button (click)="toggle()">{{ rows() === 100000 ? "1,000 rows" : "100,000 rows" }}</button>
<span id="info">{{ info() }}</span>
</header>
<guiexpert-table class="table-div"
[tableModel]="model()" [tableOptions]="options"
(tableReady)="onReady($event)" (mouseClicked)="onClick($event)" />
`
})
export class App {
private readonly rwf = inject(RenderWrapperFactory);
private readonly cdr = inject(ChangeDetectorRef);
// episode 026: an Angular component per visible status cell
private readonly statusRenderer = this.rwf.create(StatusChip, this.cdr);
readonly rows = signal(100_000);
// a new model object → the table is rebuilt
readonly model = computed(() => createOrderTableModel(this.rows(), { status: this.statusRenderer }));
readonly options = { ...new TableOptions() };
readonly info = signal("");
toggle(): void {
this.rows.update(n => (n === 100_000 ? 1_000 : 100_000));
}
onReady(api: TableApi): void {
this.info.set(`${api.getBodyModel().getRowCount().toLocaleString("en")} orders`);
}
onClick(evt: GeMouseEvent): void {
this.info.set(`row ${evt.rowIndex}, column ${evt.columnIndex}`);
}
}

Vue, Svelte, Solid, Preact

Their wrapper packages export a ComponentRendererWrapper as well – same idea, the component receives the cell data as props.

Lifecycle

  • Components are created only for visible cells and destroyed when the cell scrolls out of view – tested: after 20 scroll steps there are still 19 components in the DOM, not hundreds.
  • That means: no long-lived state in the cell component. Keep state in your data (the row) or in a store.
  • Components are heavier than plain renderers. For 10 columns × 30 visible rows that is no problem; for a heatmap with 400 cells per screen prefer classes and styles from the model.

Next

Colour from the value – chapter 27 (Heatmap).

API used in this chapter