Miłosz Orzeł

.net, js, html, arduino, java... no rants or clickbaits.

Resizing All ag-Grid (React) Columns

TL;DR

You can use measureText method to calculate widths of text and then use setColumnWidth to adjust columns. This the demo and this is the repo. It's quite likely that you don't need this technique (suppressColumnVirtualisation might be enough), but read on if your grid has huge amount of columns and users need high data density...

 

VIRTUALIZATION VS RESIZE

I've been porting some old UI with grids based on HTML table to ag-Grid. Users appreciate the features added by ag-Grid (such as filters, pinning, sorting, resizing, reordering, grouping...) but noticed one change for worse: not all columns were automatically sized to take the smallest amount of space possible. In enterprise applications data density is really important (not recognizing that is a recipe for failure). Too much white space means that people might miss important information or have to waste time scrolling...

ag-Grid is is capable of handing huge amount of rows and columns thanks to virtualization. The gird creates elements only for rows and columns which are currently visible (plus some buffer) to avoid producing bloated DOM (too many elements severely degrade performance). Virtualization means that the API methods designed to automatically resize columns are only capable of resizing the rendered columns. ag-Grid has suppressColumnVirtualisation option but in my case girds can have as much as 300 columns so turning virtualization off is not possible for performance reasons.

 

THE SOLUTION

So is it possible to resize all columns while enjoying the benefits of virtualization? If your grid cells contain only text and you know the font then yes, it's possible.

You can calculate the width of text for each grid cell and collect the minimal width needed for each of the gird columns. ag-Grid API might then be used to set desired column sizes. Text width calculation could be done efficiently with measureText method on Canvas.

Here's a module that can collect minimal width needed for each column (it doesn't have any dependency on React):

const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');

const collectMaxWidth = (text, group, maxWidths) => {
    const width = context.measureText(text).width;

    const maxWidth = maxWidths.get(group);

    if (maxWidth === undefined || width > maxWidth) {
        maxWidths.set(group, width);
    }
};

const collectMaxWidthCached = (text, group, maxWidths, widthsCache) => {
    const cachedWidth = widthsCache.get(text);

    let width;

    if (cachedWidth === undefined) {
        width = context.measureText(text).width;
        widthsCache.set(text, width);
    } else {
        width = cachedWidth;
    }

    const maxWidth = maxWidths.get(group);

    if (maxWidth === undefined || width > maxWidth) {
        maxWidths.set(group, width);
    }
};

const calculateColumnWidths = config => {
    console.time('Column widths calculation');

    const maxWidths = new Map()

    if (config.measureHeaders) {
        context.font = config.headerFont;

        config.columnDefs.forEach(column => {
            collectMaxWidth(column.headerName, column.field, maxWidths);
        });
    }

    context.font = config.rowFont;

    config.rowData.forEach(row => {
        config.columnDefs.forEach(column => {
            if (config.cache) {
                collectMaxWidthCached(row[column.field], column.field, maxWidths, config.cache);
            } else {
                collectMaxWidth(row[column.field], column.field, maxWidths);
            }
        });
    });

    const updatedColumnDefs = config.columnDefs.map(cd => ({
        ...cd,
        width: Math.ceil(maxWidths.get(cd.field) + config.padding)
    }));

    console.timeEnd('Column widths calculation');

    return updatedColumnDefs;
};

export default calculateColumnWidths;

The module creates canvas element and then a 2d context is retrieved from it. The context is used in collectMaxWidth function to measure size of provided text by invoking context.measureText(text).width

The module also has collectMaxWidthCached function which can offer a performance improvement based on the fact that grid data is often repetitive. If some string was already measured, there's no need to use canvas API again - taking the value from JavaScript Map is super quick. So unless you are extremely worried about memory limits, use the cached version.

The module exports calculateColumnWidths function which takes config object with following properties:

  • columnDefs - Array used to specify ag-Grid columns (limit this if you have hidden columns).
  • rowData - Array of grid records (limit this if you use paging or filtering).
  • measureHeaders - If true then column headers should be taken into account (caveat: header icons are ignored). 
  • headerFont - Determines column header font.
  • rowFont - Determines normal grid cell font.
  • padding - Additional width added to measured text (you need to choose it experimentally, mind varying header icons).
  • cache - JS Map used for speed boost (length of each unique non-header text is measured only once), pass null to skip caching.

