bgenc.net/content/posts/2022.09.18.solving-react-redux-triggering-too-many-re-renders.md
2022-09-18 22:01:21 -04:00

4.7 KiB

title date toc images tags
Solving React Redux Triggering Too Many Re-Renders 2022-09-18T18:13:31-04:00 false
javascript
typescript
react
web
dev

This might be obvious for some, but I was struggling with a performance issue in Bulgur Cloud, my React (well, React Native) based web application. Bulgur Cloud is an app like Google Drive or NextCloud, and one of the features is that you can upload files. But I noticed that the page would slow down to a crawl and my computers fans would spin up during uploads. It can't be that expensive to display a progress bar for an upload, so let's figure out what's happening.

As the first step, I installed the "React Developer Tools" extension in my browser and enabled the "Highlight updates when components render" option. Then I used the network tab to add a throttle so I could see the upload happen more slowly, and started another upload.

{{}}

A video showing a progress bar slowly increasing. As the progress bar goes up, the entire screen flashes with blue borders.

{{}}

Pretty much the entire screen flashes every time the progress bar goes up. Something is causing unnecessary re-renders! The next step in my diagnosis was to record a profile of the upload process with the react developer tools. I enable the option "Record why each component rendered when profiling", then run the profiler as I upload another file.

A flame graph with many elements highlighted in blue.

Walking through the commits in the flame graph (the part that says "select next commit" in the screenshot, top right), I can see this weird jagged pattern that repeats through the upload process. Selecting on of the tall commits again confirms that pretty much everything had to be re-rendered. Hovering over the items being re-rendered shows me that the items being re-rendered are my folder listings, the rows representing files. It also tells me why they re-rendered: "Hook 3 changed".

Next step is to switch over to the components feature of the react developer tools, and take a look at FolderList. Once I select it, I get a list of the hooks that it uses. Keep expanding the tree of hooks, and the hook numbers are revealed.

A tree view displaying hooks for a component named FolderList

The hook names here seem to be the names in the source code minus the use part, so my useFetch hook becomes Fetch. They are in a tree view since hooks can call other hooks inside them. Following the tree, hook 3 is State, and is located under Selector which is a React Redux hook useSelector. At this point things become clearer: I'm using redux to store the upload progress, and every time I update the progress it causes everything to re-render. And all of this is being caused through my fetch hook. Let's look at the code for that:

export function useFetch<D, R>(
  params: RequestParams<D>,
  swrConfig?: SWRConfiguration,
) {
  // useAppSelector is useSelector from react redux,
  // just a wrapper to use my app types
  const { access_token, site } = useAppSelector((selector) =>
    // pick is same as Lodash's _.pick
    pick(selector.auth, "access_token", "site"),
  );
  // ...

I realized the issue once I had tracked it down to here! The state selector is using the pick function to extract values out of an object. React Redux checks if the value selected by the selector has changed to decide if things need to be re-rendered, but it uses a basic equality comparison and not a deep equality check. Because pick keeps creating new objects, the objects are never equal to each other, and Redux keeps thinking that it has to re-render everything!

The solution luckily is easy, we can tell redux to use a custom function for comparison. I used a shallowEquals function to do a single-depth comparison of the objects (the object is flat so I don't need recursion).

export function shallowEquals<Left extends Record<string, unknown>, Right extends Record<string, unknown>>(left: Left, right: Right) {
  if (Object.keys(left).length !== Object.keys(right).length) return false;
  for (const key of Object.keys(left)) {
    if (left[key] !== right[key]) return false;
  }
  return true;
}

// ...
  const { access_token, site } = useAppSelector((selector) =>
    pick(selector.auth, "access_token", "site"),
  shallowEquals
  );

Let's look at the profile now:

A flame graph with only a tiny portion of elements highlighted.

Much better! The only thing re-rendering now is the progress bar itself, which is ideal.