A new article was just published ā†’
Glaukos - fixing React hook re-renders

Glaukos - fixing React hook re-renders

Glaukos is a small NPM package I created at InterBolt to allow React developers to write large custom hooks that don't trigger unexpected re-renders as they grow in size.

Origin

When ReactJS hooks launched in version 16.8 I quickly adopted them in my projects and advocated for their use at my day job. But I found myself running into a recurring problem, especially in the context of a team: excessive re-renders in unexpected places.

Frank Learns a Lesson

To demonstrate the problem, let's walk through a hypothetical where we follow a team within a new startup building a web platform for animal shelters.

  1. In the early days of building the startup's web app, one of the team's developers wrote a React hook that did one well-defined thing - lookup the list of cats at a particular shelter.

    // useCats.ts
    
    const useCats = (shelterId: number) => {
      const { cats, isLoading } = useReactQueryEndpoint("cats", shelterId);
      return {
        cats,
        isLoading,
      };
    };
    
    export default useCats;
  2. Then, a few weeks later, the team decided that customers should also be able to view a list of all the dogs at the shelter. So someone came along and wrote a new, similar hook:

    // useDogs.ts
    
    const useDogs = (shelterId: number) => {
      const { dogs, isLoading } = useReactQueryEndpoint("dogs", shelterId);
      return {
        dogs,
        isLoading,
      };
    };
    
    export default useDogs;
  3. After receiving more customer feedback, the product manager decided that the app should display both the dogs and cats on several pages in the application. The developer responsible for this feature found the useCats and useDogs hooks and decided to create a new hook called useShelterAnimals, which would call both useCats and useDogs. The dev, pleased with their "clean" re-use of code, submitted their code for review. One of the reviewers commented on the new code and suggested that the author copy-paste-modify useCats and useDogs rather than re-use them inside of useShelterAnimals. Given the pitfalls of composing custom React hooks, this is the correct suggestion. But the PR in its current state was D.R.Y., so teammates decided not to heed the advice and the PR was approved.

    // useShelterAnimals.ts
    
    const useShelterAnimals = (shelterId: number) => {
      const { dogs, isLoading: isLoadingDogs } = useDogs(shelterId);
      const { cats, isLoading: isLoadingCats } = useCats(shelterId);
      const isLoading = isLoadingDogs || isLoadingCats;
    
      return {
        cats,
        dogs,
        isLoading,
      };
    };
    
    export default useShelterAnimals;

    The new hook was a hit and made its way onto several pages throughout the application. It wasn't causing many re-renders and now users could see the list of animals in various widgets and screens.

  4. Fast forward to the present. During a team meeting, the team's product manager floats an idea:

    Enter Frank, a junior developer who tends to do too much with too little code. Since the rest of the team is under fire this week, the team lead, Lauren, assigns him the responsibility of implementing the product manager's new feature idea. Frank, anxious to write the code in the most terse way possible, sees the useShelterAnimals, useCats, and useDogs hooks and decides to forgo writing a new hook for his carousel. Instead, he augments the useCats hook:

    // useCats.ts
    
    const useCats = (shelterId: number) => {
      const [visibleCatIndex, setVisibleCatIndex] = React.useState(0);
      const { cats, isLoading } = useReactQueryEndpoint("cats", shelterId);
    
      React.useEffect(() => {
        if (Array.isArray(cats)) {
          let shuffleInterval = setInterval(() => {
            // THE CULPRIT!!!
            setVisibleCatIndex((prev) => (prev >= cats.length ? 0 : prev + 1));
          }, 5000);
    
          return () => clearInterval(shuffleInterval);
        }
      }, [cats]);
    
      const visibleCat = Array.isArray(cats) ? cats[visibleCatIndex] : null;
    
      return {
        visibleCat,
        cats,
        isLoading,
      };
    };
    
    export default useCats;

    Can you spot the issue with Frank's code? Due to the state change within his React.useEffect, any component that calls useCats will get re-rendered more often than in the original design. If Frank's code is approved, the useShelterAnimals hook, which calls useCats, will trigger re-renders on pages not related to the new feature.

    A couple of over-burdened team members are too quick to approve Frank's code and, unaware of the performance regression introduced, he merges the updated useCats hook. The QA team manually reviews the widget in isolation and relies on automated regression tests for the other pages. Except, there's a problem: their regression tests don't flag newly introduced re-renders! The feature gets deployed to prod, and Frank celebrates his first contribution.

  5. A couple of weeks later, one of the product owners comes to the team with a complaint. They say the inputs are occasionally laggy on the signup screen, which wasn't the case a month ago. Lauren investigates, placing logs in the offending screen. She notices that the form is re-rendering more often than expected as a result of a call to the useShelterAnimals hook:

    // SignupScreen.tsx
    
    const SignupScreen = () => {
      const shelterId = useParams("id");
      const { dogs, cats, isLoading } = useShelterAnimals(shelterId);
      return (
        <div>
          {!isLoading && <NotFranksWidget dogs={dogs} cats={cats} />}
          <Form />
        </div>
      );
    };
    
    export default SignupScreen;

    It doesn't take long for her to spot the culprit - Frank's augmentation of useCats. His useEffect was causing the form to re-render every few seconds each time the visibleCatIndex updated. Lauren creates a ticket for a senior developer on the team to refactor Frank's code into a separate hook and uses the opportunity to help Frank understand what went wrong. While a good teaching moment, this debacle has caused customer pain and several hours of senior dev time. Hopefully, Frank won't do this again.

    The End


