This article is published by the Cloud + community

By Dan Abramov

After spending a lot of time with React Hooks, you might run into a magic problem: setInterval isn’t as easy to use as you think.

Ryan Florence tweeted:

Many of my friends have told me that setInterval and hooks are used together, and it’s a bit of a dick blues.

To be honest, these friends are not bullshit. It was confusing when I first got to grips with Hooks.

But I don’t think it’s Hooks, it’s a pattern difference between the React programming model and setInterval. Hooks are more closely aligned with the React programming model than classes, making this difference even more striking.

It’s a little convoluted, but there are ways to make them work together.

This article explores how setInterval and Hooks play in harmony, why they play that way, and what new abilities they give you.


Disclaimer: This article uses step-by-step examples to explain the problem. So there are some examples where it looks like there’s a shortcut, but let’s take it one step at a time.

If you’re new to Hooks and don’t quite understand what I’m dealing with, read the Introduction of React Hooks and the official documentation. This article assumes that the reader has been using Hooks for more than an hour.


Code?

We can easily implement a counter that increments itself per second by:

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

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

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

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

(CodeSandbox online example)

UseInterval is not a built-in React Hook, but a custom Hook that I implemented:

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

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

  // Remember the latest 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

(In case you missed it, here’s a similar CodeSandbox online example.)

The useInterval Hook I implemented set a timer and cleared it when the component was unmounted. This is done through a combination of setInterval and clearInterval bindings on the component lifecycle.

This is an implementation that can be copied and pasted from project to project, and you can even publish to NPM.

Readers who do not care why this is done need not read on. The following is for readers who want a deeper understanding of React Hooks.


Huh? ! 🤔

I know what you’re thinking:

Dan, there’s something wrong with the code. What about “pure JavaScript”? React Hooks in the face of the React philosophy?

Well, that’s what I thought at first, but then I changed my mind, and now I’m going to change yours, too. Before we begin, let me describe the capabilities of this implementation.


whyuseInterval()Is it a more reasonable API?

UseInterval Hook takes a function and a delay as arguments:

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

This is very similar to the native setInterval:

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

So why not just use setInterval?

The biggest difference between setInterval and useInterval Hook is that the parameter of useInterval Hook is “dynamic”. It may not be obvious at first glance.

I’ll illustrate this with a practical example:


If we want the interval to be adjustable:

Instead of manually controlling the delay, adjust the Hooks parameters dynamically. For example, we can reduce the frequency of AJAX updates when the user switches to another TAB.

How do you implement this with a setInterval, Class style? I made this:

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 = (a)= > {
    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} /> </> ); }}Copy the code

(CodeSandbox online example)

Too familiar!

What about using Hooks instead?

