Improving component's re-render performance

Making components re-render faster by pre-computing our data

2022-07-093 min read
TL;DR
To avoid having noticeable lag between re-renders, we should identify our expensive computations and pre-compute them before they reach our components.
Re-renders in React isn't the devil. But it can be one when we dump all of our business logic there, including the most trivial data transformations.

The problem

Let's examine a common scenario.

We get the backend response, an array of objects.

TypeScript
main.ts
const data = await fetch('/api/books').then((res) => res.json());!

We map over this array in order to render a table

React
Component.tsx
<Table.Container>
  <Table.Header>
    <Table.HeaderCell>...</Table.HeaderCell>
    <Table.HeaderCell>...</Table.HeaderCell>
    <Table.HeaderCell>...</Table.HeaderCell>
  </Table.Header>
  {data.map((book) => (
    <Table.Row key={book.uuid}>
      <Table.Cell>
        <ComponentA propA={...book.propertyA} propB={...book.propertyB} />
      </Table.Cell>
      <Table.Cell>
        <ComponentB propC={...book.propertyC} />
      </Table.Cell>
      <Table.Cell>
        <ComponentC propA={...book.propertyA} propB={...book.propertyB} />
      </Table.Cell>
    </Table.Row>
  ))}
</Table.Container>

Each row of the table is a component that holds multiple sub-components

React
Component.tsx
<Table.Cell>
  <ComponentA {...book.propertyA} />
</Table.Cell>
table-1

There shouldn't be an issue, right? Our content changes only by applying filters, pagination, or sorting. All will trigger a fresh batch of data, so there won't be a noticeable change.

But, what if we implement a functionality where we can select a range of items? For example, we can pick the first ten items, the last ten items, or the whole page. Here's where things get tricky.

React
Component.tsx
<Table.Container>
  <Table.Header>
    <Table.HeaderCell>
      <Checkbox
        state={tableSelectionState} // 'ALL' | 'SOME' | 'NONE'
        onChange={handleAllSelection}
      />
    </Table.HeaderCell>
    <Table.HeaderCell>...</Table.HeaderCell>
    <Table.HeaderCell>...</Table.HeaderCell>
    <Table.HeaderCell>...</Table.HeaderCell>
  </Table.Header>
  {data.map((book) => (
    <Table.Row key={book.uuid}>
      <Table.Cell>
        <Checkbox
          isChecked={selectedBooks.includes(book.uuid)}
          onChange={handleCheckboxSelect}
        />
      </Table.Cell>
      <Table.Cell>
        <ComponentA propA={...book.propertyA} propB={...book.propertyB} />
      </Table.Cell>
      <Table.Cell>
        <ComponentB propC={...book.propertyC} />
      </Table.Cell>
      <Table.Cell>
        <ComponentC propA={...book.propertyA} propB={...book.propertyB} />
      </Table.Cell>
    </Table.Row>
  ))}
</Table.Container>
table-2

By clicking the 'select-all' checkbox in the header row, all the components will re-render. This is a very expensive operation.

The same applies for selecting a single row. The parent that holds the state will update and the rest of the components will re-render.

Oh no, it's slow. It has always been.

No point thinking about these pesky re-renders. Our components were slow from the start.

Extracting expensive computations

We should dig deep into our components and look for the expensive computations.

Commonly the culprits are:

  1. Date parsing/formatting
  2. Nested loops

By formatting our data the moment we get them from the backend, (adding an adapter if you may), we guarantee two things:

  • All the expensive computations are done on the get go, are co-located, easier tested and probably cached.
  • Future incompatibilities with the backend will be fixed in a single place

Fin

That pretty much sums it up. Before going crazy about multiple rerenders, memoizing everything, we should take a step back and figure out if our components are doing too much.

For more digging on composition, re-renders and et al, I suggest following up on this great read: The mystery of React Element, children, parents and re-renders


Resources