Below is an example of button's click handler from my demo app what uses calculateColumnWidths:

const handleResizeWithCustomClick = () => {
    console.time('Resize all columns (including widths calculation)');

    if (gridApi && gridColumnApi) {
        // Here ALL columns and rows are used because there are no hidden columns
        // and the the grid has neither paging nor filtering enabled!
        const updatedColumDefs = calculateColumnWidths({
            columnDefs,
            rowData,
            measureHeaders: true,
            headerFont: 'bold 12px Arial',
            rowFont: 'normal 12px Arial',
            padding: 30,
            cache: useWidthsCache ? textWidthsCache : null
        });

        // Setting width by setColumnWidth has the advantage of preserving column
        // changes done by user such as sorting or filters. The disadvantage is that
        // initial resize might be slow if the grid was scrolled towards later columns
        // before resizing was invoked (bug in the gird?).
        // Resize by gridApi.setColumnDefs(updatedColumDefs) or setColumnDefs(updatedColumDefs)
        // should be faster but columns settings could be reset (mind deltaColumnMode)...            
        updatedColumDefs.forEach(def => gridColumnApi.setColumnWidth(def.field, def.width));
    }

    console.timeEnd('Resize all columns (including widths calculation)');
};

Notice how setColumnWidth from ag-Grid Column API is used to apply calculated width. Mind the comments about paging/filtering and the difference between using setColumnWidth and calling Grid API setColumnDefs or updating state bound to grid's columnDefs property...

 

DEMO APP

Live demo: https://morzel85.github.io/blog-post-ag-grid-full-resize 
Source code on GitHub: https://github.com/morzel85/blog-post-ag-grid-full-resize

