AG Grid Cell Rendering Pipeline with TypeScript
Here at LiveLoveApp, we're big fans of AG Grid - the best JavaScript grid in the world. Why? For two primary reasons: performance and extensibility. Many of our clients use AG Grid to meet customer requirements for displaying tabular data.
In this article you'll learn:
- The AG Grid cell rendering pipeline
- How to leverage the new TypeScript generics provided by the AG Grid API (released in version 28)
- How to create a type-safe value getter to retrieve the value for a cell
- How to create a type-safe value formatted to format the value of a cell
- How to create a type-safe and performant cell renderer
AG Grid Cell Rendering Pipeline
Without any customization and in the simplest form, each cell in AG Grid is rendered as a string based on the field
specified in the provided row data.
However, often times an AG Grid implementation is not this simple.
This is where we can leverage the pipeline for rendering cells:
valueGetter()
valueFormatter()
cellRenderer()
Demo or it didn't happen
Here is a demo using React:
And, here is a demo using Angular:
Using the valueGetter()
callback function
First, we can use a valueGetter()
to fetch and/or mutate data in a cell using a provided callback function.
Let's take a quick look at an example.
In this example, the requirement is to create a value getter that is type-safe and uses the data provided to AG Grid to conditionally multiply a value within our data set.
export const multiplierValueGetter =
<T extends Record<TKey, number>,
TKey extends string | number | symbol = string>(
value: keyof T,
multiplier: keyof T
) =>
(params: ValueGetterParams<T>): number => {
if (params.data === undefined) {
return 0;
}
return Math.round(params.data[value] * params.data[multiplier] * 100) / 100;
};
Let's review the code above:
- First, we declare the
multiplierValueGetter()
higher-order function. Using a higher-order function enables us to define the generic typeT
that extends aRecord
whose values are of typenumber
. The higher-order function will return the value getter function that will be invoked by AG Grid with providedValueGetterParams<T>
. - The
multiplierValueGetter()
has two required parameters, first, thevalue
property, and second, themultiplier
property, both of which are keys of the data provided to the grid that is of typeT
. - Because we are using AG Grid v28 (or greater) we can specify the generic type of
T
for theValueGetterParams
. Prior to version 28, this generic type was not available, and as a result the type definition for thedata
property wasany
. - Within the value getter function, if
data
isundefined
, which can be the case when using infinite row model or row grouping in AG Grid, we return0
. - Finally, we can round the value after multiplying.
Here is an example implementation of our multiplierValueGetter()
higher-order function.
interface RowData {
value: number;
multiplier: number;
}
type Props = {
rowData: RowData[]
}
export default function Grid ({ rowData }: Props) {
const colDefs = [
{
colId: 'value',
headerName: 'Value',
field: 'value'
},
{
colId: 'multiplied',
headerName: 'Multiplied',
valueGetter: multiplierValueGetter<RowData>('value', 'multiplier')
}
] as ColDef<RowData>[];
return (
<AgGridReact
className="ag-theme-material"
columnDefs={colDefs}
rowData={rowData}
/>
);
}
Using the valueFormatter()
callback function
After the cell value is known, the optional valueFormatter()
callback function enables us to format the value.
Let's look at an example of using the valueFormatter()
callback function.
In this example, the requirement is to declare a reusable decimalValueFormatter()
higher-order function that is type-safe and formats the specified data property to a specified length.
export const decimalValueFormatter =
<TData, TValue>(digits = 0) =>
(params: ValueFormatterParams<TData, TValue>): string => {
const formatter = new Intl.NumberFormat('en-US', {
minimumFractionDigits: digits,
maximumFractionDigits: digits,
});
if (params.value === undefined) {
return formatter.format(0);
}
return formatter.format(Number(params.value));
};
Let's review the code above:
- We have declared a
decimalValueFormatter()
higher-order function. This enables the implementation of this value formatter to specify two generic types:TData
andTValue
. The generic ofTData
represents the type for thedata
parameter, and the generic ofTValue
represents the type for thevalue
parameter. Our higher-order function has an optionaldigits
parameter that specifies the min and maximum number of digits for the decimal formatting. The higher-order function returns a function that is the value getter that is invoked by AG Grid with theValueGetterParams<TData, TValue>
object. - In this value formatter, we are using the
Intl.NumberFormat
class to create a new formatter instance, specifying the minimum and maximum number of fraction digits. - If the
data
is undefined, which can be the case when using infinite row model or row grouping in AG Grid, then we simply return 0. - Otherwise, we return the formatted value.
Here is an example implementation of our decimalValueFormatter()
higher-order function.
interface RowData {
value: number;
multiplier: number;
}
type Props = {
rowData: RowData[]
}
export default function DashboardGrid ({ rowData }: Props) {
const colDefs = [
{
colId: 'value',
headerName: 'Value',
field: 'value'
},
{
colId: 'multiplied',
headerName: 'Multiplied',
valueGetter: multiplierValueGetter<RowData>('value', 'multiplier'),
valueFormatter: decimalValueFormatter<RowData, Pick<RowData, 'taxRate'>>(2)
}
] as ColDef<RowData>[];
return (
<AgGridReact
className="ag-theme-material"
colDefs={colDefs}
rowData={rowData}
/>
);
}
Using the cellRenderer()
callback function
After the value for a cell is determined, and we have optionally formatted the value, we can use a cell renderer to have full control of how a cell is rendered in AG Grid. By default, all values are rendered as a string. In order to render a cell other than a string, we can use a custom cell renderer.
It's important to note that we should only use a cell renderer when necessary.
By default, the textContent
of the cell HTML element is set to the (optionally formatted) value.
When we are using a cell renderer we are adding additional elements, event listeners, etc. to the DOM, all of which must be rendered for each cell in the grid.
Finally, we recommend that all cell renderers strictly use vanilla JS. This will improve the paint performance of your application when scrolling the grid. Why is that? If you use a framework (e.g. React, Angular, or Vue) then as a result each time the cell needs to be rendered, AG Grid must switch the context to a React (or Angular or Vue) application context in order to render the resulting HTML to the DOM. This can be very expensive and is often not necessary.
To configure a cell renderer we can provide AG Grid with:
- A string that references a registered framework component
- A class that implements the
ICellRendererComp
interface - A function that is invoked with the
ICellRendererParams
object
Let's look at an example. In this example, the user requirement is to display a column with a name that is optionally abbreviated, and, when a user clicks on the name, we want to open a dialog (which will not be the responsibility of AG Grid, but we need to notify the consumer that the user has clicked on the name).
First, let's define a new interface that describes the contract between the implementation and the cell renderer for the data that is expected.
export interface NameCellRendererData {
id: string;
name: string;
}
Next, let's define another interface for the click event that will notify the implementation that the user has clicked on the name.
export interface NameCellRendererClickEvent<T, E = Event> {
event: E;
data: T;
}
The NameCellRendererClickEvent
describes the event handler object that will be provided to a click
parameter that is implemented when using the cell renderer.
The interface has two generics:
- First, we define a generic of
T
that will be provided for the row data. - Second, we have a generic of
E
that has a default assignment to the globalEvent
interface. In the cell renderer we can set a type that is narrower.
Now, let's define another interface for the parameters that will be provided to the cell renderer.
export interface NameCellRendererParams<T> {
click: (event: NameCellRendererClickEvent<T>) => void;
document: Document;
isAbbreviated?: (params: ValueGetterParams<T>) => boolean;
}
A few things to note:
- First, we have declared the generic type of
T
in order to maintain type checking of theparams
object that is invoked for theisAbbreviated
function. - The
click
parameter will be a callback function that is invoked by the cell renderer. The callback function is invoked with anevent
parameter that is theNameCellRendererClickEvent
interface. - The
isAbbreviated
parameter is another callback function that enables the implementing grid to determine if a specific cell value should be abbreviated. We'll use theValueGetterParams
interface provided by AG Grid to keep our API ergonomic (in that we expect the developer to be aware of this existing interface, so it makes sense to use it).
Having described the API, let's look at the code for the cell renderer.
type Params<T> = NameCellRendererParams<T> & ICellRendererParams<T, string>;
/**
* AG Grid cell renderer for a user name.
*/
export class NameCellRenderer<T extends NameCellRendererData>
implements ICellRendererComp<T>
{
/** AG Grid API. */
private api: GridApi | null = null;
/** The button element. */
private btnEl: HTMLButtonElement | null = null;
/** Provided callback function that is invoked when the button is clicked. */
private click:
| ((event: NameCellRendererClickEvent<T, MouseEvent>) => void)
| null = null;
/** The column definition. */
private colDef: ColDef;
/** The AG Grid column. */
private column: Column | null = null;
/** AG Grid Column API. */
private columnApi: ColumnApi;
/** AG Grid context. */
private context: any;
/** The provided data. */
private data: T | undefined;
/** The global document. */
private document: Document | null = null;
/** Execution context bound function when the button is clicked. */
private handleClick:
| ((this: NameCellRenderer<T>, event: MouseEvent) => void)
| null = null;
/** Callback function to determinate if the name is abbreviated. */
private isAbbreviated?: (params: ValueGetterParams<T>) => boolean;
/** AG Grid row node. */
private node: RowNode;
/** The user name. */
private value: = '';
/** Value getter params to be provided. */
get valueGetterParams(): ValueGetterParams<T> {
return {
api: this.api,
colDef: this.colDef,
column: this.column,
columnApi: this.columnApi,
context: this.context,
data: this.data,
getValue: (field?: string) =>
this.data && field ? this.data[field] : this.value,
node: this.node,
};
}
init(params: Params<T>): void {
this.updateParams(params);
this.setGui();
}
destroy(): void {
if (this.handleClick !== null && this.btnEl !== null) {
this.btnEl.removeEventListener('click', this.handleClick);
}
}
getGui(): HTMLElement {
return this.btnEl!;
}
refresh(params: Params<T>): boolean {
this.updateParams(params);
const isAbbreviated = this.isAbbreviated?.(this.valueGetterParams) ?? false;
this.value = this.transform(params.value, isAbbreviated);
if (this.btnEl) {
this.btnEl.innerHTML = this.value;
}
return true;
}
private setGui(): void {
this.btnEl = this.document.createElement('button') as HTMLButtonElement;
this.btnEl.classList.add('user-name-cell');
this.handleClick = (event) => {
if (this.click) {
this.click({
event,
data: this.data,
});
}
};
const isAbbreviated = this.isAbbreviated?.(this.valueGetterParams) ?? false;
this.btnEl.innerHTML = this.transform(this.value, isAbbreviated);
this.btnEl.addEventListener('click', this.handleClick);
}
private updateParams(params: Params<T>): void {
this.api = params.api;
this.click = params.click;
this.colDef = params.colDef;
this.column = params.column;
this.columnApi = params.columnApi;
this.context = params.context;
this.data = params.data;
this.document = params.document;
this.isAbbreviated = params.isAbbreviated;
this.node = params.node;
this.value = params.value;
}
private transform(value: string, isAbbreviated: boolean): string {
if (isAbbreviated) {
return value.replace(/^Model/i, '');
}
return value;
}
}
Ok, phew. Let's review the code above.
- First, we define a new
Params
type that is a union of ourNameCellRendererParams
interface and the AG Grid providedICellRendererParams
. The generic typeT
is the provided type for the AG Grid row data, which we further provide to theICellRendererParams
interface. The second typescript generic is explicitly set tostring
as we expect that thevalue
of the cell will always be a string. - We export the
NameCellRenderer
class whose generic typeT
extends our previously definedNameCellRendererData
interface. This ensures that we have type safety between the row data provided to AG Grid and our cell renderer. As required, our class implements theICellRendererComp
interface from AG Grid. - We have a lot of properties that declared that will have references and values as necessary to pass to the
isAbbreviated
provided callback function. - Note that the
click
property is the provided callback function from the implementation that is invoked when the user clicks on the name. - Further, note that the
handleClick
property is an execution-bound function that we'll use within the cell renderer class for adding and removing the event listener. - The
valueGetterParams
property accessor method returns aValueGetterParams<T>
object that is used by the implementation to determine if a name is abbreviated or not. We have decided to use this interface from AG Grid to keep a consistent API for our users (those developers using our cell renderer in their AG Grid implementations). This is important for API ergonomics. - The
init()
,getGui()
,refresh()
, anddestroy()
methods are all implemented according to theICellRendererComp
interface from AG Grid. These methods provide hooks to initialize the cell renderer, provide an HTML element to be appended to the DOM by AG Grid when rendering a cell, and more hooks for when the data is refreshed and when the cell is destroyed. It's important that we use thedestroy()
lifecycle method to do any necessary cleanup, such as removing event listeners, to prevent memory leaks in our application.
Finally, here is an example implementation of the NameCellRenderer
.
interface RowData {
id: string;
name: string;
}
export default function DashboardGrid () {
const colDefs = [
{
colId: 'name',
field: 'name',
headerName: 'Name',
cellRenderer: NameCellRenderer,
cellRendererParams: {
click: ({ data }) => {
window.alert(`You clicked: ${data.name}`)
},
document,
isAbbreviated: ({ data }) => {
return data.name.length > 20;
},
} as NameCellRendererParams<RowData>
}
] as ColDef<RowData>[];
return (
<AgGridReact
colDefs={colDefs}
rowData={rowData}
/>
);
}
Summary
So In summary, we have learned how AG Grid renders a cell, and how we can provide data to a cell, optionally format a cell, and if necessary, customize the rendering of a cell. The key takeaways are:
- Use the
valueGetter()
callback function to fetch and/or mutate the value of a cell. - Use the
valueFormatter()
callback function to format a cell's value. - If necessary, provide a cell renderer to customize the HTML of a cell.
- Cell renderers can also be interactive, invoke callback functions, and more.
- It's important to remove event listeners when a cell is destroyed.
- Design an API that is ergonomic.
- Create value getters, value formatters, and cell renderers that are type safe.