I can nitpick this hypothetical by saying things like:

"In reality, a few extra re-renders won't cause laggy inputs"

or

"Teams usually catch these types of mistakes in the review process".

In complex components, a few extra re-renders can sometimes hurt responsiveness. As for the reviewers, let's be honest, developers prefer "elegant" code, and sometimes elegant code, while dogshit, passes the review process with flying colors. Therefore, library authors should strive to write code that is performant when used in elegant ways. And, unfortunately, when writing hooks, the most elegant ways of doing things are sometimes not the most performant.

Out of frustration, I created a library that would allow my teammates to compose and co-locate hooks without worrying about unintuitive re-renders. For the remainder of the post I'll walk through the process I went through to design and implement Glaukos.

Step one) I needed a target API...

Target API - Attempt #1

Initially, I was only concerned with solving re-renders. Drawing from the hypothetical animal shelter app above, the pseudo-code solution I was searching for went something like this:

const useApp = () => {
  const { cats, dogs, onAdoptCat, onAdoptDog } = useShelterAnimals();
  const { user, userPreferences, onSignout, onUpdateProfile } = useUserProfile();

  return {
    cats,
    dogs,
    onAdoptCat,
    onAdoptDog,
    user,
    userPreferences,
    onSignout,
    onUpdateProfile,
  };
}

const useHookWithoutRerenders =
  // GOAL: I want to create this.
  myTargetLibrary(useApp)

const App = () => {
  // GOAL: should not trigger a re-render when, for example, `dogs` changes.
  const { cats, onUpdateProfile } = useHookWithoutRerenders()
  return (
    <html>
      <div>{...}</div>
    </html>
  )
}

One of the constraints I wanted to impose on my new library was that any hook passed to myTargetLibrary need only follow the rules laid out in the react hook documentation. I eventually gave this constraint up (in a small way), but we'll dig into that later.

After some experimentation, I decided to widen the scope of my new library to solve more than the re-render problem. I might as well make it possible for useApp to manage and share state with several components within a render tree to avoid prop drilling. I settled on a new target API that could wrap a render tree in a provider, which would provide access to the return value of useApp to any component in the render tree.

Target API - Attempt #2