The app uses React 16.13.1 and ag-Grid Community 23.0.1 (I've tested it in Chrome 80, Firefox 74, Edge 44).
Clone the repo, do npm install and npm start to run the app locally (just like with any other thing started with create-react-app)...

Usage:
Click on "Generate data (100 rows with 300 columns)" button to create 30K grid cells. You should then click the "Resize columns with columnApi.autoSizeColumns" button and scroll to the right to notice that only visible columns were resized:

Resizing by autoSizeColumns... Click to enlarge...


Now reload the page, click data generation again and press "Resize columns with custom text measure" and scroll the gird horizontally. You should notice that all columns were resized:

Resizing by measureText... Click to enlarge...


Clicking resizing button the the second time should give you better performance because of text measure caching and because ag-Grid itself has less work to do. These are the timings from Chrome: 

Resizing perfromance in Chrome... Click to enlarge...
I would say that 100ms for 30K cell measure is quite fast! With caching it drops to 5ms! You may also notice that most of the time is not really spent in my text measuring function but in the ag-Grid which handles columns resize. 

I've noticed one weird performance issue: if you scroll to the right of the grid before clicking the resize button, the time spent by ag-Grid handling columnApi.autoSizeColumns calls increases significantly. Doing bulk resize with gridApi.setColumnDefs or by updating array used in for columnDefs property solves the performance issue at the cost of column settings reset (exact behavior depends on deltaColumnMode)...

Update 2020-13-04:
ag-Grid team confirmed that there's indeed an issue with setColumnWidth performance and the fix should be ready later this month.

ag-Grid API Access with Hooks

TL;DR

Utilize useState or useRef hooks and gridReady event to maintain access to Grid API and Column API. Check here for code samples.

 

AG-GRID IN REACT 16.8.0+

ag-Grid has an impressive set of features, matched by unusually comprehensive documentation. The issue is that at this moment (February 2020), most of the React-specific examples are written for class components. Because of this, using the grid inside a function (functional) component could be slightly challenging. One of the things that are not so obvious is how to access Grid API and Column API...

Grid and Column APIs have over 150 methods that let you manipulate everything from cell focus to column width. The docs suggest obtaining and saving references to these APIs inside gridReady event handler:

class GridExample extends Component {
  // Irrelevant parts removed for brevity... 

  onGridReady = params => {
    this.gridApi = params.api;
    this.gridColumnApi = params.columnApi;
  }

  render() {
    return (
		<AgGridReact          
            onGridReady={this.onGridReady}            
        />  
    );
  }	
}

Such approach works nicely in a class component but doesn't work when component is "just" a function. Worry not! useState and useRef hooks (part of React 16.8.0) can help you access the APIs in a function component and it's all quite easy

I've asked ag-Grid team if they have a preference between useState and useRef and they don't, so I will show you the two options and you can pick the one that suites you.

Here's a component that uses selectAll method from Grid API and moveColumnByIndex method from Column API with the help of useState hook:

import React, { useState } from "react";
import { AgGridReact } from "ag-grid-react";

import { columnDefs } from "./columnDefs";
import { rowData } from "./rowData";

import "ag-grid-community/dist/styles/ag-grid.css";
import "ag-grid-community/dist/styles/ag-theme-balham.css";

const AgGridWithUseState = () => {
  console.log("AgGridWithUseState Render");

  const [gridApi, setGridApi] = useState();
  const [columnApi, setColumnApi] = useState();

  return (
    <div className="example">
      <h1>API access with useState</h1>
      <div>
        <button
          onClick={() => gridApi && gridApi.selectAll()}>
          Grid API selectAll()
        </button>
        <button 
          onClick={() => columnApi && columnApi.moveColumnByIndex(0, 1)}>
          Column API moveColumnByIndex(0, 1)
        </button>
      </div>
      <div className="ag-theme-balham">
        <AgGridReact
          columnDefs={columnDefs}
          rowData={rowData}
          rowSelection="multiple"
          onGridReady={params => {
            console.log("AgGridWithUseState Grid Ready");
            setGridApi(params.api);
            setColumnApi(params.columnApi);
          }}
        />
      </div>
    </div>
  );
};

export default AgGridWithUseState;

and below is the exact same thing done with useRef:

import React, { useRef } from "react";
import { AgGridReact } from "ag-grid-react";

import { columnDefs } from "./columnDefs";
import { rowData } from "./rowData";

import "ag-grid-community/dist/styles/ag-grid.css";
import "ag-grid-community/dist/styles/ag-theme-balham.css";

const AgGridWithUseRef = () => {
  console.log("AgGridWithUseRef Render");

  const gridApi = useRef();
  const columnApi = useRef();

  return (
    <div className="example">
      <h1>API access with useRef</h1>
      <div>
        <button
          onClick={() => gridApi.current && gridApi.current.selectAll()}>
          Grid API selectAll()
        </button>
        <button
          onClick={() => columnApi.current && columnApi.current.moveColumnByIndex(0, 1)}>
          Column API moveColumnByIndex(0, 1)
        </button>
      </div>
      <div className="ag-theme-balham">
        <AgGridReact
          columnDefs={columnDefs}
          rowData={rowData}
          rowSelection="multiple"
          onGridReady={params => {
            console.log("AgGridWithUseRef Grid Ready");
            gridApi.current = params.api;
            columnApi.current = params.columnApi;
          }}
        />
      </div>
    </div>
  );
};

export default AgGridWithUseRef;

Check the function assigned to onGridReady property to find out how to retrieve and keep references to the APIs. Button's onClick handlers show you how to use the APIs.

 

USEREF VS USESTATE (DEMO)

Live demo: https://morzel85.github.io/blog-post-ag-grid-api-access-with-hooks 
Source code on GitHub: https://github.com/morzel85/blog-post-ag-grid-api-access-with-hooks

The app uses React 16.12.0 and ag-Grid Community 22.1.1 (I've tested it in Chrome 80, Firefox 73, Edge 44).
Clone the repo, do npm install and npm start to run the app locally (just like with any other thing started with create-react-app)...

Demo shows access to selectAll method from Grid API and moveColumnByIndex method from Column API. This application has two grids that differ only in the way the APIs are accessed (one uses useState hook, the other goes with useRef hook).

What's the difference between useState and useRef version?

It boils down to the way hooks are designed to work. useState causes component rerender and useRef doesn't. You can see the difference if you open the console and check the log:

useState vs useRef - render calls

Notice two "AgGridWithUseState Render" lines after "AgGridWithUseState Grid Ready".

This happens because Grid and Column APIs are set by two calls to state hook setters and that causes component rerenders. Sounds bad? Remember that in React, a component rerender doesn't necessary mean any DOM change has to be applied. ag-Grid is also smart enough to avoid unnecessary work. useState triggers rendering by design and it lets you write the component in more declarative way. For example you could keep rowData and columnDefs in state and have the grid update itself without explicit API calls...

How about the useRef approach? Use it if you don't need to run any action as a result of API reference being set.