At Fieldguide, we prioritize React web performance optimizations to minimize the number of component updates or re-renders. This is especially impactful within our complex spreadsheet feature enriched with linked controls, documents, and data requests. React components will always have the first render, and their updates are unavoidable. But we want to ensure that each subsequent update is justified and happens only when necessary.
The post surfaces some of the existing debugging tools within the React ecosystem before introducing a new solution we implemented that attempts to mitigate their drawbacks. The utility was used to diagnose unnecessary re-renders caused by internal Apollo Client GraphQL mutation hook state changes.
React Developer Tools clearly indicates what components have been updated. However, it does not directly indicate the cause of such updates, including a state change in useState() or modified dependencies in useMemo(), useCallback() or useEffect().
While the React Profiler can track which hooks cause components to re-render, it reports the index of a changed hook and may require multiple clicks to discover it in the Component tabs:
We initially wanted to use the library use-what-changed which is easy to start using. After enabling its Babel plugin and adding a comment line before the hook:
// uwc-debug useEffect(() => { // console.log("something changed , need to figure out") }, [a, b, c, d]);
you can track what is going on:
However, it doesn’t let you name tracked hooks which complicates the process of distinguishing changes. From the screenshot above, locating where a change occurred is not obvious.
Why Did You Render is another powerful debugging solution which can track all major updates types. It is as capable as use-what-changed, however it does not provide great developer experience to inspect deeply nested trees and produces overwhelming logs that are difficult to analyze.
In order to enable it for components, the developer may need to either individually mark each component to be tracked:
WorkplanTableGrid.whyDidYouRender = true;
craft a regular expression to only include the components in question (assuming there is an established naming convention):
whyDidYouRender(React, { include: [/^Workplan/], });
or enable tracking all pure components:
whyDidYouRender(React, { trackHooks: true, trackAllPureComponents: true, });
We built a lightweight utility that fills these gaps. After decorating major React hooks with additional logging and debugging capabilities, wrapping a single top-level component in trackChangedHookDependencies() will pinpoint the closest component / custom hook:
The setup is straightforward:
import { instrumentReact,
trackChangedHookDependencies,
} from './trackChangedHookDependencies'; instrumentReact(); // (1) decorate major React hooks import { useState, useEffect, useMemo, useCallback } from 'react';
// (2) wrap a top-level component with trackChangedHookDependencies() export const CountObjects = trackChangedHookDependencies(() => { const counter = useCounter(); const { objects } = useObjects(counter); return ( <pre> {JSON.stringify({ objects: objects.length, counter }, null, 4)} </pre> );
});
The decorated hook functions test whether their dependencies have changed between calls when rendering the component’s subtree and logs to the console if detected.
The caller component in which a hook is used is determined via an auto-incrementing lastComponentInstanceId ref. Each hook within the active component’s subtree is assigned a similar auto-incrementing identifier:
// Used to assign a unique auto-incrementing instance ID let lastComponentInstanceId = 0;
// When React begins rendering, we keep the active component's ID in here, // Otherwise it is reset to undefined let currentComponentInstanceId: number | undefined = undefined;
// When React begins rendering, we assign a unique zero-based auto-incrementing ID to each hook instance within the active component's subtree, // Otherwise it is reset to undefined let currentComponentCurrentHookCounter: number | undefined = undefined;
export function trackChangedHookDependencies<T extends Function>(fn: T): T { // @ts-ignore return (...args: any[]) => { // We use useRef() bc it is not patched const instanceId = useRef(++lastComponentInstanceId); currentComponentInstanceId = instanceId.current;
currentComponentCurrentHookCounter = 0;
const result = fn(...args);
currentComponentInstanceId = undefined; return result; };
}
When we detect modified dependencies, we can derive the hook consumer’s location by reading from the stack trace which is available from an Error() instance.
As these functions themselves don’t have side effects, they can be easily decorated and patched.
Customizing useState() is more challenging because we need to hold a state and handle both setter function versions (one that expects just a new value and one accepting a callback providing the current value). It adds some boilerplate, but the idea behind the implementation is more or less similar to patching useMemo().