Skip to main content

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. Given the number of connected parts, one update can trigger compounding downstream effects, resulting in a frustratingly slow user experience. By minimizing updates, the web application more closely resembles native OS programs, delighting users.

Recently, we identified problematic re-renders taking place in a top-level component which led to expensive full screen re-renders. In our application we use Apollo Client to talk to the GraphQL backend and rely on an auto-generated type-safe API client for queries and mutations. Our theory was that a component that maintains the user’s active backend session gets updated due to internal state changes in useMutation(); thus reloading <AuthApolloWrapper/>.

export const AuthApolloWrapper = ({ children }: Props) => {
    return (
        <UserTypeAppAbilityContextProvider>
            <InactiveSessionContextProvider>
                <InactiveSessionTimer />
                {children}
            </InactiveSessionContextProvider>
        </UserTypeAppAbilityContextProvider>
    );
};

Apollo Client's useMutation hook returns a second mutation result object that enables callers to interact with the mutation result declaratively. Internally, this property is backed by a result state that is updated when mutations resolve, triggering a re-render.

We confirmed the hypothesis by leveraging a custom utility to debug state changes in the mutation hook:

In most cases, we only ever interact with the mutate function imperatively, and the result state behavior can be disabled via the ignoreResults mutation option.

While Apollo Client does not enable this option to be configured globally, the latest version of the TypeScript React Apollo GraphQL Code Generator plugin enables the generation of defaultBaseOptions. Configuring defaultBaseOptions to have ignoreResults: true for our generated hooks by default mitigated the unexpected re-renders.

To ensure developers were aware of this global behavior change, we leveraged ESLint to discourage declarative mutation usage using the no-restricted-syntax rule:

{
   'no-restricted-syntax': [
       'error',
       {
           selector:
               'VariableDeclarator[id.type="ArrayPattern"][id.elements.length>1][init.type="CallExpression"][init.callee.name=/^use.*Mutation$/]:has(CallExpression:not(:has(Property[key.name="ignoreResults"][value.raw="false"])))',
           message: [
               'Avoid interacting with the declarative mutation result object in favor of `MutationButton` or a local `loading` state if applicable.',
               'In most cases, we only interact with the mutate function imperatively, and the mutation result object is not updated to minimize renders. If you need to interact with the declarative `data` result, configure the `ignoreResults: false` mutation hook option.',
           ].join('\n\n'),
       },
   ],
};

For a closer look at the debugging utility we implemented, read Debugging React hook dependency changes.

Nikolay Khodov

Nikolay Khodov

Senior Software Engineer

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

fg-gradient-light