0

I'm making a simple progress bar and I've noticed strange behaviour of hook state when I'm using setInterval. Here is my sample code:

const {useState} = React;

const Example = ({title}) => {
  const [count, setCount] = useState(0);
  const [countInterval, setCountInterval] = useState(0)
  
  let intervalID
 
  const handleCount = () => {
    setCount(count + 1)
    console.log(count)
  }  
    
  const progress = () => {
    intervalID = setInterval(() => {
  setCountInterval(countInterval => countInterval + 1)
  console.log(countInterval)
  if(countInterval > 100) { // this is never reached
      setCountInterval(0)
      clearInterval(intervalID)
  }
 },100)
  }
  
  const stopInterval = () => {
    clearInterval(intervalID)
  }
    
  return (
    <div>
      <p>{title}</p>
      <p>You clicked {count} times</p>
      <p>setInterval count { countInterval } times</p>
      <button onClick={handleCount}>
        Click me
      </button>
      <button onClick={progress}>
        Run interval
      </button>
      <button onClick={stopInterval}>
        Stop interval
      </button>
    </div>
  );
};

// Render it
ReactDOM.render(
  <Example title="Example using simple hook:" />,
  document.getElementById("app")
);
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/16.8.4/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/16.8.4/umd/react-dom.production.min.js"></script>
<div id="app"></div>

If I set state by handleCount everything is happen as expected, but when I'm running progress function, inside setInterval countInterval value doesn't change at all. Regardless of it, countInterval has changed in the state.

To get this around I'm using variable inside progress function, like this:

  const progress = () => {
    let internalValue = 0
    intervalID = setInterval(() => {
        setCountInterval(internalValue)
        internalValue++
        if(internalValue > 100) {
      setCountInterval(0)
      clearInterval(intervalID)
        }
    },100)
  }

And that works fine, but I'm still wondering if there is a better approach and what I'm doing wrong in the first case.

The second problem is that I can't clear interval outside a progress function and I'm not sure what I'm doing wrong here or I miss something? Thanks in advance for any help and advices.

Ayush Gupta
  • 8,716
  • 8
  • 59
  • 92
cccn
  • 929
  • 1
  • 8
  • 20

2 Answers2

3

Your problems are caused by your timer references being lost on re-renders, and the setInterval call-back referencing out-of-date versions of setCountInterval.

To get it fully working, I would suggest adding a state variable to track whether it is started or not and a useEffect to handle the setting and clearing of setInterval.

const Example = ({ title }) => {
  const [count, setCount] = useState(0);
  const [countInterval, setCountInterval] = useState(0);
  const [started, setStarted] = useState(false);

  const handleCount = () => {
    setCount(count + 1);
    console.log(count);
  };

  useEffect(() => {
    let intervalID;
    if (started) {
      intervalID = setInterval(() => {
        setCountInterval(countInterval => countInterval + 1);
        console.log(countInterval);
        if (countInterval > 100) {
          setCountInterval(0);
          setStarted(false);
        }
      }, 100);
    } else {
      clearInterval(intervalID);
    }
    return () => clearInterval(intervalID);
  }, [started, countInterval]);

  return (
    <div>
      <p>{title}</p>
      <p>You clicked {count} times</p>
      <p>setInterval count {countInterval} times</p>
      <button onClick={handleCount}>Click me</button>
      <button onClick={() => setStarted(true)}>Run interval</button>
      <button onClick={() => setStarted(false)}>Stop interval</button>
    </div>
  );
};

Working sandbox here: https://codesandbox.io/s/elegant-leaf-h03mi

Will Jenkins
  • 9,507
  • 1
  • 27
  • 46
2

countInterval is a local variable, that contains a primitive value. By the nature of JavaScript, there is no way for setState to mutate that variable in any way. Instead, it reexecutes the whole Example function, and then useState will return the new updated value. That's why you can't access the updated state, until the component rerenders.

Your problem can trivially be solved by moving the condition into the state update callback:

 setCountInterval(countInterval => countInterval > 100 ? 0 : countInterval + 1)

Because of the rerendering, you can't use local variables, so intervalId will be recreated at every rerender (and it's value gets lost). Use useRef to use values across rerenders.

  const interval = useRef(undefined);
  const [count, setCount] = useState(0);

  function stop() { 
    if(!interval.current) return;
    clearInterval(interval.current);
    interval.current = null;
  }

  function start() {
    if(!interval.current) interval.current = setInterval(() => {
      setCount(count => count > 100 ? 0 : count + 1);
    });
 }

 useEffect(() => {
   if(count >= 100) stop();
 }, [count]);
Jonas Wilms
  • 132,000
  • 20
  • 149
  • 151