React, setInterval behavior
Asked Answered
B

1

5
let updateTimer: number;

export function Timer() {
  const [count, setCount] = React.useState<number>(0);
  const [messages, setMessages] = React.useState<string[]>([]);

  const start = () => {
    updateTimer = setInterval(() => {
      const m = [...messages];
      m.push("called");
      setMessages(m);
      setCount(count + 1);
    }, 1000);
  };

  const stop = () => {
    clearInterval(updateTimer);
  };

  return (
    <>
      <div>{count}</div>
      <button onClick={start}>Start</button>
      <button onClick={stop}>Stop</button>
      {messages.map((message, i) => (
        <p key={i}>{message}</p>
      ))}
    </>
  );
}

Code Sample: https://codesandbox.io/s/romantic-wing-9yxw8?file=/src/App.tsx


The code has two buttons - Start and Stop.

  • Start calls a setInterval and saves interval id. Timer set to 1 second (1000 ms).

  • Stop calls a clearInterval on the interval id.

The interval id is declared outside the component.

The interval callback function increments a counter and appends a called message to the UI.

When I click on Start, I expect the counter to increment every second and a corresponding called message appended underneath the buttons.

What actually happens is that on clicking Start, the counter is incremented just once, and so is the called message.

If I click on Start again, the counter is incremented and subsequently reset back to its previous value.

If I keep clicking on Start, the counter keeps incrementing and resetting back to its previous value.

Can anyone explain this behavior?

Basildon answered 29/12, 2020 at 8:20 Comment(0)
U
9

You have closure on count value inside the interval's callback. Therefore after the first state update with value setState(0+1), you will have the same count value call setState(0+1) that won't trigger another render.

Use functional updates which uses the previous state value without closures:

setCount((count) => count + 1);

Same reason for messages:

setMessages(prev => [...prev,"called"]);
const start = () => {
  // should be a ref
  intervalId.current = setInterval(() => {
    setMessages((prev) => [...prev, "called"]);
    setCount((count) => count + 1);
  }, 1000);
};

Edit tender-currying-vqi49


Notice for another possible bug using an outer scope variable instead of useRef, for this read about useRef vs variable differences.


For a reference, here is a simple counter toggle example:

function Component() {
  // use ref for consisent across multiple components
  // see https://mcmap.net/q/343779/-why-need-useref-and-not-mutable-variable/57444430#57444430
  const intervalRef = useRef();

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

  // simple toggle with reducer
  const [isCounterOn, toggleCounter] = useReducer((p) => !p, false);

  // handle toggle
  useEffect(() => {
    if (isCounterOn) {
      intervalRef.current = setInterval(() => {
        setCounter((prev) => prev + 1);
      }, 1000);
    } else {
      clearInterval(intervalRef.current);
    }
  }, [isCounterOn]);

  // handle unmount
  useEffect(() => {
    // move ref value into callback scope
    // to not lose its value upon unmounting
    const intervalId = intervalRef.current;
    return () => {
      // using clearInterval(intervalRef.current) may lead to error/warning
      clearInterval(intervalId);
    };
  }, []);

  return (
    <>
      {counter}
      <button onClick={toggleCounter}>Toggle</button>
    </>
  );
}

Edit Counter Toggle

Unsnarl answered 29/12, 2020 at 8:25 Comment(2)
Ah, beat me by 7 seconds. I would suggest pushing for using a react ref and using an useEffect callback return function to also clear the interval when unmounting so there isn't a "can't update XXX of unmounted" error.Autotruck
Agree, should wrap the setInterval() in useEffect(). Trigger the effect by some state like isCounting, and set isCounting when clicking the start and stop button.Derr

© 2022 - 2024 — McMap. All rights reserved.