const useApp = () => {
  const { dogs, cats, onAdoptCat, onAdoptDog } = useShelterAnimals();
  const { user, userPreferences, onSignout, onUpdateProfile } = useUserProfile();

  return {
    dogs,
    cats,
    onAdoptCat,
    onAdoptDog,
    user,
    userPreferences,
    onSignout,
    onUpdateProfile,
  };
}

const { Provider, useHookWithoutRerenders } = myTargetLibrary(useApp)

const App = () => {
  return (
    <html>
      <Provider>
        {...stuff here...}
        <SomeComponent />
      </Provider>
    </html>
  )
}

const SomeComponent = () => {
  // `useHookWithoutRerenders` can be called here because `SomeComponents` is
  // wrapped in the `Provider` above.
  const { dogs } = useHookWithoutRerenders()
  return (
    <div>
      {dogs}
    </div>
  )
}

Assuming the above code solves the re-render problem, it will also ensure that useApp is only called once. A cool side-effect of the above design is that myTargetLibrary makes many state management dependencies obsolete (looking at you React Redux), since we can manage global app state in useApp.

Now that I had a good approximation of the target API I wanted, it was time to write some questionable code :)

Implementation

Despite valid warnings from a slew of stack overflow posts and co-workers, I decided to use the React Context API to share the return value of useApp with all components in a render tree. The official React documentation warns against managing lots of changing state within a single React Context, and rightly so.

Stubbornly, I went forward with Contexts anyways and was able to mitigate some of their performance problems by memoizing the immediate children of the Context provider. Here's a bit of code demonstrating how to access the return value of a user-defined hook, similar to the useApp hook above, using a React Context.

// myTargetLibrary.tsx

import React from "react";

const myTargetLibrary = (useSomeHook) => {
  const HookContext = React.createContext();

  const Provider = ({ children }) => {
    const hookValue = useSomeHook();

    // First optimization: prevents changes to the hook's state from triggering re-renders
    // in the immediate children of our Provider.
    const memoizedChildren = React.useMemo(() => children, []);

    return (
      <HookContext.Provider value={hookValue}>
        {memoizedChildren}
      </HookContext.Provider>
    );
  };

  // note: this implementation does NOT prevent re-renders yet. keep reading.
  const useHookWithoutRerenders = () => React.useContext(HookContext);

  return {
    Provider,
    useHookWithoutRerenders,
  };
};

export default myTargetLibrary;

When we wrap a render tree in the Provider above, we can import and call useHookWithoutRerenders anywhere within the tree and access the return value of the user defined hook. The memoization of HookContext.Provider's immediate children prevents the worst of our re-render problems by ensuring re-renders only happen in sub-components that call useHookWithoutRerenders, but that's still a disaster waiting to happen as the user supplied hook grows in scope.

React.useContext doesn't care that we only need a small portion of the context value because if the user defined hook's state updates, the context value's reference will change too, triggering a re-render. See the comment below for a generic demonstration of the problem.

const CountComponent = () => {
  // imagine that SomeContext contains a property called `screenWidth` that changes
  // when the user resizes their browser window. By calling `React.useContext(SomeContext)`
  // here we can now expect that everytime the browser window size is adjusted, `CountComponent`
  // will re-render, even though it doesn't care at all about the window's size.
  const { count } = React.useContext(SomeContext);

  return <div>{count}</div>;
};

But there's a solution! After a bit of googling, I discovered the React RFC #119. The goal of RFC #119 is to add support for a second argument in React.useContext, a selector function that ensures re-renders only happen when a selected portion of the context value changes. While we mere mortals await deliberation from the React core team, an implementation of this concept already exists as a library, use-context-selector, authored by Dai Shai.

Dai Shai's use-context-selector replaces React's built-in useContext hook with its own useContextSelector, which does exactly what RFC #119 proposes. So let's revisit our previous implementation, but this time swap out React.useContext with useContextSelector.

import React from "react";
import { createContext, useContextSelector } from "use-context-selector";

