Managing Grids with React

Developing an Advanced Table System in Frontend

When working at Youleap, I needed to build a table similar to Airtable, with features like filters, sorting, and various column types. Furthermore, clicking on a cell would allow editing the cells according to their type.

The React-Table Library

I utilized the react-table library for this purpose. It’s a “headless” library that doesn’t render anything itself, giving me full control over the styling and rendering, while handling the data manipulation to make it easy to display in the UI. This helped with data management, as I only needed to define the columns and table data. For each column, I had to define its renderer and could access the table context as a prop to reach other elements See an example.

React-table is very convenient when you have data and want to define a table with different types of columns that you can control.

Using a Context Provider for Rendering Content above the Component

Sometimes the table required filters above it, so I had to move the data higher up in the React component tree. I defined a Context Provider for the table data (the return value of useReactTable), allowing components below to access it using useContext. This also helped with composability, enabling the use of the table without the filters and additions above it.

I would expose the table UI (Table component which internally uses the Context), the Context Provider (to support cases where the table state is high up and to manage its data), and a wrapper (BasicTable) containing both. This way, table users don’t have to think about the internal implementation if they don’t need anything that accesses the data. Occasionally, I had to add additional Contexts to access more data within the table, such as for cell selection, which wasn’t supported by react-table but was easy to achieve with additional state stored in a Context.

interface Props<T extends { id: Id }> {
  columns: Array<ColumnDef<T>>;
  data: Array<T>;
  isFullBorder: boolean;
}

export function BasicTable<T extends { id: Id }>(props: Props<T>) {
  const { columns, data, isFullBorder } = props;

  return (
    <BasicTableProvider data={data} columns={columns}>
      <Table isFullBorder={isFullBorder} />
    </BasicTableProvider>
  );
}

Inside the table, I used react-virtual to improve performance.

Cell Editing

The table was designed for both viewing and editing. At most, one cell was in edit mode at a time, so I stored the id of that cell in the CellSelectionProvider Context. For each cell, I had to define a view mode and an edit mode. Clicking on the cell or focusing on it would switch to edit mode, and changing the focus would save the cell on the server side while displaying the data using react-query’s optimistic updates. In react-query version 5, this became easier by using variables from useMutation, but it wasn’t available at the time, so I had to patch the cache.

Since I didn’t want to call the server for every character edit for partial editing, I had to maintain state for the value of each cell in edit mode.

I tried to reuse this behavior in an EditableCell component that received the following props (Props):

interface AppTableRowType {
  id: RowId;
  data: Record<string, unknown>;
}

interface TextCellEditComponentProps<T> {
  value: T;
  onBlur: (value: T) => void;
}

interface Props<T> {
  onEdit: (value: T) => void;
  cellContext: CellContext<AppTableRowType, unknown>;
  EditComponent: ComponentType<TextCellEditComponentProps<T>>;
  renderValue?: (value: T) => ReactNode;
}

This component stored the cell’s value in state for editing (and if it changed, I would sync it) and knew how to manage whether the cell is in edit mode or not by comparing the id against the CellSelectionProvider value. I used it for some cells, but sometimes the check for edit mode was done differently, and some cells had their own logic. Some opened a popover to display a more detailed view of the cell’s value, with an option to edit and save using a save button.

Almost every time I needed to change the cell to edit mode, I had to manually change the focus from the cell’s focus (in view mode) to an editable element (e.g., the input of the component in edit mode), and on onBlur, I had to call the backend and save the value. So almost every edit component had to manage the focus to it when it mounts and call the edit function when it loses focus or needs to apply the edit.

This wasn’t particularly convenient, as there were many types of cells with this logic. Each cell managed its own logic of entering and exiting focus.

Ag-Grid

At some point (and a different project), I built something that required less customization of the filters, so I used Ag-Grid. It’s an older library written in JavaScript that has plugins for various frameworks like React. It renders its own styles and contains many features, but allows customization of many things, including the cells.

In Ag-Grid, you define data and columns like I did. But their way of customizing the columns is different. They have view and edit props (cellRenderer and cellEditor) and allow providing cellEditorParams. What’s unique and different from my approach is the management of the editor’s state and focus. They leave that to the internal implementation of Ag-Grid to manage, instead of the cells managing it. Each cell can use React’s useImperativeHandle and return an object containing a getValue function, which returns the value of the table’s data in edit. See an example of it

The cells don’t receive an edit function - if a cell wants to signal an update, it simply makes the getValue return a different value. Whoever implements the cell usually creates internal state and calls setState whenever there’s an update. And when Ag-Grid decides on its own that there’s an edit (be it from onBlur or otherwise), it calls the cell’s getValue from outside the component to edit the table.

export const MyCustomCellEditor = forwardRef<
  ICellEditor<MyCustomData>,
  { value: MyCustomData }
>((props, ref) => {
  const { value } = props;
  const [valueState, setValueState] = useState(value);

  useImperativeHandle(ref, () => {
    return {
      getValue() {
        return {
          id: valueState.id,
          name: valueState.name,
          email: valueState.email,
        };
      },
    };
  });

  // ...
});

This is a simpler and more maintainable model than the edit function I provided as a prop to each cell, as the logic inside the cell editor component doesn’t throw events outwards. There’s the getValue function that determines the state of the table’s value - that’s it. The cell editor doesn’t determine when it closes or opens, just how it’s displayed and what value it has. It doesn’t contain the implementation of focus management and the transition between view and edit modes - it leaves that to the table itself in Ag-Grid. The table manages on its own when to call the edit and when the transition between view and edit changes.

Inversion of component control

What Ag-Grid did is a nice pattern in React. Sometimes, there’s a parent component that can be configured with different child components of various types, receiving props with callback functions like onEdit. Then, the child component calls those events by itself.

This pattern allows the parent component to manage everything and request the data from the child component if needed. The children need to be created with forwardRef and create a useImperativeHandle that returns an agreed-upon interface (e.g., a getValue function), and then the parent component calls getValue whenever appropriate, instead of giving that responsibility to the children.

Before:

before callback component pattern illustration

We changed it to:

callback component patter illustration

Conclusion

In this post, we reviewed the solution I developed for an advanced table system using the React-Table library. We highlighted the use of a Context Provider for managing the data and the transition between view and edit modes and the connection between the table and the editable cells.
We also explore a pattern inspired by Ag-Grid that allowed us to invert control and manage logic at the table level, while still getting data from the cell components.