SaltyCrane Blog — Notes on JavaScript and web development

How to useMemo to improve the performance of a React table

These are notes on how I improved the React rendering time of a large table with useMemo, the React Devtools, and consideration of referential equality of my data.

Summary of steps

  • Profile using the React Devtools Profiler to find components that are rendering excessively
  • Add memo, useMemo, or PureComponent to prevent the excessive rendering
  • If using memo or PureComponent, ensure the props passed in are referentially equal. Something like use-why-did-you-update can help find unexpected inequalities. If using useMemo, ensure the dependencies in the dependency array are referentially equal. Hooks like useMemo and useCallback can help preserve referential equality of props or dependencies. If using Redux, reselect memoizes selectors to prevent excessive referential inequalities. And immutable libraries like immer help preserve referential equality by preserving references to data if the values do not change.

Problem

I had a table of 100 rows of select inputs. Changing a select input had a noticeable lag.

react table screenshot

React Profiler

I profiled the table with the Profiler in React Devtools and found that all the rows were re-rendering even though only one of them changed. The screenshot below shows rendering of my Table component took 239ms. All the colored bars beneath the Table means each of the 100 rows are rendering even though only one of them changed. For more information, see this article on how to use the React Profiler.

react table screenshot

Row component

The table was built using React hooks and I sprinkled useMemo liberally in my code. Most of my data was memoized, but React was still re-rendering. Here is my row component:

const MappingRow = ({ id }) => {
  // ...
  const mapping = useMapping(state, id);
  const enabled = useEnabledFields(state, id);
  const { makeOptions, modelFamilyOptions, modelParentOptions, segmentOptions } = useMappingRowApis(id);

  const handleChange = field => selected => {
    const value = selected && selected.value;
    if (value === mapping[field]) {
      return;
    }
    const update = { [field]: value };
    dispatch({
      type: "save_mapping",
      promise: saveMapping(id, update),
      id,
      timeSaved: Date.now(),
      update,
    });
  };

  return (
    <tr>
      <Cell>{mapping.source}</Cell>
      <SelectCell
        isDisabled={!enabled.mappableMakeName}
        onChange={handleChange("makeCode")}
        options={makeOptions}
        value={mapping.makeCode}
      />
      <SelectCell
        isDisabled={!enabled.mappableModelParent}
        onChange={handleChange("modelParentId")}
        options={modelParentOptions}
        value={mapping.modelParentId}
      />
      <SelectCell
        isDisabled={!enabled.mappableModelFamilyName}
        onChange={handleChange("modelFamilyName")}
        options={modelFamilyOptions}
        value={mapping.modelFamilyName}
      />
      <SelectCell
        isDisabled={!enabled.mappableSegmentName}
        onChange={handleChange("segmentCode")}
        options={segmentOptions}
        value={mapping.segmentCode}
      />
    </tr>
  );
};

memo HOC

Even though the data provided by my custom hooks was memoized, I realized I still needed to apply React's memo higher order component (HOC) to prevent re-rendering. I extracted out a new MemoizedRow component, so that I could wrap it with React's memo HOC. (Note: if this seems undesirable to you, see the end of this post.)

const MappingRow = ({ id }) => {
  // ...
  const mapping = useMapping(state, id);
  const enabled = useEnabledFields(state, id);
  const { makeOptions, modelFamilyOptions, modelParentOptions, segmentOptions } = useMappingRowApis(id);
  const handleChange = field => selected => {
    // ...
  };

  return (
    <MemoizedRow
      enabled={enabled}
      handleChange={handleChange}
      makeOptions={makeOptions}
      mapping={mapping}
      modelFamilyOptions={modelFamilyOptions}
      modelParentOptions={modelParentOptions}
      segmentOptions={segmentOptions}
    />
  );
};

const MemoizedRow = memo(props => {
  const {
    enabled,
    handleChange,
    makeOptions,
    mapping,
    modelFamilyOptions,
    modelParentOptions,
    segmentOptions,
    sourceConfig,
  } = props;
  return (
    <tr>
      <Cell>{mapping.source}</Cell>
      <SelectCell
        isDisabled={!enabled.mappableMakeName}
        onChange={handleChange("makeCode")}
        options={makeOptions}
        value={mapping.makeCode}
      />
      <SelectCell
        isDisabled={!enabled.mappableModelParent}
        onChange={handleChange("modelParentId")}
        options={modelParentOptions}
        value={mapping.modelParentId}
      />
      <SelectCell
        isDisabled={!enabled.mappableModelFamilyName}
        onChange={handleChange("modelFamilyName")}
        options={modelFamilyOptions}
        value={mapping.modelFamilyName}
      />
      <SelectCell
        isDisabled={!enabled.mappableSegmentName}
        onChange={handleChange("segmentCode")}
        options={segmentOptions}
        value={mapping.segmentCode}
      />
    </tr>
  );
});

Referential equality or shallow equality

I applied the memo HOC, but profiling showed no change in performance. I thought I should useWhyDidYouUpdate. This revealed some of my props were not equal when I expected them to be. One of them was my handleChange callback function. This function is created every render. The reference to the function from one render does not compare as equal to the reference to the function in another render. Wrapping this function with useCallback memoized the function so it will compare equally unless one of the dependencies change (mapping or id).

