The Evolution of a Custom React Hook

The Evolution of a Custom React Hook

Play this article

Photo by Suzanne D. Williams on Unsplash

Abstracting code is a common thing we do in programming. It is also very common for those abstractions to evolve as the requirements change. Recently I had to opportunity to rapidly evolve a React hook from one form to another in the same day. Now that I have finished it, I thought I would share that evolution with you.

The first requirement was that I needed a generic way to poll data in a React component. If you are not familiar with the term, polling means checking data at a regular interval. This first version looked like this:

import { useRef, useEffect, useLayoutEffect } from "react";

export function usePolling(callBack, intervalTime = 10_000) {
  const callBackRef = useRef(callBack);

  useLayoutEffect(() => {
    callBackRef.current = callBack;
  });

  useEffect(() => {
    const interval = setInterval(() => callBackRef.current(), intervalTime);

    return () => {
      clearInterval(interval);
    };
  }, [callBackRef, intervalTime]);
}

In the above hook, we take a callback and an optional interval time. This callback will be called at a regular interval determined by the interval time provided in the second parameter, set to 10,000 by default.

The first thing that we are doing is using the useRef hook to create a callBackRef and then we are using the useLayoutEffect to keep the ref current with any changes to the callback. Kent C Dodds well documents this pattern, but to summarize, This pattern allows the callback to stay up to date without causing the useEffect to have to rerun or requiring the user of the hook to wrap the callback in a useCallback hook.

In the second part of the hook, we are using a useEffect hook to start our interval, and then we clean up the hook in the clean-up function returned from the useEffect.

This hook works great, but then I got a new requirement that it needs only to poll when the tab is visible; this would ensure that we are not wasting resources polling data when the user isn't actively using the tab.

Luckily there is an easy way to do this. First of all, the document object has a visibilityState that will let you know if it is visible or hidden. There is also an event listener that we can tap into called visibilitychange that we can use to react to the visibility state changing. Both of these are well documented at MDN.

So let's make a tweek to our hook:

import { useRef, useEffect, useLayoutEffect } from "react";

export function usePollWhenVisible(callBack, intervalTime = 10_000) {
  const callBackRef = useRef(callBack);

  useLayoutEffect(() => {
    callBackRef.current = callBack;
  });

  useEffect(() => {
    let interval;

    if (document.visibilityState === "visible") {
      interval = setInterval(() => callBackRef.current(), intervalTime);
    }

    const handleVisibilityChange = () => {
      clearInterval(interval);
      if (document.visibilityState === "visible") {
        interval = setInterval(() => callBackRef.current(), intervalTime);
      }
    };

    document.addEventListener("visibilitychange", handleVisibilityChange);

    return () => {
      clearInterval(interval);
      document.removeEventListener("visibilitychange", handleVisibilityChange);
    };
  }, [callBackRef, intervalTime]);
}

In the above code, we have changed the internals of our useEffect hook. First of all, we start by declaring the interval variable without defining it, and we declare it using a let. The reason we are doing this will be more apparent why we are doing as we go.

The next thing we are doing is checking if our current visibility state is visible and if it is, we start our interval just as before. This will ensure that we don't begin our interval if the page is no longer visible when the component mounts.

Next, we are defining our event listener function: handleVisibilityChange. This function will be called whenever the visibility state changes. In this function, the first thing we are doing is clearing the interval. This is safe because if the value passed in is not a valid interval, the function will ignore it. After we earn the interval we are doing the same visibility check as above and conditionally starting the interval if the visibility state is visible.

Finally, we add our event listener function to the visibilitychange event.

In our clean-up function, we do two things now. First, we clear the interval to ensure that we don't have a memory leak, but secondly, we also remove our event listener so it won't run.

Our hook is working fantastically now, but then a new requirement came down from the requirements gods. This hook will be used in multiple components, but we need them all to be on the same interval cycle. In other words, we need all our hooks to be synchronized.

To do this, I made this last final set of changes:

import { useEffect } from "react";

