DEV Community

druchan
druchan

Posted on

Build a useInterval hook from scratch

TL;DR:

  • running a function repeatedly, at set intervals, is tricky in React. existing examples and libraries are all-too simple for real-world use cases (for eg, they don't work great for async functions, they don't do well with exponential back-off and they don't stop when the function being called at regular interval fails)
  • you might reach for setInterval at first, but it has problems like waiting for an async function to finish before calling it again at a given interval, or to stop after a few failed calls. complexity compounds when you add a back-off to this.
  • what we'll end up doing in this exercise is to build on top of two critical things – setTimeout and useEffect's "unmounting" behavior – to build a decently-robust useInterval hook that works great for both regular and async functions, and comes with a couple of extra niceties like stopping after n-retries and exponential back-off that are very useful in real-world scenarios.

You'll find a lot of examples online if you went looking for a way to run a function repeatedly after a delay that goes something like this:

const useInterval = (fn, { delay = 5000 }) => {
  useEffect(() => {
    let id;

    if (delay === null) {
      return;
    }

    id = setInterval(fn, delay);

    return () => clearInterval(id);
  }, [delay]);
};
Enter fullscreen mode Exit fullscreen mode

The idea is simple:

  • you create a hook that takes the function to run and a delay time
  • and it uses a useEffect to setup a setInterval that runs the function after the delay
  • and when the component using this hook unmounts, you clear the interval

There are a bunch of problems with this approach:

  • What if the function you want to run is async and returns (or resolves) only after a few seconds? (Ans: you'll see function stackups if your delay is less than the time it takes for your async function to resolve.)
  • What if you wanted to add some kind of an exponential back-off? (Ans: Not possible in the current scheme. Plain old setInterval is too limiting)
  • Or what if the function throws an error?! (Ans: we could simply slap a try ... catch but then, it doesn't solve for advanced use-cases like retrying a few times before giving up)

So let's make our useInterval robust by solving these problems.

Supporting async functions

Here's the ask: you want to run async functions repeatedly (with a delay) but you want the next-run of the function to be some seconds after the first run is complete. To do this, we have to await our function. But setInterval does not care for waiting - it just keeps calling whatever you give it after a delay.

We could use a setTimeout instead. Sure, the problem is it runs just once but let's see:

const useInterval = (fn, { delay = 5000 }) => {
  useEffect(() => {
    let id;

    if (delay === null) {
      return;
    }

    id = setTimeout(async () => {
      await fn();
    }, delay);

    return () => clearTimeout(id);
  }, [delay]);
};
Enter fullscreen mode Exit fullscreen mode

Because all logic is inside a useEffect, we could simply force the useEffect to re-run after a delay - and that will call setTimeout again!

And useEffect will re-run if something changes in the dependency array. To do this, we'll just introduce a random state variable (which is just Math.random()):

const useInterval = (fn, { delay = 5000 }) => {
  let [randomN, setRandomN] = useState(Math.random());

  useEffect(() => {
    let id;

    if (delay === null) {
      return;
    }

    id = setTimeout(async () => {
      await fn();
      setRandomN(Math.random());
      clearTimeout(id);
    }, delay);

    return () => clearTimeout(id);
  }, [delay, randomN]);
};
Enter fullscreen mode Exit fullscreen mode

What happens is this:

  • the hook loads
  • it calls the useEffect function
  • which calls the setTimeout (if there is a valid delay value)
  • in the setTimeout, we call the function to call (and wait for it to resolve)
  • once the function is run, we clear the interval and we set a new randomN which triggers the useEffect to re-run

Supporting error-retries

But of course what's a function if it does not throw in the most unexpected way? (/s)

Error handling is simple: we just wrap the function call with in a try ... catch but what that achieves is not optimal. Why? Because if the function (for some reason) keeps throwing an error all the time, what's the point in calling it over and over again?

So we have to get the whole thing to stop if the function throws an error. We'll just be a little fancy and ask our hook to "retry" the function a few times before giving up.

That is, just two rules:

  • don't blow up
  • try a few times

To do this, we'll just do 3 things:

  • introduce a "retryCount" state; except, we'll just use a ref for this because we don't want to re-render anything when it changes
  • update the retryCount when our function errors
  • and if retryCount has hit the max, we clear the timeout and stop the whole logic from running again
const useInterval = (fn, { retries = 3, delay = 5000 }) => {
  let [randomN, setRandomN] = useState(Math.random());
  let retryCount = useRef(retries);

  useEffect(() => {
    let id;

    if (delay === null) {
      return;
    }

    if (retryCount.current === 0) {
      clearTimeout(id);
      return;
    }

    id = setTimeout(async () => {
      try {
        await fn();
      } catch (_) {
        retryCount.current = retryCount.current - 1;
      }
      setRandomN(Math.random());
    }, delay);

    return () => clearTimeout(id);
  }, [delay, randomN]);
};
Enter fullscreen mode Exit fullscreen mode

There is a small problem with this logic though: our hook tracks retries but not "consecutive" ones. We want the hook to stop only if the function throws three consecutive times.

To do this, we'll reset the retryCount if the function succeeds.

const useInterval = (fn, { retries = 3, delay = 5000 }) => {
  let [randomN, setRandomN] = useState(Math.random());
  let retryCount = useRef(retries);

  useEffect(() => {
    let id;

    if (delay === null) {
      return;
    }

    if (retryCount.current === 0) {
      clearTimeout(id);
      return;
    }

    id = setTimeout(async () => {
      try {
        await fn();
        retryCount.current = retries;
      } catch (_) {
        retryCount.current = retryCount.current - 1;
      }
      setRandomN(Math.random());
    }, delay);

    return () => clearTimeout(id);
  }, [delay, randomN]);
};
Enter fullscreen mode Exit fullscreen mode

Adding an incremental back-off

This is a great place to be at. But more realistically, these interval-functions need an exponential back-off so that the retries are lagged by an increasing amount of delay.

All we need to do is keep track of – and use – a new delay amount every time the function runs. We can do this by introducing a new reference or variable called delayAmt and updating its value when the function finishes running.

const useInterval = (
  fn,
  { retries = 3, delay = 5000, backoffFactor = 1.2 }
) => {
  let [randomN, setRandomN] = useState(Math.random());
  let retryCount = useRef(retries);
  let delayAmt = useRef(delay);

  useEffect(() => {
    let id;

    if (delay === null) {
      return;
    }

    if (retryCount.current === 0) {
      clearTimeout(id);
      return;
    }

    id = setTimeout(async () => {
      try {
        await fn();
        retryCount.current = retries;
        delayAmt.current = delayAmt.current * backoffFactor;
      } catch (_) {
        retryCount.current = retryCount.current - 1;
      }
      setRandomN(Math.random());
    }, delayAmt.current);

    return () => clearTimeout(id);
  }, [delay, randomN]);
};
Enter fullscreen mode Exit fullscreen mode

And that's a complete, usable useInterval hook.

Other improvements you could try:

  • The hook should update if the function passed to it changes
  • Use this as a wrapper around popular data-fetching libraries like SWR and TanStack Query

Latest comments (0)