const MappingRow = ({ id }) => {
  //...

  const handleChange = useCallback(
    field => selected => {
      const value = selected && selected.value;
      if (value === mapping[field]) {
        return;
      }
      const update = { [field]: value };
      dispatch({
        type: "save_mapping",
        promise: saveMapping(id, update),
        id,
        timeSaved: Date.now(),
        update,
      });
    },
    [mapping, id],
  );

  return (
    <MemoizedRow
      enabled={enabled}
      handleChange={handleChange}
      makeOptions={makeOptions}
      mapping={mapping}
      modelFamilyOptions={modelFamilyOptions}
      modelParentOptions={modelParentOptions}
      segmentOptions={segmentOptions}
    />
  );
};

Another problem was my mapping data object was changing for every row even though I only actually changed one of the rows. I was using the Immer library to create immutable data structures. I had learned that using immutable data structures allows updating a slice of data in an object without changing the reference to a sibling slice of data so that it would compare equally when used with the memo HOC or PureComponent. I had thought my data was properly isolated and memoized, however there was one piece of my state that was breaking the memoization. Here is my code to return a single mapping data object for a row:

export const useMapping = (state, id) => {
  const {
    optimisticById,
    optimisticIds,
    readonlyById,
    writableById,
  } = state.mappings;
  const optimisticMapping = optimisticById[id];
  const readonlyMapping = readonlyById[id];
  const writableMapping = writableById[id];
  return useMemo(() => {
    const mapping = { ...readonlyMapping, ...writableMapping };
    return optimisticIds.includes(id)
      ? { ...mapping, ...optimisticMapping }
      : mapping;
  }, [id, optimisticIds, optimisticMapping, readonlyMapping, writableMapping]);
};

The optimisticIds state was used to store a list of ids of mapping items that had been updated by the user, but had not yet been saved to the database. This list changed whenever a row was edited, but it was used in creating the mapping data for every row in the table. The optimisticIds is in the useMemo dependency array, so when it changes, the mapping data is re-calculated and a new value is returned. The important part is not that running the code in this function is expensive. The important part is that the function returns a newly created object literal. Like the handleChange function created in the component above, object literals created at different times do not compare equally even if the contents of the object are the same. e.g. The following is not true in JavaScript: {} === {}. I realized I did not need the optimisticIds state, so I removed it. This left a memoized function that only recalculated when data for its corresponding row in the table changed:

export const useMapping = (state, id) => {
  const { optimisticById, readonlyById, writableById } = state.mappings;
  const optimisticMapping = optimisticById[id];
  const readonlyMapping = readonlyById[id];
  const writableMapping = writableById[id];
  return useMemo(() => {
    const mapping = { ...readonlyMapping, ...writableMapping };
    return optimisticMapping ? { ...mapping, ...optimisticMapping } : mapping;
  }, [optimisticMapping, readonlyMapping, writableMapping]);
};

20X improvement

After fixing these referential inequalities, the memo HOC eliminated the re-rendering of all but the edited row. The React profiler now showed the table rendered in 10ms, a 20X improvement.

react table screenshot

Refactoring to useMemo

To use the memo HOC, I had to extract out a separate component for the sole purpose of applying the memo HOC. I started to convert the HOC to a render prop so I could use it inline. Then I thought, aren't hooks supposed to replace most HOCs and render props? Someone should make a useMemo hook to do what the memo HOC does. Wait there is a useMemo hook already... I wonder if...

const MappingRow = ({ id }) => {
  const mapping = useMapping(id);
  const enabled = useEnabledFields(id);
  const { makeOptions, modelFamilyOptions, modelParentOptions, segmentOptions } = useMappingRowApis(id);

  const handleChange = useCallback(
    field => selected => {
      const value = selected && selected.value;
      if (value === mapping[field]) {
        return;
      }
      const update: MappingUpdate = { [field]: value };
      dispatch({
        type: "save_mapping",
        promise: saveMapping(id, update),
        id,
        timeSaved: Date.now(),
        update,
      });
    },
    [dispatch, mapping, id],
  );

  return useMemo(
    () => (
      <tr>
        <Cell>{mapping.source}</Cell>
        <SelectCell
          isDisabled={!enabled.mappableMakeName}
          onChange={handleChange("makeCode")}
          options={makeOptions}
          value={mapping.makeCode}
        />
        <SelectCell
          isDisabled={!enabled.mappableModelParent}
          onChange={handleChange("modelParentId")}
          options={modelParentOptions}
          value={mapping.modelParentId}
        />
        <SelectCell
          isDisabled={!enabled.mappableModelFamilyName}
          onChange={handleChange("modelFamilyName")}
          options={modelFamilyOptions}
          value={mapping.modelFamilyName}
        />
        <SelectCell
          isDisabled={!enabled.mappableSegmentName}
          onChange={handleChange("segmentCode")}
          options={segmentOptions}
          value={mapping.segmentCode}
        />
      </tr>
    ),
    [
      enabled,
      handleChange,
      mapping,
      makeOptions,
      modelParentOptions,
      modelFamilyOptions,
      segmentOptions,
    ],
  );
};

Yes applying useMemo to the returned JSX element tree had the same effect as applying the memo HOC without the intrusive component refactor. I thought that was pretty cool. Dan Abramov tweeted about wrapping React elements with useMemo also:

Comments