Ian Obermiller

Part time hacker, full time dad.

Highlight the row and column of a hovered table cell in React & Recoil

Posted 2020-09-07

Contents

Intro

I recently came across an internal post at Facebook asking if Recoil would be a good solution for highlighting the row and column of a table when you select or hover an individual cell. They had been using React context for this, but were running into performance issues.

Inspired by the question, I took a look at the different methods for highlighting a table row and column with React, including plain old state, context, Recoil, and a custom event emitter. This post will compare the code and performance of each solution, and I'll provide some takeaways at the end.

Setup

We'll start out with a simple table, broken into a few components. I used create-react-app to bootstrap the project:

yarn create react-app table-highlighting

Then, replace App.js with the following code, which simply renders a large table, breaking the table, row, and cell into separate components.

import React from 'react';

const ROWS = 400;
const COLUMNS = 30;

function App() {
  return <Table />;
}

function Table() {
  return (
    <table>
      <tbody>
        {
          // One-liner for _.range
          Array(ROWS)
            .fill()
            .map((_, i) => (
              <TableRow key={i} row={i} />
            ))
        }
      </tbody>
    </table>
  );
}

function TableRow({row}) {
  return (
    <tr>
      {Array(COLUMNS)
        .fill()
        .map((_, i) => (
          <TableCell key={i} row={row} column={i} />
        ))}
    </tr>
  );
}

function TableCell({row, column}) {
  return (
    <td>
      {row}x{column}
    </td>
  );
}

export default App;

Plain table example

Alright, so we have a big table. Next, we want to highlight the entire column and row whenever you hover over a single cell. Let's start with the simplest solution posible, React component state at the table root.

React state

For our first try, we will store the highlighted cell's row and column in the top level Table's component state.

function Table() {
  const [highlightedCell, setHighlightedCell] = useState({
    row: -1,
    column: -1,
  });
  // ...
}

The state, along with a setter, will be passed down through every row into each TableCell component, which will apply its highlighted style and call the setter onMouseEnter.

function TableCell({
  row,
  column,
  highlightedCell,
  setHighlightedCell,
}) {
  const isHighlighted =
    highlightedCell.row === row ||
    highlightedCell.column === column;

  return (
    <td
      onMouseEnter={() => setHighlightedCell({row, column})}
      style={isHighlighted ? highlightedStyle : null}>
      {row}x{column}
    </td>
  );
}

Try it out for yourself below. I've added in a timing hook to let us measure the performance of each alternative.

Table with state example

On my machine, hovering a cell takes about 60ms 1. This is ok, but won't get you to 60fps and may not scale with a larger table. Why, though? You'll notice that this implementation re-renders everything on every hover; the main Table, every TableRow, and every TableCell. We only need to update the TableCell though. Luckily, "Context provides a way to pass data through the component tree without having to pass props down manually at every level." (Source: React Docs).

React context

Next, we will modify the example to pass the highlighted cell down via context.

const TableHighlightContext = React.createContext();

function Table() {
  const [highlightedCell, setHighlightedCell] = useState({
    row: -1,
    column: -1,
  });
  return (
    <TableHighlightContext.Provider value={highlightedCell}>
      <table>...</table>
    </TableHighlightContext.Provider>
  );
}

Memoize TableRow so that it doesn't re-render.

const TableRow = React.memo(({row, setHighlightedCell}) => {
  // ...
});

And finally read the value in TableCell from context instead of props.

function TableCell({row, column, setHighlightedCell}) {
  const highlightedCell = React.useContext(
    TableHighlightContext,
  );
  const isHighlighted =
    highlightedCell.row === row ||
    highlightedCell.column === column;
  // ...
}

Table with context example

Using context doesn't really affect the performance, resulting in about 55ms again on my machine. It isn't that much faster because all 12,000 TableCell components are still re-rendering every single time you hover a cell. But, we know that we only need to re-render the cells that are changing, which should be at most two rows and columns worth, or 860 cells.

Recoil is one (of many) state libraries for React that can help with the problem of granular state updates.

Recoil

The problem with context is that too many components were re-rendering. Recoil is a state management library that allows components to subscribe only to the atomic units of state (called atoms) that they need to render. So, each cell should be able to subscribe to the value it needs (that is, to know whether or not it is highlighted) and only re-render when that value changes.

To integrate Recoil, we'll create an atom to hold the highlightedCell.

const highlightedCell = atom({
  key: 'highlightedCell',
  default: {row: -1, column: -1},
});

Wrap Table in RecoilRoot.

function Table() {
  return (
    <RecoilRoot>
      <table>...</table>
    </RecoilRoot>
  );
}

Create a selectorFamily that will create a new selector for each cell, returning a boolean indicating whether or not this cell is highlighted.

const isHighlightedSelector = selectorFamily({
  key: 'isHighlightedSelector',
  get: ({row, column}) => ({get}) => {
    const h = get(highlightedCell);
    return h.row === row || h.column === column;
  },
});

And finally use the atom and the selector family in the TableCell component:

function TableCell({row, column}) {
  const isHighlighted = useRecoilValue(
    isHighlightedSelector({row, column}),
  );
  const setHighlightedCell = useSetRecoilState(
    highlightedCell,
  );
  return (
    <td
      onMouseEnter={() => setHighlightedCell({row, column})}
      style={isHighlighted ? highlightedStyle : null}>
      {row}x{column}
    </td>
  );
}

Table with Recoil example

On my machine, the Recoil example is surprisingly the slowest, at 300ms per hover. I'm not actually sure why, and I'll update this post after asking the Recoil team. From a cursory look, all the TableCells were re-rendering twice for each change to the atom, which would of course be slow.

