TransWikia.com

React interval using old state inside of useEffect

Stack Overflow Asked by user10832440 on January 23, 2021

I ran into a situation where I set an interval timer from inside useEffect. I can access component variables and state inside the useEffect, and the interval timer runs as expected. However, the timer callback doesn’t have access to the component variables / state. Normally, I would expect this to be an issue with "this". However, I do not believe "this" is the the case here. No puns were intended. I have included a simple example below:

import React, { useEffect, useState } from 'react';

const App = () => {
  const [count, setCount] = useState(0);
  const [intervalSet, setIntervalSet] = useState(false);

  useEffect(() => {
    if (!intervalSet) {
      setInterval(() => {
        console.log(`count=${count}`);
        setCount(count + 1);
      }, 1000);
      setIntervalSet(true);
    }
  }, [count, intervalSet]);

  return <div></div>;
};

export default App;

The console outputs only count=0 each second. I know that there’s a way to pass a function to the setCount which updates current state and that works in this trivial example. However, that was not the point I was trying to make. The real code is much more complex than what I showed here. My real code looks at current state objects that are being managed by async thunk actions. Also, I am aware that I didn’t include the cleanup function for when the component dismounts. I didn’t need that for this simple example.

3 Answers

The first time you run the useEffect the intervalSet variable is set to true and your interval function is created using the current value (0).

On subsequent runs of the useEffect it does not recreate the interval due to the intervalSet check and continues to run the existing interval where count is the original value (0).

You are making this more complicated than it needs to be.

The useState set function can take a function which is passed the current value of the state and returns the new value, i.e. setCount(currentValue => newValue);

An interval should always be cleared when the component is unmounted otherwise you will get issues when it attempts to set the state and the state no longer exists.

import React, { useEffect, useState } from 'react';

const App = () => {
    // State to hold count.
    const [count, setCount] = useState(0);

    // Use effect to create and clean up the interval 
    // (should only run once with current dependencies)
    useEffect(() => {
        // Create interval get the interval ID so it can be cleared later.
        const intervalId = setInterval(() => {
            // use the function based set state to avoid needing count as a dependency in the useEffect.
            // this stops the need to code logic around stoping and recreating the interval.
            setCount(currentCount => {
                console.log(`count=${currentCount}`);
                return currentCount + 1;
            });
        }, 1000);

        // Create function to clean up the interval when the component unmounts.
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
            }
        }
    }, [setCount]);

  return <div></div>;
};

export default App;

You can run the code and see this working below.

const App = () => {
    // State to hold count.
    const [count, setCount] = React.useState(0);

    // Use effect to create and clean up the interval 
    // (should only run once with current dependencies)
    React.useEffect(() => {
        // Create interval get the interval ID so it can be cleared later.
        const intervalId = setInterval(() => {
            // use the function based set state to avoid needing count as a dependency in the useEffect.
            // this stops the need to code logic around stoping and recreating the interval.
            setCount(currentCount => {
                console.log(`count=${currentCount}`);
                return currentCount + 1;
            });
        }, 1000);

        // Create function to clean up the interval when the component unmounts.
        return () => {
            if (intervalId) {
                clearInterval(intervalId);
            }
        }
    }, [setCount]);

  return <div></div>;
};

ReactDOM.render(<App />, document.getElementById('app'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>

<div id="app"></div>

Correct answer by Jacob Smit on January 23, 2021

If you need a more complex implementation as mention in your comment on another answer, you should try using a ref perhaps. For example, this is a custom interval hook I use in my projects. You can see there is an effect that updates callback if it changes.

This ensures you always have the most recent state values and you don't need to use the custom updater function syntax like setCount(count => count + 1).

const useInterval = (callback, delay) => {
  const savedCallback = useRef()

  useEffect(() => {
    savedCallback.current = callback
  }, [callback])

  useEffect(() => {
    if (delay !== null) {
      const id = setInterval(() => savedCallback.current(), delay)
      return () => clearInterval(id)
    }
  }, [delay])
}

// Usage

const App = () => {
  useInterval(() => {
    // do something every second
  }, 1000)

  return (...)
}

This is a very flexible option you could use. However, this hook assumes you want to start your interval when the component mounts. Your code example leads me to believe you want this to start based on the state change of the intervalSet boolean. You could update the custom interval hook, or implement this in your component.

It would look like this in your example:

const useInterval = (callback, delay, initialStart = true) => {
  const [start, setStart] = React.useState(initialStart)
  const savedCallback = React.useRef()

  React.useEffect(() => {
    savedCallback.current = callback
  }, [callback])

  React.useEffect(() => {
    if (start && delay !== null) {
      const id = setInterval(() => savedCallback.current(), delay)
      return () => clearInterval(id)
    }
  }, [delay, start])
  
  // this function ensures our state is read-only
  const startInterval = () => {
    setStart(true)
  }
  
  return [start, startInterval]
}

const App = () => {
  const [countOne, setCountOne] = React.useState(0);
  const [countTwo, setCountTwo] = React.useState(0);
  
  const incrementCountOne = () => {
    setCountOne(countOne + 1)
  }
  
  const incrementCountTwo = () => {
    setCountTwo(countTwo + 1)
  }
  
  // Starts on component mount by default
  useInterval(incrementCountOne, 1000)

  // Starts when you call `startIntervalTwo(true)`
  const [intervalTwoStarted, startIntervalTwo] = useInterval(incrementCountTwo, 1000, false)

  return (
    <div>
      <p>started: {countOne}</p>
      <p>{intervalTwoStarted ? 'started' : <button onClick={startIntervalTwo}>start</button>}: {countTwo}</p>
    </div>
  );
};

ReactDOM.render(<App />, document.getElementById('app'))
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.1/umd/react.production.min.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.1/umd/react-dom.production.min.js"></script>

<div id="app"></div>

Answered by Nitsew on January 23, 2021

The problem is the interval is created only once and keeps pointing to the same state value. What I would suggest - move firing the interval to separate useEffect, so it starts when the component mounts. Store interval in a variable so you are able to restart it or clear. Lastly - clear it with every unmount.

const App = () => {
  const [count, setCount] = React.useState(0);
  const [intervalSet, setIntervalSet] = React.useState(false);

  React.useEffect(() => {
    setIntervalSet(true);
  }, []);

  React.useEffect(() => {
    const interval = intervalSet ? setInterval(() => {
      setCount((c) => {
         console.log(c);
         return c + 1;
      });
    }, 1000) : null;

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

  return null;
};

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

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

Answered by kind user on January 23, 2021

Add your own answers!

Ask a Question

Get help from others!

© 2024 TransWikia.com. All rights reserved. Sites we Love: PCI Database, UKBizDB, Menu Kuliner, Sharing RPP