Building a useInterval hook from scratch

Feb 09, 2023

TL;DR:


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

The idea is simple:


There are a bunch of problems with this approach:

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

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

What happens is this:


Supporting error-retries

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

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:

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

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

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

Adding an incremental backoff

This is a great place to be at. But more realistically, these interval-functions need an exponential backoff 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 everytime 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]);
};

And that's a complete, usable useInterval hook.

Other improvements you could try: