If you’ve been playing React Hooks for hours, you might run into an annoying problem: using setInterval to do things you don’t want to do.

Here’s what Ryan Florence said:

I’ve seen a lot of React hooks mentioned with setInterval, but this is the first time I’ve seen a stale state issue. If this problem is extremely difficult in hooks, then we have a different level of complexity than class Component.

To be honest, I think these people are on to something, or at least confused about it.

However, I realize that this is not a bug caused by Hooks, but a mismatch between the React programming model and setInterval. Hooks are closer to the React programming model than class, making this mismatch more obvious.

In this article, we’ll see how Intervals and Hooks play together, why this solution makes sense, and what new features it provides.


Disclaimer: The focus of this article is a sample problem. Even though apis can simplify hundreds of cases, the discussion always points to the harder problem.

If you’re starting with Hooks and don’t know what they’re talking about, check this introduction and documentation first. This article assumes that you have been using Hooks for over an hour.


Just show me the code

Needless to say, this is a counter that increases every second:

import React, { useState, useEffect, useRef } from 'react'; function Counter() { let [count, setCount] = useState(0); UseInterval (() => {// your own code setCount(count + 1); }, 1000); return <h1>{count}</h1>; }Copy the code

(this isCodeSandbox demo).

UseInterval in demo is not a built-in React Hook, but a custom Hook I wrote.

import React, { useState, useEffect, useRef } from 'react';

function useInterval(callback, delay) {
  const savedCallback = useRef();

  // Save the new callback
  useEffect((a)= > {
    savedCallback.current = callback;
  });

  / / set up the interval
  useEffect((a)= > {
    function tick() {
      savedCallback.current();
    }
    if(delay ! = =null) {
      let id = setInterval(tick, delay);
      return (a)= > clearInterval(id);
    }
  }, [delay]);
}
Copy the code

(This is something you might have missed in the previous demoCodeSandbox demo).

My useInterval Hook has an interval built in and cleared when unmounting. It is a combination of setInterval and clearInterval used in the component lifecycle.

Feel free to copy and paste it into your project or import it using NPM.

If you don’t care how it works, you can stop reading! This next section is for folks who want to dig deeper into React Hooks.


Waiting for? ! 🤔

I know what you’re thinking:

Dan, this code doesn’t make any sense. What does “just JavaScript” mean? Admit React caught the shark in Hooks!

That’s what I thought at first, but then I changed my mind, and I’m gonna change yours, too. Before I explain why this code makes sense, I want to show you what it can do.


whyuseInterval()It’s a better API

UseInterval Hook accepts a function and a delay parameter:

  useInterval((a)= > {
    // ...
  }, 1000);
Copy the code

This looks a lot like setInterval:

  setInterval((a)= > {
    // ...
  }, 1000);
Copy the code

So why not just use setInterval?

It may not be obvious at first, but when you notice the difference between my useInterval and setInterval, you’ll see that its parameters are “dynamically”.

I will illustrate this point with concrete examples.


Suppose we want delay to be adjustable:

While you don’t have to use input control delay, dynamic adjustment can be useful — for example, reducing the AJAX polling update interval when a user switches to another TAB.

So how do you do that with setInterval in class? Here’s what I would do:

class Counter extends React.Component { state = { count: 0, delay: 1000, }; componentDidMount() { this.interval = setInterval(this.tick, this.state.delay); } componentDidUpdate(prevProps, prevState) { if (prevState.delay ! == this.state.delay) { clearInterval(this.interval); this.interval = setInterval(this.tick, this.state.delay); } } componentWillUnmount() { clearInterval(this.interval); } tick = () => { this.setState({ count: this.state.count + 1 }); } handleDelayChange = (e) => { this.setState({ delay: Number(e.target.value) }); } render() { return ( <> <h1>{this.state.count}</h1> <input value={this.state.delay} onChange={this.handleDelayChange} / > < / a >); }}Copy the code

(this isCodeSandbox demo).

That’s not bad either!

What does the Hook version look like?

🥁 🥁 🥁

function Counter() {
  let [count, setCount] = useState(0);
  let [delay, setDelay] = useState(1000);

  useInterval(() => {
    // Your custom logic here
    setCount(count + 1);
  }, delay);

  function handleDelayChange(e) {
    setDelay(Number(e.target.value));
  }

  return (
    <>
      <h1>{count}</h1>
      <input value={delay} onChange={handleDelayChange} />
    </>
  );
}
Copy the code

(this isCodeSandbox demo).

Yes, that’s all.

Unlike the class version, useInterval Hook is simple to “update” to dynamically adjust delay:

UseInterval (() => {setCount(count + 1); }, 1000); Delay useInterval(() => {setCount(count + 1); }, delay);Copy the code

When useInterval Hook receives different delays, it resets the interval.

Instead of writing code to add and clear intervals, declare an interval with dynamically adjusted delay. UseInterval Hook does this for us.

What do I do if I want to temporarily pause interval? I can do this with a state:

  const [delay, setDelay] = useState(1000);
  const [isRunning, setIsRunning] = useState(true);

  useInterval(() => {
    setCount(count + 1);
  }, isRunning ? delay : null);
Copy the code

(this isdemo!).

This makes me excited again about React and Hooks. We can wrap existing imperative APIs and create declarative APIs that are closer to expressing our intentions. In the case of rendering, we can accurately describe the process at each point in time without carefully manipulating it with instructions.


I hope at this point you’re starting to see useInterval() Hook as a better API — at least compared to components.

But why bother using setInterval() and clearInterval() in Hooks? Let’s go back to the counter example and try to implement it.


First try

I’ll start with a simple example that renders only the initial state:

function Counter() {
  const [count, setCount] = useState(0);
  return <h1>{count}</h1>;
}
Copy the code

Now I want an interval that increases every second, which is a side effect that needs to be cleaned up, so I’ll use useEffect() and return the cleanup function:

function Counter() {
  let [count, setCount] = useState(0);

  useEffect(() => {
    let id = setInterval(() => {
      setCount(count + 1);
    }, 1000);
    return () => clearInterval(id);
  });

  return <h1>{count}</h1>;
}
Copy the code

(see theCodeSandbox demo.).

Seems like a simple job, right?

However, this code has a strange behavior.

By default, React reexecutes effects after each render, which is purposeful and helps avoid certain bugs in the React class component.

This is usually a good thing because it requires many subscription apis to remove old listeners and add new ones at any time. However, setInterval is different. When we execute clearInterval and setInterval, they go into the time queue. If we re-render and re-execute effects frequently, the interval might not get a chance to execute!

We can find this bug by re-rendering our components at shorter intervals:

setInterval((a)= > {
  // Re-render and re-execute the effects of Counter
  // clearInterval() occurs
  // before interval is executed setInterval()
  ReactDOM.render(<Counter />, rootElement);
}, 100);
Copy the code

Look at this bugdemo)


Second attempt

As you may know, useEffect() allows us to selectively re-execute effects. You can set a dependency array as the second argument. React will only re-execute when something in the array changes:

useEffect(() => {
  document.title = `You clicked ${count} times`;
}, [count]);
Copy the code

When we only want to clean it up by executing effect and unmount, we can pass empty the dependency array of [].

However, if you are unfamiliar with JavaScript closures, you will encounter a common error. Let’s make this mistake right now! (We’ve also set up a Lint rule for early feedback on this error, but we’re not ready yet.)

In our first attempt, we had a problem with rerunning effects causing the timer to clear prematurely. We could try to fix this without rerunning:

function Counter() { let [count, setCount] = useState(0); useEffect(() => { let id = setInterval(() => { setCount(count + 1); }, 1000); return () => clearInterval(id); } []); return <h1>{count}</h1>; }Copy the code

However, now we update our timer to 1 and it doesn’t move. (See actual bugs.)

What happened? !

The problem is that useEffect gets a count of 0 on the first rendering, and we don’t re-execute effect anymore, so setInterval keeps referring to the closure count from the first rendering so that count + 1 is always 1. Ah me!

I can hear you gnashing your teeth. Hooks are so annoying, aren’t they?

One way to fix it is to replace setCount(count + 1) with an “updater” like setCount(c => c + 1), which reads the new state variable. But this doesn’t help you get new props.

Another method is to use useReducer(). This approach gives you more flexibility. In the Reducer, you have access to the current state and the new props. The Dispatch method itself never changes, so you can put data into it from any closure. One constraint with useReducer() is that you can’t use it to perform side effects. (However, you can return to a new state — triggering some effects.)

But why does it have to be so complicated?


Impedance mismatch

This term is sometimes mentioned, as Phil Haack explains:

Some people say that databases are from Mars and objects are from Venus, and databases don’t naturally map to object models. It’s a lot like trying to push the poles of a magnet together.

Our “impedance matching” is not between the database and the object; it is between the React programming model and the imperative setInterval API.

A React component may flow through many different states before Mounted, but its render results will be described all at once.

  // Describe each render
  return <h1>{count}</h1>
Copy the code

Hooks let us use the same declaration method for effects:

UseInterval (() => {setCount(count + 1); }, isRunning ? delay : null);Copy the code

We do not set interval, but specify whether or by how much it sets delay, as our Hooks do, describing continuous processes in discrete terms

In contrast, setInterval does not describe the process in a timely manner — once an interval is set, there is nothing you can do about it other than clear it.

This is the mismatch between the React model and the setInterval API.


React components have props and state that can be changed. React rerenders them and “discards” any previous render. There is no longer any correlation between them.

UseEffect () Hook also “throws away” the result of the last render, which clears the previous effect and creates the next effect, which locks the new props and state, which is why the simple example worked correctly the first time we tried it.

But setInterval does not “drop.” It will keep referring to the old props and state until you replace it — you can’t do that without resetting the time.

Or wait, you can do it?


Refs can do it!

The question boils down to the following:

  • We perform the strip on the first rendercallback1setInterval(callback1, delay).
  • We get one with the new props and state for the next rendercallbaxk2.
  • We cannot replace an existing interval without resetting the time.

So what if instead of replacing the interval at all, we introduce a mutable savedCallback that points to the new interval callback?

Now let’s look at the scheme:

  • We callsetInterval(fn, delay), includingfncallsavedCallback.
  • After the first rendering willsavedCallbackSet tocallback1.
  • After the next renderingsavedCallbackSet tocallback2.
  • ?????
  • complete

This mutable savedCallback needs to be “persisted” during re-rendering, so it can’t be a regular variable, we want an instance-like field.

As we learned from the Hooks FAQ, useRef() gives us the result we want:

  const savedCallback = useRef();
  // { current: null }
Copy the code

(You’re probably familiar with ReactDOM refs). Hooks use the same concept to hold arbitrary mutable values. The REF is like a “box”, you can put anything in it

UseRef () returns a regular object with current for renders to share between renders. We can save the new interval to it:

Function callback() {// Can read new props, state, etc. setCount(count + 1); } // After each render, save the new callback to our ref. useEffect(() => { savedCallback.current = callback; });Copy the code

We can then read and call it from our interval:

useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, 1000); return () => clearInterval(id); } []);Copy the code

