react hooks and setInterval
Asked Answered
H

4

11

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
  };
};
Hettie answered 30/12, 2018 at 21:33 Comment(16)
What's your question exactly? How to do something in timed intervals without "a clock"?Circumnutate
Also, because the state is updated on every interval and useEffect has a return function which clears it, it will start and stop a new clock every render. setTimeout in this instance should work exactly the sameCircumnutate
Is there any other way to the have the clock without having it running always in the background even if it's not being used? — I tried using useEffect within the start method but no resultHettie
I think useEffect has a return function which clears it, it will start and stop a new clock every render. is incorrect as per reactjs.org/docs/hooks-reference.html#useeffectHettie
if (auto) { id = setInterval(...) } like that you mean?Circumnutate
No, within startHettie
which part of that doc says its incorrect? that's exactly what the "cleanup" function does. it will run on every render unless you provide a second argument to useEffect which says under which value changes it should not runCircumnutate
you can't call useEffect or any hooks for that matter inside the body of another function, or behind a conditional. they need to run unconditionally in the main render bodyCircumnutate
Read cleaning up an event sectionHettie
I have.. I've read the docs many times and also use the hook liberally. you can test this easily yourself by putting a console log in the body of your cleanup functionCircumnutate
the docs even highlight in bold that I am correct in that exact section the previous effect is cleaned up before executing the next effect. Circumnutate
anyways if you just don't call setInterval inside your useEffect function then it won't start a timer in the background which is what you are asking forCircumnutate
Then text is misleading: The clean-up function runs before the component is removed from the UI to prevent memory leaks.Hettie
it does that as well. read the next section about conditionally firing an effect, it answers your questionCircumnutate
I don't see how that helps. What would be the dependency to use in this case?Hettie
Possible duplicate of State not updating when using React state hook within setIntervalMesolithic
L
16

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]
);
Lianneliao answered 31/12, 2018 at 19:37 Comment(0)
C
1

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);
        }
      };
    }
  );
Circumnutate answered 31/12, 2018 at 0:7 Comment(3)
The codesanbox seems not to be working — only re-renders one time. Also, in my example I'm not setting up the setInterval conditionally. I'm conditionally incrementing the value inside the handler function. Take a look here: codesandbox.io/s/5wxpnqn9kxHettie
I understand your point about setInterval and setTimeout — if there are no dependencies in useEffect, then it will setup a timer for the next render at each run.Hettie
Hmm, the sandbox is working perfectly well for me, that's odd. There don't need to be any second argument passed to make this work in theory, and it appears in practice it works too. Could you copy the code and paste it somewhere? Wondering if there's a cached difference or somethingCircumnutate
H
0

So far, it seems that both solutions below work as desired:

Conditionally creating timer — it requires that useEffect is dependent both on auto and current to work

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

Conditionally executing update to state — it does not require useEffect dependencies

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

Both solutions work if we replace setInterval by setTimeout

Hettie answered 31/12, 2018 at 9:26 Comment(0)
C
0

You could use useTimeout hook that returns true after specified number of milliseconds.

Corabelle answered 11/2, 2019 at 15:7 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.