Skip to content
Advertisement

React, setInterval behavior

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?

Advertisement

Answer

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://stackoverflow.com/questions/57444154/why-need-useref-to-contain-mutable-variable-but-not-define-variable-outside-the/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

User contributions licensed under: CC BY-SA
8 People found this is helpful
Advertisement