AG Grid Cell Rendering Pipeline with TypeScript

This post contributed to the AG Grid blog by Brian Love and originally posted here

Our friends at LiveLoveApp, are big fans of AG Grid. Why? For two primary reasons: performance and extensibility. Many of their 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:

  1. valueGetter()
  2. valueFormatter()
  3. 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 type T that extends a Record whose values are of type number. The higher-order function will return the value getter function that will be invoked by AG Grid with provided ValueGetterParams<T>.
  • The multiplierValueGetter() has two required parameters, first, the value property, and second, the multiplier property, both of which are keys of the data provided to the grid that is of type T.
  • Because we are using AG Grid v28 (or greater) we can specify the generic type of T for the ValueGetterParams. Prior to version 28, this generic type was not available, and as a result the type definition for the data property was any.
  • Within the value getter function, if data is undefined, which can be the case when using infinite row model or row grouping in AG Grid, we return 0.
  • 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 and TValue. The generic of TData represents the type for the data parameter, and the generic of TValue represents the type for the value parameter. Our higher-order function has an optional digits 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 the ValueGetterParams<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:

  1. First, we define a generic of T that will be provided for the row data.
  2. Second, we have a generic of E that has a default assignment to the global Event 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 the params object that is invoked for the isAbbreviated function.
  • The click parameter will be a callback function that is invoked by the cell renderer. The callback function is invoked with an event parameter that is the NameCellRendererClickEvent 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 the ValueGetterParams 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 our NameCellRendererParams interface and the AG Grid provided ICellRendererParams. The generic type T is the provided type for the AG Grid row data, which we further provide to the ICellRendererParams interface. The second typescript generic is explicitly set to string as we expect that the value of the cell will always be a string.
  • We export the NameCellRenderer class whose generic type T extends our previously defined NameCellRendererData 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 the ICellRendererComp 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 a ValueGetterParams<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(), and destroy() methods are all implemented according to the ICellRendererComp 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 the destroy() 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.

AG Grid trusts LiveLoveApp to deliver AG Grid workshops worldwide at conferences. They provide implementation support for AG Grid to ensure your project with AG Grid is successful. Learn more about their AG Grid implementation services at: https://liveloveapp.com/services/ag-grid.