Search code examples
reactjssettimeoutreact-state-management

Create `setTimeout` Loop In React Without Cyclic Dependencies


I have the following situation and don't know what's the best way to approach it. I want a component with two states, playing and items, when playing is set to true, it should add a new item to items every second, where the new item depends on the content in items so far. So my naive approach would be the following:

function App() {
  const [playing, setPlaying] = useState(false);
  const [items, setItems] = useState([]);
  const addItem = useCallback(
    function () {
      /* adding new item */
    },
    [items]
  );
  useEffect(
    function () {
      let timeout;
      playing &&
        (function loop() {
          timeout = window.setTimeout(loop, 1000);
          addItem();
        })();
      return function () {
        window.clearTimeout(timeout);
      };
    },
    [addItem, playing]
  );
  /* render the items */
}

(I could use setInterval here, but I want to add another state later on, to change the interval while the loop is running, for this setTimeout works better.)

The problem here is that the effect depends on addItem and addItem depends on items, so as soon as playing switches to true, the effect will be caught in an infinite loop (adding a new item, then restarting itself immediately because items has changed). What's the best way to avoid this?

One possibility would be using a ref pointing to items, then have an effect only updating the ref whenever items changes, and using the ref inside addItem, but that doesn't seem like the React way of thinking.

Another possibility is to not use items in addItem but only setItems and using a callback to get access to the current items value. But this method fails when addItem manipulates more than a single state (a situation I've encountered before).


Solution

  • Implements functional approach to setting state, while defining the function to invoke the same within useState to remove dependency. ultimately allows setInterval to be used which feels more natural for this case.

    import * as React from "react";
    import { useState, useEffect, useCallback } from "react";
    import { render } from "react-dom";
    
    
    function App() {
      const [playing, setPlaying] = useState(true);
      const [items, setItems] = useState([1]);
    
      useEffect(
        function () {
          const addItem = () => (
              setItems((arr) => [...arr, arr[arr.length - 1] + 1])
          );
        
    
          setTimeout(() => setPlaying(false), 10000)
          let interval: any;
        
          if (playing) {
            (function() {
              interval = window.setInterval(addItem, 1000);
            })();
          }
    
          return function () {
            clearInterval(interval);
          };
        },
        [playing]
      );
    
    
        return (
          <div>
            {items.map((item) => (<div key={item}>{item}</div>))}
          </div>
        )
    
      }
    
    
    render(<App />, document.getElementById("root"));