49

The code is here: https://codesandbox.io/s/nw4jym4n0

export default ({ name }: Props) => {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCounter(counter + 1);
    }, 1000);

    return () => {
      clearInterval(interval);
    };
  });

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

The problem is each setCounter trigger re-rendering so the interval got reset and re-created. This might looks fine since the state(counter) keeps incrementing, however it could freeze when combining with other hooks.

What's the correct way to do this? In class component it's simple with a instance variable holding the interval.

Yangshun Tay
  • 49,270
  • 33
  • 114
  • 141
PeiSong
  • 871
  • 1
  • 7
  • 12

3 Answers3

97

You could give an empty array as second argument to useEffect so that the function is only run once after the initial render. Because of how closures work, this will make the counter variable always reference the initial value. You could then use the function version of setCounter instead to always get the correct value.

Example

const { useState, useEffect } = React;

function App() {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    const interval = setInterval(() => {
      setCounter(counter => counter + 1);
    }, 1000);

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

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

ReactDOM.render(
  <App />,
  document.getElementById('root')
);
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

<div id="root"></div>

A more versatile approach would be to create a new custom hook that stores the function in a ref and only creates a new interval if the delay should change, like Dan Abramov does in his great blog post "Making setInterval Declarative with React Hooks".

Example

const { useState, useEffect, useRef } = React;

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

  // Remember the latest callback.
  useEffect(() => {
    savedCallback.current = callback;
  }, [callback]);

  // Set up the interval.
  useEffect(() => {
    let id = setInterval(() => {
      savedCallback.current();
    }, delay);
    return () => clearInterval(id);
  }, [delay]);
}

function App() {
  const [counter, setCounter] = useState(0);

  useInterval(() => {
    setCounter(counter + 1);
  }, 1000);

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

ReactDOM.render(
  <App />,
  document.getElementById('root')
);
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

<div id="root"></div>
Tholle
  • 108,070
  • 19
  • 198
  • 189
  • Did you really need to save it here with useRef? Couldn't you just call the callback straight without referencing it? – RegarBoy Nov 26 '21 at 21:53
15

As another answer by @YangshunTay already shows, it's possible to make it useEffect callback run only once and work similarly to componentDidMount. In this case it's necessary to use state updater function due to the limitations imposed by function scopes, otherwise updated counter won't be available inside setInterval callback.

The alternative is to make useEffect callback run on each counter update. In this case setInterval should be replaced with setTimeout, and updates should be limited to counter updates:

export default ({ name }: Props) => {
  const [counter, setCounter] = useState(0);

  useEffect(() => {
    const timeout = setTimeout(() => {
      setCounter(counter + 1);
    }, 1000);

    return () => {
      clearTimeout(timeout);
    };
  }, [counter]);

  return <h1>{counter}</h1>;
};
Gangula
  • 5,193
  • 4
  • 30
  • 59
Estus Flask
  • 206,104
  • 70
  • 425
  • 565
  • 4
    This is not ideal as you will keep run `setTimeout` on every render and seems kind of unnecessary. – Yangshun Tay Nov 20 '18 at 16:33
  • 3
    I don't see anything wrong with this. Performance concerns are nonexistent. Recursive setTimeout is a common replacement for setInterval in JS when it's justified. State updater function looks fine in setInterval in this particular case, i.e. for simple increment. This would be very different if several states were involved. – Estus Flask Nov 20 '18 at 16:43
14

The correct way to do this would be to run the effect only once. Since you only need to run the effect once because during mounting, you can pass in an empty array as a second argument to achieve.

However, you will need to change setCounter to use the previous value of counter. The reason is because the callback passed into setInterval's closure only accesses the counter variable in the first render, it doesn't have access to the new counter value in the subsequent render because the useEffect() is not invoked the second time; counter always has the value of 0 within the setInterval callback.

Like the setState you are familiar with, state hooks have two forms: one where it takes in the updated state, and the callback form which the current state is passed in. You should use the second form and read the latest state value within the setState callback to ensure that you have the latest state value before incrementing it.

function Counter() {
  const [counter, setCounter] = React.useState(0);
  React.useEffect(() => {
    const timer = setInterval(() => {
      setCounter(prevCount => prevCount + 1); // <-- Change this line!
    }, 1000);
    return () => {
      clearInterval(timer);
    };
  }, []); // Pass in empty array to run effect only once!

  return (
    <div>Count: {counter}</div>
  );
}

ReactDOM.render(<Counter />, document.querySelector('#app'));
<script src="https://unpkg.com/react@16.7.0-alpha.0/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@16.7.0-alpha.0/umd/react-dom.development.js"></script>

<div id="app"></div>
Yangshun Tay
  • 49,270
  • 33
  • 114
  • 141