Recoil has another performance issue, which you'll have noticed if you started the example above. It takes a considerable amount of time to start. This is because selectorFamily actually creates a selector (and its associated entry in the Redux root store) for every single cell, which (as of this version of Recoil) is not very optimized.

We've gone through state, context, and Recoil, the only thing left is to roll our own customized state management.

Custom event emitter

As mentioned before, for peak performance we need to make sure all 12,000 TableCell components don't re-render at once. Only the cells that are changing need to re-render, which should be at most two rows and columns worth, or 860 cells.

We can accomplish this by rolling our own simple event emitter to listen for the hoverer cell (identified by the row and column) to change, and then only notifying the cells that are changing by calling a callback, which will in turn set some local state.

We'll start out with the event emitter.

// Use an IIFE to contain the variable declarations. In a
// production app, I'd probably make this a class so you can
// test the instances, and also throw it into context to
// avoid the global.
const emitter = (() => {
  // 2d array indexed by row and column
  const subs = [];
  return {
    // Subscribe function that will be called once for each
    // cell, in a useEffect
    subscribe(r, c, cb) {
      subs[r] = subs[r] ?? [];
      // Note that this does not handle multiple
      // subscriptions for the same cell
      subs[r][c] = cb;
      // This will be invoked by useEffect's cleanup
      return () => delete subs[r][c];
    },
    // Called by each cell when it is hovered
    highlight(newRow, newCol) {
      subs.forEach((row, r) => {
        row.forEach((cb, c) => {
          const isHighlighted =
            r === newRow || c === newCol;
          // useState won't rerender the component if the
          // value hasn't changed, so we can call it for
          // every cell
          cb(isHighlighted);
        });
      });
    },
  };
})();

The implementation is rather simplistic, and does not handle multiple subscriptions for the same cell, but it will suffice for our purposes.

Next we update TableCell to subscribe to the event emitter and keep its own highlighted state.

function TableCell({row, column}) {
  const [isHighlighted, setIsHighlighted] = useState(false);
  useEffect(() => {
    // Subscribe and return the result to remove the
    // callback when the component unmounts
    return emitter.subscribe(row, column, setIsHighlighted);
  }, [column, row]);
  return (
    <td
      onMouseEnter={() => emitter.highlight(row, column)}
      style={isHighlighted ? highlightedStyle : null}>
      {row}x{column}
    </td>
  );
}

Table with custom event emitter example

This implementation is the fastest yet, at 45ms. The speed here comes from only rerendering the cells that change. But can we do better?

Direct DOM manipulation

You'll notice a pattern of cutting React out of the picture, which tends to speed things up. By modeling the highlighting with a domain specific event emitter, we only call React when the component actually needs to change. However, we don't actually need to use React state to update the component. Instead, we can get a ref to the underlying DOM element and update the styles directly.

Since we will be doing work in every callback, and React won't be deduping, we want to avoid calling the subscriber's callback if the highlighted state isn't changing for that cell.

const emitter = (() => {
  // Keep track of the currently highlighted row and column
  let currentRow = -1;
  let currentCol = -1;
  const subs = [];
  return {
    subscribe(r, c, cb) {
      subs[r] = subs[r] ?? [];
      subs[r][c] = cb;
      return () => delete subs[r][c];
    },
    highlight(newRow, newCol) {
      subs.forEach((row, r) => {
        row.forEach((cb, c) => {
          const wasHighlighted =
            r === currentRow || c === currentCol;
          const isHighlighted =
            r === newRow || c === newCol;
          // Only notify if the highlighting for this row
          // has changed. We could optimize this loop to
          // only run for the changed rows, but you're
          // unlikely to see noticable gains.
          if (wasHighlighted !== isHighlighted) {
            cb(isHighlighted);
          }
        });
      });

      // Update the currently highlighted cell, otherwise
      // you'll never unhighlight the old ones.
      currentRow = newRow;
      currentCol = newCol;
    },
  };
})();

Then, in the event emitter callback, instead of setting the style prop, we can get another small boost (from 10ms to 7ms) by toggling a class using node.classList.toggle.

function TableCell({row, column}) {
  const ref = useRef();
  useEffect(() => {
    emitter.subscribe(row, column, isHighlighted => {
      if (ref.current) {
        // Directly update the class on the DOM node
        ref.current.classList.toggle(
          'highlight-cell',
          isHighlighted,
        );
      }
    });
  }, [column, row]);
  return (
    <td
      onMouseEnter={() => emitter.highlight(row, column)}
      ref={ref}>
      {row}x{column}
    </td>
  );
}

Table with event emitter and ref

Directly manipulating the DOM paid off, as this one clocks in at a blazing 7ms, well within the 16ms limit to hit 60 frames per second.

Takeaways

  • React is fast. The naive implementation with global state performed very well, considering we were updating over 12,000 components at once. It is worth noting that you will likely have other performance troubles besides highlighting if you are rendering that many components at once, and you'd probably want to look into virtualizing the table with something like react-window first, which would probably make most of this article moot.
  • Recoil isn't a panacea. Especially if you have thousands of selectors, Recoil may not be your best bet. They are actively working on performance with many atoms and selectors, so I expect this to improve.
  • Avoid invoking React at all for top speed. At the end of the day, React has to perform DOM manipulations to get anything done, so if you have a simple way to remove React from the critical path it will nearly always be faster.

The full code to all the examples used in this article, including the test harness, can be found on GitHub, and can be used however you see fit, as all the code for this blog is MIT licensed.


  1. Profiling was done using production mode React. After firing off the call to update state, the harness measures the elapsed time until an animation frame has completed. For recoil, though, two animation frames are required to capture all of the work that happens for some reason. The numbers look very fast for Recoil with a single animation frame, even though it is visually slow.