4.7 KiB
title | date | toc | images | tags | |||||
---|---|---|---|---|---|---|---|---|---|
Solving React Redux Triggering Too Many Re-Renders | 2022-09-18T18:13:31-04:00 | false |
|
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.
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.
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:
Much better! The only thing re-rendering now is the progress bar itself, which is ideal.