Lazy-Loading Expandable Rows with React Table

This post is a continuation of my article on Using React Query with React Table. I recommend reading that first to understand the full context.

We will also be following along with the same set of example code which can be found at github.com/nafeu/react-query-table-sandbox. We will be focusing on the ReactTableExpanding.jsx file.

You can get it quickly set up with:

git clone https://github.com/nafeu/react-query-table-sandbox.git
cd react-query-table-sandbox
npm install
npm start

The Expandable Table We Want To Build

Understanding Our Data

When we think of table data, it’s easier to visualize when we consider it to be in the form of a flat structure, for instance, the following collection only has a single layer of depth:

[
  { id: 1, name: 'foo' },
  { id: 2, name: 'bar' },
  { id: 3, name: 'baz' }
]

And in a table would render:

But what if we had more of a hierarchical (or tree-like) structure such as:

[
  {
    id: 1,
    name: 'foo'
    children: [
      { id: 4, name: 'qux' },
      { id: 5, name: 'quux' }
    ]
  },
  { id: 2, name: 'bar' },
  { id: 3, name: 'baz' }
]

This is where things get wonky and we have to shift our approach a tiny bit. Let’s say we want to render this, but only render the children when we click on an individual row. For example, if we were to click on the row of id: 1, we would want to see a flat table rendered like:

In the real world, we have many instances where this is the case and they come in the form of drill-down tables. This is helpful for BI dashboards, video game leaderboards, accounting, or pretty much any case where you need further investigate a single row in a table.

In index.mjs from our example code, we have defined an API that returns mock data about an imaginary software dev discussion group.

The api/ endpoint returns data in the form of:

[
  {
    "id": 1,
    "name": "How to use react-table in reporting dashboard",
    "active": 40,
    "status": "locked",
    "upvotes": 30
  },
  {
    "id": 2,
    "name": "How to use react-query for BI solution",
    "active": 31,
    "status": "resolved",
    "upvotes": 39
  }
  ...
]

The api/child endpoint returns similar data but provides new entries every time, whereas the root api/ endpoint returns the original payload with slight modifications. This is to simulate an “active website” where data changes in pseudo real-time.

Although the example is a bit rough, imagine for a moment that each of these discussion topics have sub-issues which are structured in the same way. These will be used to persist a “hierarchical” structure in our table.

In our front-end code, we have:

const fetchParentData = () => axios.get(`http://localhost:8000/api`);
const fetchChildData = () => axios.get(`http://localhost:8000/api/child`);

Which are promise-based API helpers that retrieve the partially dynamic parent data and novel set of child data respectively (it is required for React Query to use promise-based API helpers).

Recursively Modifying Row Data

Before we dig into the react code, we have to figure out a way of recursively inserting one set of rows into an existing set of rows, for instance, how do we get from:

row-0
row-1
row-2

to

row-0
  row-0.0
  row-0.1
row-1
row-2

and even further to

row-0
  row-0.0
    row-0.1.0
    row-0.2.1
  row-0.2
row-2
row-3

and so on and so forth. In our example code, we do this using the recursivelyUpdateTable function which is as follows:

const recursivelyUpdateTable = ({ tableData, childData, id }) => {
  return insertIntoTable({
    existingRows: tableData,
    subRowsToInsert: childData,
    path: id.split('.')
  });
}

const insertIntoTable = ({ existingRows, subRowsToInsert, path }) => {
  const id = path[0];
  let updatedRows;

  const isBaseCase = path.length === 1;

  if (isBaseCase) {
    return existingRows.map((row, index) => {
      const isMatchedRowWithoutSubRows = index === Number(id) && !row.subRows;

      if (isMatchedRowWithoutSubRows) {
        return {
          ...row,
          subRows: subRowsToInsert
        }
      }

      return row;
    });
  }

  return existingRows.map((row, index) => {
    const isMatchedRowWithSubRows = index === Number(id) && row.subRows;

    if (isMatchedRowWithSubRows) {
      const [, ...updatedPath] = path;

      return {
        ...row,
        subRows: insertIntoTable({
          existingRows: row.subRows,
          subRowsToInsert,
          path: updatedPath
        })
      }
    }

    return row;
  });
}

Here we take:

We use these values and some basic knowledge of recursive algorithms to first grab our current path for traversal:

const id = path[0];

Then we formalize our base case (which in our case is when there just one path index left), decide if the node that we have reached has sub-rows or not, and then insert the row:

const isBaseCase = path.length === 1;

if (isBaseCase) {
  return existingRows.map((row, index) => {
    const isMatchedRowWithoutSubRows = index === Number(id) && !row.subRows;

    if (isMatchedRowWithoutSubRows) {
      return {
        ...row,
        subRows: subRowsToInsert
      }
    }

    return row;
  });
}

If we are not in our base case, we recursively use the function insertIntoTable and feed in a subset version of our current rows:

return existingRows.map((row, index) => {
  const isMatchedRowWithSubRows = index === Number(id) && row.subRows;

  if (isMatchedRowWithSubRows) {
    const [, ...updatedPath] = path;

    return {
      ...row,
      subRows: insertIntoTable({
        existingRows: row.subRows,
        subRowsToInsert,
        path: updatedPath
      })
    }
  }

  return row;
});