const myTargetLibrary = (userHook) => {
  // The context must be created using Dai Shai's library
  const HookContext = createContext();

  const Provider = ({ children }) => {
    const appValue = useApp();

    const memoizedChildren = React.useMemo(() => children, []);

    return (
      <HookContext.Provider value={appValue}>
        {memoizedChildren}
      </HookContext.Provider>
    );
  };

  // Getting closer to eliminating re-renders but there's still
  // more to do so keep reading :)
  const useHookWithoutRerenders = (selector) =>
    useContextSelector(HookContext, selector);

  return {
    Provider,
    useHookWithoutRerenders,
  };
};

export default myTargetLibrary;

With that in place, let's look at how this can be used:

// SomeComponent.tsx

import myTargetLibrary from "./myTargetLibrary";

const useApp = () => {
  // ...
  // state, effects, handlers
  // ...

  return {
    someState,
    otherState,
    onSomeEvent,
    // ...
    // more state, handlers, and refs here
  };
};

const { Provider, useHookWithoutRerenders } = myTargetLibrary(useApp);

const App = () => {
  return (
    <Provider>
      <div>
        <SomeComponent />
        {/* leaving out other stuff in the render tree... */}
      </div>
    </Provider>
  );
};

const SomeComponent = () => {
  // Now we know that we'll only re-render when someState changes.
  // We're getting closer!!!
  const { someState } = useHookWithoutRerenders((hookReturnValue) => ({
    someState: hookReturnValue.someState,
  }));

  return <div>{/* stuff here */}</div>;
};

The call to useHookWithoutRerenders is now performant since we can say, "Don't re-render unless hookReturnValue.someState changes". But I still have a bone to pick when accessing handlers returned via the user defined hook. In the code above, when any portion of a useApp's state is changed, all un-memoized handler functions are redefined, causing their references to change. Meaning, if the selector function supplied to useHookWithoutRerenders grabs onSomeEvent, we can expect to trigger the same amount of re-renders as we would when using the unoptimized React.useContext hook. Users of myTargetLibrary can mitigate this with liberal usage of React.useCallback when defining their handler functions, but that comes with other pitfalls. Not to mention, it cripples my claim that myTargetLibrary will prevent all unintuitive re-renders.

I need to do more than swap out React.useContext with useContextSelector. Remember what I wrote above when discussing the target API:

"One of the constraints I wanted to impose on my new library was that any hook passed to myTargetLibrary need only follow the rules laid out in the react hook documentation. I eventually gave this constraint up (in a small way) but we'll dig in to that later."

The time had come to abandon my self-imposed constraint. If the user defined hook supplied to myTargetLibrary conforms to a particular return type where its state, handlers, and refs are separated, I can write some library code to eliminate handler functions from the context value altogether and provide a separate hook to access them without re-renders. And, while I'm at it, I can separate constant refs from other state whose references might change, making deep memoization of non-ref and non-handler state more performant (React.useMemo doesn't actually do a deep comparison).

So the user defined hook supplied to myTargetLibrary should look like:

const useUserDefinedHook = () => {
  // ...
  // effects, handlers, state
  // ...
  return {
    store: {
      // ...
      // ...
      // ...
    },
    handlers: {
      // ...
      // ...
      // ...
    },
    refs: {
      // ...
      // ...
      // ...
    },
  };
};

At this point, the code to implement myTargetLibrary is getting complicated so I recommend reviewing the source code of the final implementation, which I'll now refer to as Glaukos.

It's a single file. Read it here.

With all of that in place, let's take a look at the final API.

The Glaukos API

import glaukos from '@interbolt/glaukos'

