Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

react hooks and setInterval

Is there any alternative to just keeping a "clock" in the background to implement auto-next (after a few seconds) in carousel using react hooks?

The custom react hook below implements a state for a carousel that supports manual (next, prev, reset) and automatic (start, stop) methods for changing the carousel's current (active) index.

const useCarousel = (items = []) => {
  const [current, setCurrent] = useState(
    items && items.length > 0 ? 0 : undefined
  );

  const [auto, setAuto] = useState(false);

  const next = () => setCurrent((current + 1) % items.length);
  const prev = () => setCurrent(current ? current - 1 : items.length - 1);
  const reset = () => setCurrent(0);
  const start = _ => setAuto(true);
  const stop = _ => setAuto(false);


useEffect(() => {
    const interval = setInterval(_ => {
      if (auto) {
        next();
      } else {
        // do nothing
      }
    }, 3000);
    return _ => clearInterval(interval);
  });

  return {
    current,
    next,
    prev,
    reset,
    start,
    stop
  };
};
like image 249
marco alves Avatar asked Dec 30 '18 21:12

marco alves


People also ask

How do you use setInterval in React hooks?

setInterval is a method that calls a function or runs some code after specific intervals of time, as specified through the second parameter. For example, the code below schedules an interval to print the phrase: “Interval triggered” every second to the console until it is cleared.

Can we use setInterval in React?

Using setInterval lets you execute a function at specific intervals. It's often very useful in React apps, for example for checking a condition regularly or fetching data every so often.

How do you write setInterval in useEffect?

The code can be as simple as follows: useEffect(() => { setInterval(() => { /* Run a function or set any state here */ }, 1000); }, []); By combining the setInterval() method with useEffect and useState hooks, you can create a timer that counts how many seconds have passed since the component has mounted.

Is setInterval a side effect?

First, it should be clear that setInterval() is a side effect. After all, it's not directly tied to a component's render method. Therefore we should call it inside a useEffect() hook and use its return to call clearInterval() when unmounting.


Video Answer


2 Answers

There are differences between setInterval and setTimeout that you may not want to lose by always restarting your timer when the component re-renders. This fiddle shows the difference in drift between the two when other code is also running. (On older browsers/machines—like from when I originally answered this question—you don't even need to simulate a large calculation to see a significant drift begin to occur after only a few seconds.)

Referring now to your answer, Marco, the use of setInterval is totally lost because effects without conditions dispose and re-run every time the component re-renders. So in your first example, the use of the current dependency causes that effect to dispose and re-run every time the current changes (every time the interval runs). The second one does the same thing, but actually every time any state changes (causing a re-render), which could lead to some unexpected behavior. The only reason that one works is because next() causes a state change.

Considering the fact that you are probably not concerned with exact timing, is is cleanest to use setTimeout in a simple fashion, using the current and auto vars as dependencies. So to re-state part of your answer, do this:

useEffect(
  () => {
    if (!auto) return;
    const interval = setTimeout(_ => {
      next();
    }, autoInterval);
    return _ => clearTimeout(interval);
  },
  [auto, current]
);

Generically, for those just reading this answer and want a way to do a simple timer, here is a version that doesn't take into account the OP's original code, nor their need for a way to start and stop the timer independently:

const [counter, setCounter] = useState(0);
useEffect(
  () => {
    const id= setTimeout(() => {
      setCounter(counter + 1); 
      // You could also do `setCounter((count) => count + 1)` instead.
      // If you did that, then you wouldn't need the dependency
      // array argument to this `useEffect` call.
    }, 1000);
    return () => {
      clearTimeout(id);
    };
  },
  [counter],
);

However, you may be wondering how to use a more exact interval, given the fact that setTimeout can drift more than setInterval. Here is one method, again, generic without using the OP's code:

// Using refs:

const [counter, setCounter] = useState(30);
const r = useRef(null);
r.current = { counter, setCounter };
useEffect(
  () => {
    const id = setInterval(() => {
      r.current.setCounter(r.current.counter + 1);
    }, 1000);
    return () => {
      clearInterval(id);
    };
  },
  [] // empty dependency array
);

// Using the function version of `setCounter` is cleaner:

const [counter, setCounter] = useState(30);
useEffect(
  () => {
    const id = setInterval(() => {
      setCounter((count) => count + 1);
    }, 1000);
    return () => {
      clearInterval(id);
    };
  },
  [] // empty dependency array
);

Here is what is going on above:

(first example, using refs): To get setInterval's callback to always refer to the currently acceptable version of setCounter we need some mutable state. React gives us this with useRef. The useRef function will return an object that has a current property. We can then set that property (which will happen every time the component re-renders) to the current versions of counter and setCounter.

(second example, using functional setCounter): Same idea as the first, except that when we use the function version of setCounter, we will have access to the current version of the count as the first argument to the function. No need to use a ref to keep things up to date.

(both examples, continued): Then, to keep the interval from being disposed of on each render, we add an empty dependency array as the second argument to useEffect. The interval will still be cleared when the component is unmounted.

Note: I used to like using ["once"] as my dependency array to indicate that I am forcing this effect to be set up only once. It was nice for readability at the time, but I no longer use it for two reasons. First, hooks are more widely understood these days and we have seen the empty array all over the place. Second, it clashes with the very popular "rule of hooks" linter which is quite strict about what goes in the dependency array.

So applying what we know to the OP's original question, you could use setInterval for a less-likely-to-drift slideshow like this:

// ... OP's implementation code including `autoInterval`,
// `auto`, and `next` goes above here ...

const r = useRef(null);
r.current = { next };
useEffect(
  () => {
    if (!auto) return;
    const id = setInterval(() => {
      r.current.next();
    }, autoInterval);
    return () => {
      clearInterval(id);
    };
  },
  [auto]
);
like image 82
Don Avatar answered Oct 05 '22 23:10

Don


Because the current value is going to change on every "interval" as long as it should be running, then your code will start and stop a new timer on every render. You can see this in action here:

https://codesandbox.io/s/03xkkyj19w

You can change setInterval to be setTimeout and you will get the exact same behaviour. setTimeout is not a persistent clock, but it doesn't matter since they both get cleaned up anyways.

If you do not want to start any timer at all, then put the condition before setInterval not inside of it.

  useEffect(
    () => {
      let id;

      if (run) {
        id = setInterval(() => {
          setValue(value + 1)
        }, 1000);
      }

      return () => {
        if (id) {
          alert(id) // notice this runs on every render and is different every time
          clearInterval(id);
        }
      };
    }
  );
like image 25
azium Avatar answered Oct 05 '22 22:10

azium