This may be a little complicated to get, especially if you haven’t written much recursive code in a while, so feel free to take your time to trace through and understand it.

The purpose of this article isn’t to focus on recursion but instead to focus on using React Table to pull off the lazy-loading expandable rows. This helper however is still a very important part of it.

Building On Our React Query + React Table Proposal

In my previous post I proposed an outline for combining React Query and React Table, this code also exists in our example code at ReactQueryWithTable.jsx

The proposal consists of structuring a query layer, data processing layer and rendering layer between the three components as follows:

TableQuery
TableInstance
TableLayout

Let’s build on top or expand on this example, refer now to ReactTableExpanding

Updating our TableQuery Component

Let’s take a look at our TableQuery component. We create a new variable called isRowLoading and implement a handleClickRow handler function which will be called anytime an individual row is clicked on our table.

It should be noted that isRowLoading is a loading state variable. It is a mapping of paths to a boolean value, that helps us keep track of which row is currently in the process of fetching data:

const [isRowLoading, setIsRowLoading] = useState({});

const handleClickRow = async ({ id }) => {
  setIsRowLoading({ [id]: true });

  const { data: childData } = await fetchChildData();

  setIsRowLoading({ [id]: false })

  if (tableData) {
    const updatedTableData = recursivelyUpdateTable({ tableData, childData, id });

    setTableData(updatedTableData);
  }
}

We also use recursivelyUpdateTable to modify our existing table data with the child data that has been received from our API, this is why our recursive function is so important. One of the beauties of React Table is that it already knows how to work with embedded structures, all you need to do is provide them using a subRows property inside an individual row and it takes care of the rest.

We also have this invocation of useQuery that we modify:

const {
  data: apiResponse,
  isLoading
} = useQuery('discussionGroups', fetchParentData, { enabled: !tableData });

We add enabled: !tableData to prevent the table form re-fetching in the background as it can mess up the embedded structure. Implementing re-fetching with an embedded structure is a more advanced topic that won’t be covered in this article.

We also pass some more props into the TableInstance like so:

return (
  <TableInstance
    tableData={tableData}
    onClickRow={handleClickRow}
    isRowLoading={isRowLoading}
  />
);

Updating our TableInstance Component

Aside from utilizing two new props onClickRow and isRowLoading, we will mainly focus on a new column header that we add:

{
  Header: () => null,
  id: 'expander',
  Cell: ({ row, isLoading, isExpanded }) => {
    const toggleRowExpandedProps = row.getToggleRowExpandedProps();

    const onClick = async event => {
      if (!isLoading) {
        if (!isExpanded) {
          await onClickRow(row);
        }
        toggleRowExpandedProps.onClick(event);
      }
    }

    if (isLoading) {
      return <span>🔄</span>
    }

    return (
      <span
        {...row.getToggleRowExpandedProps({
          style: {
            paddingLeft: `${row.depth}rem`,
          },
        })}
        onClick={onClick}
      >
        {row.isExpanded ? '🔽' : '▶️'}
      </span>
    )
  },
},

Here is where some of the magic happens. We use the Cell property to instruct React Table on how it should format an individual cell, in this instance, we add an additional column in front of our table which will contain the expander button and we also add some additional logic to trigger our custom onClickRow handler if the row is in the appropriate loading and expanding state.

You may be wondering, where did { row, isLoading, isExpanding } come from? The row object is actually something that React Table provides for us to access when using the Cell property, this is super helpful for us as row also has a useful method called getToggleRowExpandedProps() which we can use to manually trigger React Table’s row expansion functionality.

We also add the useExpanded hook in our table instance instantiation with the option autoResetExpanded set to false:

const tableInstance = useTable(
  { columns, data, autoResetExpanded: false },
  useExpanded
);

And we pass the isRowLoading object into the TableLayout:

return (
  <TableLayout
    {...tableInstance}
    isRowLoading={isRowLoading}
  />
);

Updating our TableLayout Component

In our TableLayout component, we extract an extra prop state.expanded out of our tableInstance. This is provided to us by React Table thanks to the useExpanded hook, it provides a mapping similar to how we defined our isRowLoading mapping.

const TableLayout = ({
  getTableProps,
  getTableBodyProps,
  headerGroups,
  rows,
  prepareRow,
  isRowLoading,
  state: { expanded }
}) => {
  ...
}

The main update is in our individual row rendering:

return (
  <tr {...row.getRowProps()}>
    {row.cells.map(cell => {
      return <td {...cell.getCellProps()}>
        {cell.render('Cell', {
          isLoading: isRowLoading[row.id],
          isExpanded: expanded[row.id]
        })}
      </td>
    })}
  </tr>
)

In the cell.render method, we add an extra object that is filled with:

{
  isLoading: isRowLoading[row.id],
  isExpanded: expanded[row.id]
}

This works in tandem with our previously declared code in the TableInstance component where we are able to feed custom values directly into a Cell. We defined isRowLoading -> isLoading ourselves and are feeding expanded -> isExpanded to enforce loading state and expanded state. Doing so isn’t even that hard to read, that is what makes React Table so incredible to work with.

Once you’ve got all of this put together, you’ve got yourself this fancy table:

Happy Coding!