const useApp = () => {
  const {
    dogs,
    cats,
    onAdoptCat,
    onAdoptDog,
    mapRef,
  } = useShelterAnimals()
  const {
    user,
    userPreferences,
    onSignout,
    onUpdateProfile,
  } = useUserProfile()

  return {
    store: {
      dogs,
      cats,
      user,
      userPreferences,
    }
    handlers: {
      onAdoptCat,
      onAdoptDog,
      onSignout,
      onUpdateProfile,
    },
    refs: {
      mapRef,
    }
  }
}

const {
  AppProvider,
  useAppStore,
  useAppHandlers,
  useAppRefs
} = glaukos(useApp, {
  // I haven't discussed this yet but
  // its nice to enable a globally unique name
  // so that the return values of `glaukos` can be immediately
  // exported and IDE autocompletion won't get confused.
  name: 'App'
})

const App = () => {
  return (
    <html>
      <AppProvider>
        {...stuff here...}
        <SomeComponent />
        <SomeMap />
      </AppProvider>
    </html>
  )
}

const SomeComponent = () => {
  // will only trigger a re-render when dogs changes
  const { dogs } = useAppStore(store => ({
    dogs: store.dogs
  }))

  // will NEVER trigger a re-render as `useAppHandlers` is returning
  // a reference that never changes. `onAdoptCat` beneath the hood is a proxy
  // function that calls the latest version of the defined `onAdoptCat`.
  const { onAdoptCat } = useAppHandlers()
  return (
    <div>
      <button onClick={() => onAdoptCat()}>
        click me
      </button>
      {dogs}
    </div>
  )
}

const SomeMap = () => {
  // will NEVER trigger a re-render
  const { mapRef } = useAppRefs()

  return (
    <div>
      <CatMap ref={mapRef}/>
    </div>
  )
}

And that's it! We now have a way to access everything returned by useApp above without triggering unintuitive re-renders. For me, this library is a game changer in that I can now compose a wide range of hooks within a single "god hook" and not think twice about re-renders further down the render tree. Glaukos has another subtle benefit in that it encourages co-location of app logic, which helps Github Copilot make amazingly intuitive autocompletions during the prototyping phase of development.

Recommended Use Case

While there's no hard rule on when to use Glaukos, I believe it excels at defining logic on a per-page or per-app (global) basis. And yes, it can replace your state management library.

Let's revisit the shelther app example

As a callback to our hypothetical animal shelter startup, let's create the Glaukos hook they might use to manage a shelter's about page.

// src/pages/shelter/glaukos.ts

import React from "react";
import glaukos from "@interbolt/glaukos";
import {
  useShelterCats,
  useShelterDogs,
  useShelterFavoriteCount,
  useUserFavorites,
  useEmployees,
} from "@/react-query-endpoints";
import { useParams } from "@/helpers";

const useShelterAnimals = () => {
  // ...
  // effects, state, handlers
  // ...
  return {
    cats,
    dogs,
    isLoading,
  };
};

const useEmployees = () => {
  // ...
  // effects, state, handlers
  // ...
  return {
    isLoading,
    employees,
    randomlyHighlightedEmployee,
  };
};

const useFavorites = () => {
  // ...
  // effects, state, handlers
  // ...
  return {
    hasFavoritedShelter,
    shelterFavoriteCount,
  };
};

const useAdoptCat = () => {
  // ...
  // effects, state, handlers
  // ...
  return {
    onAdoptCat,
    isLoading,
  };
};

const useCatCarousel = () => {
  // ...
  // effects, state, handlers
  // ...
  return {
    visibleCat,
    isLoading,
  };
};

const useCatMap = () => {
  const mapRef = React.useRef(null);
  // some logic I won't include for the sake of brevity
  // but the idea is maybe the shelter has map of where they
  // found each cat and we want to initialize the map via a react ref
  return mapRef;
};