🥁🥁🥁 The show is on!

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

  useInterval((a)= > {
    // 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

(CodeSandbox online example)

No, that’s all!

UseInterval Hook “upgrades” to a version that supports dynamic delay adjustment without adding any complexity.

Use useInterval to add dynamic delay capability with almost no added complexity. This advantage is unmatched by using class.

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

// Dynamic delay
useInterval((a)= > {
  setCount(count + 1);
}, delay);
Copy the code

When useInterval receives another delay, it resets the timer.

Instead of executing code to set or clear timers, we declare timers with specific delays – this is the root cause of our implementation of useInterval.

What if you want to temporarily pause the timer? I can do it like this:

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

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

(Online example)

That’s why Hooks and React get me excited again. We can wrap the original calling API into a declarative API to better express our intent. Just like rendering, we can describe the state of each point at the current time without having to carefully manipulate them through specific commands.


At this point, I hope you’ve convinced yourself that useInterval Hook is a better API – at least when used at the component level.

But why is using setInterval and clearInterval in Hooks so annoying? Going back to the original timer example, let’s try to implement it manually.


For the first time,

Most simply, render the initial state:

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

Now I want it to update every second. I’m going to use useEffect() and return a cleanup method because it’s a Side Effect that needs to be cleaned up:

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

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

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

(Check out the CodeSandbox online sample)

Looks simple?

However, this code has a weird behavior.

React defaults to re-executing Effects every time it renders. This is as expected, and the mechanism circumvents a number of problems that existed earlier in the React Class component.

In general, this is a good feature because most subscription apis allow you to remove an old subscription and replace it with a new one. However, this does not include setInterval. When you reset the setInterval after calling clearInterval, the timing will be reset. If we re-render so frequently that the effects execute so frequently, the timer might not have a chance to fire at all!

We can reproduce this BUG by re-rendering our component at a smaller time interval:

setInterval((a)= > {
  // Effect reexecution caused by rerendering causes the timer to reexecute before it is called,
  // it is cleared by clearInterval(), and setInterval()
  // The reset timer will restart the timer
  ReactDOM.render(<Counter />, rootElement);
}, 100);
Copy the code

(See online examples of this BUG.)


The second time

As some readers may know, the useEffect allows us to control the actual reexecution. By specifying the dependency array in the second argument, React re-executes Effect only when the dependency array changes.

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

If we want effect to be executed only when the component is mounted and cleaned up when the component is unmounted, we can pass an empty array [] as a dependency.

But! Readers who are not particularly familiar with JavaScript closures are likely to make a common mistake. Let me show you! (We’re designing lint rules to help locate such errors, but we’re not ready yet.)

The problem the first time was that the reexecution of effect caused the timer to be cleared too early. If you don’t re-execute them, you might be able to solve this problem:

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

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

If this is done, the timer will stop when it is updated to 1. (See online examples of this BUG.)

What happened?

The problem is that the count used by useEffect is taken during the first rendering. When you get it, it’s 0. Since effect is never re-executed, the count used in the closure by setInterval is always from the first rendering, so count + 1 is always 1. Ha ha da!

I get the feeling you’re already kicking ass. Hooks are Hooks.

One solution to this problem is to replace setCount(count + 1) with the “update callback” method setCount(c => c + 1). From the callback parameter, you can get the latest status. This is not foolproof, and the new props cannot read it.

Another solution is to use useReducer(). This scheme is more flexible. From the Reducer, you can access the current status and the latest props. The dispatch method itself doesn’t change, so you can flood any data inside a closure. One limitation of using useReducer() is that you cannot trigger effects internally. (You can, however, trigger some effects by returning a new state.)

Why is it so hard?


Impedance mismatch

The term “Impedance Mismatch” is used in many places. Phil Haack explains:

Some say databases are from Mars and objects are from Venus. Databases do not naturally map to object models. This is like trying to squeeze the N poles of two magnets together.

When we say “impedance mismatch” here, we are not talking about databases and objects. It’s a mismatch between the React programming model and the imperative setInterval API.

A React component may be mounted for a while and go through several different states, but its render result describes all of those states at once

// describes the state of each render
return <h1>{count}</h1>
Copy the code

In the same vein, Hooks let us use some effects declaratively:

// Describe the state of each counter
useInterval((a)= > {
  setCount(count + 1);
}, isRunning ? delay : null);
Copy the code

We don’t need to set the timer, but specify whether it should be set and at what intervals. This is what we did with our prior Hook. By discrete statements, we describe a continuous process.

SetInterval, on the other hand, doesn’t describe the entire process – once you set the timer, it can’t be changed, only cleared.

This is the “impedance mismatch” between the React model and the setInterval API.


The React component will be re-rendered whenever its props and state change, and the render result will be “forgotten” completely. Between renderings, there is no correlation.

UseEffect () hooks also “forget” previous results. It cleans up the previous effect and sets the new effect. The new effect gets the new props and state. So our first advance can be performed in some simple cases.

But setInterval() doesn’t “forget.” It will keep referring to the old props and state until it is changed. But once it’s changed, it’s impossible not to reset the time.

Wait a minute. Really?


Refs is a lifesaver!

To sort out the questions:

  • For the first rendering, usecallback1forsetInterval(callback1, delay)
  • For the next render, usecallback2You can access the new props and state
  • We can’t replace Callback1 with callback2 without resetting the timer

What if instead of replacing the timer at all, we pass in a savedCallback variable that always points to the latest timer callback?

Now our solution looks like this:

  • Set timersetInterval(fn, delay), includingfncallsavedCallback.
  • First render, setsavedCallbackcallback1
  • Second render, setsavedCallbackcallback2
  • ?????
  • the

Mutable savedCallback needs to be “persisted” between multiple renders, so regular variables cannot be used. We need something like instance fields.

From the Hooks FAQ, useRef() can help us do this:

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

(You’re probably already familiar with the React DOM Refs. Hooks reference the same concept for holding arbitrarily variable values. A ref is a “box” into which you can put things.

UseRef () returns a literal holding a mutable current property that is shared between each render. We can save the latest timer callback there.

function callback() {
  // Can read the latest state and props
  setCount(count + 1);
}

// Each render, save the latest callback to ref
useEffect((a)= > {
  savedCallback.current = callback;
});
Copy the code

It can then be called in a timer callback:

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

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

Due to the passing of [], our effect will not be re-executed, so the timer will not be reset. On the other hand, with the savedCallback ref set, we can get the callback set at the last render and call it when the timer fires.

Look again at the full implementation:

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

  function callback() {
    setCount(count + 1);
  }

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

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

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

(Check out the CodeSandbox online sample)


Extract as a custom Hook

Admittedly, the above code is a bit confusing. All sorts of fancy manipulations are confusing and can confuse state and refs with other logic.

In my opinion, Hooks provide lower level capabilities than Class – but the beauty of Hooks is that they allow us to reorganize and abstract to create Hooks that are declaratively better

In fact, I want to write it like this:

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

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

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

So I copied my implementation core into the 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

The delay value 1000 is hardcoded, parameterizing it:

function useInterval(callback, delay) {
Copy the code

When setting a timer:

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

Now that delay can change between renders, I need to declare it as a timer effect dependency:

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

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

Wait, didn’t we just pass in a [] to avoid a timer reset? Not exactly. We just want the Hooks not to re-execute on callback changes. If the delay changes, we want to restart the timer.

Now let’s see 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

(Readers can try it on CodeSandbox.)

Stick! We can now use useInterval() whenever we need it in any component without worrying about implementation details.

Bonus: Pause the timer

We want to pause the timer when null is passed to Delay:

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

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

How do you do that? Easy: Don’t set a timer.

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

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

(CodeSandbox online example)

That’s it. This code can handle all kinds of possible changes: delay value changes, pauses, and continues. While the useEffect() API required a lot of setup and cleanup up front, adding new capabilities was easy.

Bonus: 有趣的 Demo

UseInterval () Hook is a fun Hook. Now that Side Effects is declarative, it’s much easier to combine.

For example, we can use one timer to control the delay of another timer:

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

  // Increment the counter.
  useInterval((a)= > {
    setCount(count + 1);
  }, delay);

  // Make it faster every second!
  useInterval((a)= > {
    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

(CodeSandbox online example)

conclusion

Hooks take some getting used to – especially when dealing with the difference between imperative and declarative code. You can create declarative abstractions as powerful as React Spring, but their complex usage can occasionally make you nervous.

Hooks is still young, and there are many more patterns that we can study and compare. If you are in the habit of following “best practices”, don’t worry about using Hooks. It will take time for the community to experiment and explore more content.

There are some issues when using Hooks that involve apis like setInterval(). After reading this article, I hope you can understand them and solve them, while enjoying the benefits of creating a more semantically declarative API.

This article has been published by Tencent Cloud + community in various channels

For more fresh technology dry goods, you can follow usTencent Cloud technology community – Cloud Plus community official number and Zhihu organization number