Reactive Iteration - or - How to write a fast Table component

Published: 2023-06-27 by Lars  codeperformance

Sometimes your app needs to display a large data set with changes updating the display live. This could be an editable table with thousands of rows, a chart with thousands of points from a live feed, a live map or a deep tree structure. You want updates to happen efficiently, so the UI does not become sluggish and editing becomes annoying. You also want to write a re-usable component to handle this, so you can display different <Table>s and <Chart>s in your app.

The usual way to write such a reusable component is to pass in the data it needs as a "prop". But whenever the data is changing, the component will receive a new object, causing the entire tree or chart to re-render. This is not efficient.

In this post we will describe how to "pass a hook" instead of passing the data to solve this problem in a general way.

We will use an editable table as our example. Also these examples will depend on having "selectors" for app state, here provided by useSelector from Redux, but any other state management solution with selectors, such as Zustand, would work as well.

There is a full demo available on GitHub to supplement the code snippets included in the text below.

A large herd of gnus in Tanzania

(image courtesy of Wikimedia)

Initial implementation - no reusable component

Initially we might just write the table code and iterate the data directly inline without worrying about extracting a reusable <Table> component. The code would then basically look like this (with types and some memoization omitted for brevity throughout this post):

function ProductTable() {
  const stock = useSelector((state) => state.stock);
  const ids = Object.keys(stock);
  return (
    <table>
      <tbody>
        {ids.map((id) => (
          <ProductRow key={id} id={id} />
        ))}
      </tbody>
    </table>
  );
}

function ProductRow({id}: {id: string}) {
  const product = useSelector((state) => state.stock[id]);
  const { quantity, name } = product;
  const dispatch = useDispatch();
  const onClick = () => dispatch(increment({ id }));
  return (
    <tr>
        <td>
          {name}
        </td>
        <td>
          <button onClick={onClick}>{quantity}</button>
        </td>
    </tr>
  );
}

When clicking to increment the quantity of a product, only that row is re-rendered. This is because none of the ids passed to any of the ProductRow component change, and also the useSelector inside ProductRow only triggers a re-render if the return value is a new object, which is only the case for the changed product.

The demo with 10,000 rows looks like this:

Running demo using inline implementation

However, let's look at how we might extract a reusable <Table> component from this code.

Passing data as a prop - this is slow

Since the Table component need to access all the data, it is tempting to just pass in all the data as an array as a prop. Using the Table would then look like this:

function ProductTableSlow() {
  const stock = useAppSelector((state) => state.stock);
  const rows = Object.values(stock);
  const columns = [
    { name: "name", Cell: ({ row }) => <>{row.name}</> },
    {
      name: "quantity",
      Cell: function ({ row }) {
        const { id, quantity } = row;
        const dispatch = useAppDispatch();
        const label = `${row.name} quantity`;
        const onClick = () => { 
          dispatch(increment({ id }));
        };
        return <button aria-label={label} onClick={onClick}>{quantity}</button>;
      },
    },
  ];
  return (
      <TableSlow rows={rows} columns={columns} />
  );
}

However, this doesn't scale well to large data sets. Every change to a single product will cause the entire table to re-render, because no memoization can avoid the rows prop being a new object.

The demo with 10,000 rows is now really slow:

Running demo passing data as a prop

Passing a hook as a prop - this is fast

So, how can we avoid passing in all the data? The <Table> component still needs access to the data after all. Well, we can pass a "data accessor function" instead of the actual data, since such a function will not change when the data changes. And we need to make this data accessor function a custom React hook for two reasons:

  1. The data accessor needs access to app state (via useSelector) which only a hook can do.
  2. Only a hook is reactive, in the sense that it will trigger a re-render when it returns changed data.

Using the hook-based <Table> component will look like this:

function ProductTable() {
  const stock = useSelector((state) => state.stock);
  const ids = Object.keys(stock);
  function useRow(id) {
    return useSelector((state) => state.stock[id]);
  }
  const columns = [...]; // same as above
  return (
      <Table rowIds={ids} useRow={useRow} columns={columns} />
  );
}

Note how the hook follows the required naming convention: useRow which makes React treat it like a custom hook, allowing it to call useSelector. Let's see how this hook is then invoked inside the <TableRow> component.

function Table({ rowIds, useRow, columns }) {
  return (
    <table>
      <tbody>
        {rowIds.map((rowId) => (
          <TableRow key={rowId} rowId={rowId} useRow={useRow} columns={columns} />
        ))}
      </tbody>
    </table>
  );
}

function TableRow({ rowId, useRow, columns }) {
  const row = useRow(rowId);
  return (
    <tr key={row.id}>
      {columns.map((column) => {
        const { Cell, name } = column;
        return (
          <td key={name}>
            <Cell row={row} />
          </td>
        );
      })}
    </tr>
  );
}

Note how the useRow hook can be invoked just like any other hook with all the benefits of hooks being preserved.

And now, this <Table> scales nicely to 1000s of rows, because only the single <TableRow> instance with changed data gets re-rendered, triggered by useRow returning the new row object.

The demo with 10,000 rows is now nice and fast:

Running demo passing data as a prop

Is this even allowed?

Passing hooks as props does not appear to be a very common design pattern. However, this use of hooks follow the Rules of hooks as layed out by the React team, specifically:

  1. useRow is only called at the top level of the function component TableRow.
  2. useRow is not called inside any condition or loop.
  3. useRow is not called after a conditional return statement.
  4. useRow is not called from an event handler.
  5. useRow is not called from a class component.
  6. useRow is not called from inside a function passed to useMemo, useReducer or useEffect.

How can we test re-rendering performance?

When implementing re-usable components for potential large data sets, like our <Table>, it is important to also test the scalability of the component. We can verify the re-rendering performance by adding a trace call inside TableRow, like this:

function TableRow({ rowId, useRow, columns }) {
  countTrace();
  // ... as before
}

In production we make sure that countTrace is just defined as an empty function. During testing we will actually collect the counts. We can then write a test that verifies the re-rendering performance like this:

describe("ProductTable", () => {
  it('will re-render just that single row when it is changed', async () => {
    render(<Provider store={store}><ProductTable /></Provider>);

    const button = await screen.findByLabelText("Recycled Soft Pants quantity");
    expect(button).toHaveTextContent("0");
    expect(getTraceCount()).toEqual(10); // Initially, all rows are rendered
    resetTraceCount();
    fireEvent.click(button);
    await waitFor(() => {
      expect(screen.getByLabelText("Recycled Soft Pants quantity")).toHaveTextContent("1");            
    });
    expect(getTraceCount()).toEqual(1); // Only this single row is rendered
  });
});

You can find the full test in the linked repo.

Conclusion

I have used this technique on a couple of different projects in production over the past few years. With the information provided here, I hope that you can now easily try it out yourself for your next large-data reusable component!

Interestingly, this technique seems to be yet another example of the general principle in software engineering where many problems can be solved with just another level of indirection.

Discuss on Twitter