Go Back

Debugging React hook dependency changes

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.

Existing tools

React Developer Tools

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:

Use What Changed

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

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,
});

Our solution

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:

Setup

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>
    );
});

  1. Decorate major React hooks with additional logging and debugging capabilities before the React import statement and the declaration of a component being tracked
  2. Wrap a top-level component with trackChangedHookDependencies() to track deeply what is going on inside

Implementation

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.

trackChangedHookDependencies()

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;
    };
}

whereCalled

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.

useMemo() / useCallback() / useEffect()

As these functions themselves don’t have side effects, they can be easily decorated and patched.

useState()

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().

Nikolay Khodov

Senior Software Engineer

Nikolay is a senior full-stack software engineer at Fieldguide.

graphic

Related posts

See all
arrow

Learn why the future of Advisory Services is powered by Fieldguide AI

Top 500 firms choose the Fieldguide platform and AI. Learn how Fieldguide can help your firm.

wipfli logo
logo
logo
logo
logo