const INTERVAL_TIME = 10_000;

let callbacks = [];

let interval;

let handleVisibilityChange;

function init() {
  if (handleVisibilityChange) return;

  interval = setInterval(
    () => callbacks.forEach((fn) => void fn()),
    INTERVAL_TIME
  );

  handleVisibilityChange = () => {
    clearInterval(interval);
    if (document.visibilityState === "visible") {
      interval = setInterval(
        () => callbacks.forEach((fn) => void fn()),
        INTERVAL_TIME
      );
    }
  };

  document.addEventListener("visibilitychange", handleVisibilityChange);
}

function cleanUp() {
  if (callbacks.length > 0) return;

  if (handleVisibilityChange) {
    document.removeEventListener("visibilitychange", handleVisibilityChange);
  }

  handleVisibilityChange = undefined;
  clearInterval(interval);
}

export function usePollWhenVisible(callback) {
  useEffect(() => {
    init();

    callbacks = callbacks.concat(callback);

    return () => {
      callbacks = callbacks.filter((fn) => fn !== callback);

      cleanUp();
    };
  }, [callback]);
}

Let's break all this down, part by part.

const INTERVAL_TIME = 10_000;
let callbacks = [];
let interval;
let handleVisibilityChange;

First, we lifted the interval and handleVisibilityChange functions in the module and out of the hook itself. We have also declared two new variables: callbacks and INTERVAL_TIME.

The callbacks variable is initialized with an empty array. This is where we will maintain our list of callbacks to run each interval cycle. Since we now need all the callbacks to be on the same interval cycle, we are now declaring a constant INTERVAL_TIME that we can use universally in our module.

The next thing we do is declare a init function:

function init() {
  if (handleVisibilityChange) return;
  interval = setInterval(
    () => callbacks.forEach((fn) => void fn()),
    INTERVAL_TIME
  );

  handleVisibilityChange = () => {
    clearInterval(interval);
    if (document.visibilityState === "visible") {
      interval = setInterval(
        () => callbacks.forEach((fn) => void fn()),
        INTERVAL_TIME
      );
    }
  };

  document.addEventListener("visibilitychange", handleVisibilityChange);
}

This does a few things. First, it checks if the handleVisibilityChange function is truthy. If it is, we are returning early and not running the rest of the function. This ensures that we only initialize once. The rest of the function, we have already seen. We are starting the interval, declaring our event listener function, and registering it with the visibilitychange event listener.

There is one slight change to the interval function. Instead of calling a single function, we cycle through all the callbacks in the callback array and run those functions.

The next thing that we do is declare a cleanup function:

function cleanUp() {
  if (callbacks.length > 0) return;

  if (handleVisibilityChange) {
    document.removeEventListener("visibilitychange", handleVisibilityChange);
  }

  handleVisibilityChange = undefined;
  clearInterval(interval);
}

This is doing the opposite of our init function. First, it checks if there are callbacks. If there are, it will return early and not perform the cleanup if there are still registered callbacks. Then after that, it removes the event listener, sets the handleVisibilityChange back to undefined, and clears the interval. We set the handleVisibilityChange back to undefined so that if the component remounts, the init function will rerun.

Finally, we declare our hook:

export function usePollWhenVisible(callback) {
  useEffect(() => {
    init();
    callbacks = callbacks.concat(callback);

    return () => {
      callbacks = callbacks.filter((fn) => fn !== callback);
      cleanUp();
    };
  }, [callback]);
}

Now our hook takes a callback, runs init And adds our callback to the callback array. When it cleans up, it removes itself from the array and calls the cleanUp function.

We no longer need to use the callback ref since we no longer care if the effect is rerun when the callback changes. The init and cleanup already have the guards in place not to run needlessly, and the hook itself is simply adding and removing the callback from the callback array.

That's the final version, or at least final as of right now. It's fun to see how a function can change as the requirements do.

Did you find this article valuable?

Support Travis Waith-Mair by becoming a sponsor. Any amount is appreciated!