const useGlaukosHook = () => {
  const catMapRef = useCatMap();

  const { dogs, cats, isLoading: isLoadingAnimals } = useShelterAnimals();
  const {
    employees,
    randomlyHighlightedEmployee,
    isLoading: isLoadingEmployees,
  } = useEmployees();
  const { hasFavoritedShelter, shelterFavoriteCount } = useFavorites();
  const { onAdoptCat, isLoading: isLoadingAdoptCat } = useAdoptCat();
  const { visibleCat, isLoading: isLoadingCarousel } = useCatCarousel();

  return {
    store: {
      dogs,
      cats,
      employees,
      randomlyHighlightedEmployee,
      visibleCat,
      shelterFavoriteCount,
      isLoadingAnimals,
      isLoadingEmployees,
      isLoadingCarousel,
      isLoadingAdoptCat,
      hasFavoritedShelter,
    },
    handlers: {
      onAdoptCat,
    },
    refs: {
      catMapRef,
    },
  };
};

export const {
  useShelterStore,
  useShelterHandlers,
  useShelterRefs,
  ShelterProvider,
} = glaukos(useGlaukosHook, { name: "Shelter" });

Now we can use the ShelterProvider in src/pages/shelter.tsx.

// src/pages/shelter.tsx

import React from 'react'
import { CarouselTransition } from '@/components'
import { ShelterProvider, useShelterStore } from "@/pages/shelter/glaukos";

const ShelterPageCatCarousel = () => {
  // Yay! no re-renders when things other than `visibleCat` and `isLoadingCarousel` change.
  const { visibleCat, isLoadingCarousel } = useShelterStore((store) => ({
    visibleCat: store.visibleCat,
    isLoadingCarousel: store.isLoadingCarousel,
  }));

  return (
    <div>
      {isLoadingCarousel ? (
        <div>Loading...</div>
      ) : (
        <CarouselTransition transitionDep={visibleCat}>
          <img src={visibleCat.imageUrl} />
          <button onClick={() => onAdoptCat(visibleCat.id)}>
            Adopt {visibleCat.name}
          </button>
        </CarouselTransition>
      )}
    </div>
  );
};

const ShelterPageAdoptWidget = () => {
  // no re-renders when things other than `isLoadingAnimals`, `cats`, or
  // `isLoadingAdoptCat` change.
  const { cats, isLoadingAnimals, isLoadingAdoptCat } = useShelterStore(
    (store) => ({
      isLoadingAnimals: store.isLoadingAnimals,
      isLoadingAdoptCat: store.isLoadingAdoptCat,
      cats: store.cats,
    })
  );

  // accessing `onAdoptCat` will never cause a re-render
  const { onAdoptCat } = useShelterHandlers();

  return (
    <div>
      {isLoadingAnimals ? (
        <div>Loading...</div>
      ) : (
        <div>
          {cats.map((cat) => (
            <div>
              <img src={cat.imageUrl} />
              <button disabled={isLoadingAdoptCat} onClick={() => onAdoptCat(cat.id)}>
                Adopt {cat.name}
              </button>
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

const ShelterPageRestOfContent = () => {
  // I'm not going to implement the entire page here you get the idea.
  // The point is to show that in `ShelterPageCatCarousel` and `ShelterPageAdoptWidget`
  // re-renders will only happen when the relevant state changes.

  return (
    ...
    ...
  )
};

const ShelterPage = () => {
  return (
    <ShelterProvider>
      <ShelterPageRestOfContent />
      <ShelterPageCatCarousel />
      <ShelterPageAdoptWidget />
    </ShelterProvider>
  );
};

export default ShelterPage;

Disclaimer

Glaukos is in the alpha stages of development and its API is subject to change in small ways going forward. I highly recommend checking out the repo, which should always include up to date "getting started" documentation in case this blog post goes out of date.

Closing Thoughts

There are many ways to build a React application, and I don't expect the "Glaukos-way" of writing app logic to appeal to everyone.

I hope you enjoyed this article and I hope you find the library useful. If you have any questions or feedback please feel free to reach out to me at [email protected]. I'm always happy to chat about React, software engineering, or anything else. Thanks for reading!