Thanks to [], interval will not be reset without reexecuting our effect. Also, thanks to savedCallback Ref, we can always read the callback after a new render and call it in the Interval tick.

Here’s the complete solution:

function Counter() { const [count, setCount] = useState(0); const savedCallback = useRef(); function callback() { setCount(count + 1); } useEffect(() => { savedCallback.current = callback; }); useEffect(() => { function tick() { savedCallback.current(); } let id = setInterval(tick, 1000); return () => clearInterval(id); } []); return <h1>{count}</h1>; }Copy the code

(seeCodeSandbox demo).


Extract a Hook

Admittedly, the above code is confusing, mixing opposing paradigms is confusing, and can mess up variable refs.

I feel that Hooks provide lower primitives than class — but their beauty is that they enable us to write and make better declarative abstractions.

Ideally, I’d just like to write this:

function Counter() {
  const [count, setCount] = useState(0);

  useInterval(() => {
    setCount(count + 1);
  }, 1000);

  return <h1>{count}</h1>;
}
Copy the code

I copied and pasted my ref mechanism code into a custom Hook:

function useInterval(callback) {
  const savedCallback = useRef();

  useEffect((a)= > {
    savedCallback.current = callback;
  });

  useEffect((a)= > {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, 1000);
    return (a)= >clearInterval(id); } []); }Copy the code

Currently, 1000 delay is dead, and I want to make it a parameter:

function useInterval(callback, delay) {
Copy the code

I’ll use it after I’ve created the interval:

    let id = setInterval(tick, delay);
Copy the code

Now delay can change between renders and I need to declare it in my interval effect dependencies section:

  useEffect(() => {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, delay);
    return () => clearInterval(id);
  }, [delay]);
Copy the code

Wait, aren’t we going to avoid resetting the Interval effect and specifically avoid it by []? Not really, we just want to avoid resetting it when the callback changes, but we want to restart the timer when the delay changes!

Let’s check if our code works:

function Counter() {
  const [count, setCount] = useState(0);

  useInterval((a)= > {
    setCount(count + 1);
  }, 1000);

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

function useInterval(callback, delay) {
  const savedCallback = useRef();

  useEffect((a)= > {
    savedCallback.current = callback;
  });

  useEffect((a)= > {
    function tick() {
      savedCallback.current();
    }

    let id = setInterval(tick, delay);
    return (a)= > clearInterval(id);
  }, [delay]);
}
Copy the code

(try itCodeSandbox).

Effective! We can now use useInterval() in any component without thinking too much about its implementation.

Benefits: Suspend Interval

Suppose we wanted to be able to suspend our interval by passing NULL as delay:

  const [delay, setDelay] = useState(1000);
  const [isRunning, setIsRunning] = useState(true);

  useInterval(() => {
    setCount(count + 1);
  }, isRunning ? delay : null);
Copy the code

How do you do this? Answer: Do not create an interval.

useEffect(() => { function tick() { savedCallback.current(); } if (delay ! == null) { let id = setInterval(tick, delay); return () => clearInterval(id); } }, [delay]);Copy the code

(seeCodeSandbox demo).

That’s it. This code handles all possible changes: changing the delay, pausing, or restoring the interval. The useEffect() API requires us to spend more upfront work describing build and clear — but it’s easy to add new cases.

Bonus: Fun Demo

UseInterval () Hook is really fun, and when side effects are declarative, it’s much easier to orchestrate complex behaviors together.

For example, we are in intervaldelayCan be controlled by another:

function Counter() { const [delay, setDelay] = useState(1000); const [count, setCount] = useState(0); UseInterval (() => {setCount(count + 1); }, delay); UseInterval (() => {if (delay > 10) {setDelay(delay / 2); }}, 1000); function handleReset() { setDelay(1000); } return ( <> <h1>Counter: {count}</h1> <h4>Delay: {delay}</h4> <button onClick={handleReset}> Reset delay </button> </> ); }Copy the code

(seeCodeSandbox demo!).

Summary at the end of

Hooks take time to get used to — especially on code that crosses imperative and declarative lines. You can create abstractions like React Spring, but sometimes they make you uneasy.

Hooks are in the early stages, no doubt this pattern still needs refining and comparison. If you are used to following well-known “best practices”, don’t rush to adopt Hooks, it takes a lot of trial and error.

I hope this article has helped you understand the FAQ of Hooks with APIs like setInterval(), overcome their patterns, and enjoy the sweet fruits of the more expressive declarative APIs built on top of them.

Making setInterval Declarative with React Hooks(2019-02-04)