How do I loop an image carousel with React setState and setInterval?

Viewed 1586

I am trying to set up an image carousel that loops through 3 images when you mouseover a div. I'm having trouble trying to figure out how to reset the loop after it reaches the third image. I need to reset the setInterval so it starts again and continuously loops through the images when you are hovering over the div. Then when you mouseout of the div, the loop needs to stop and reset to the initial state of 0. Here is the Code Sandbox:

https://codesandbox.io/s/pedantic-lake-wn3s7

import React, { useState, useEffect } from "react";
import { images } from "./Data";
import "./styles.css";

export default function App() {
  let timer;
  const [count, setCount] = useState(0);

  const updateCount = () => {
    timer = setInterval(() => {
      setCount((prevCount) => prevCount + 1);
    }, 1000);

    if (count === 3) clearInterval(timer);
  };

  const origCount = () => {
    clearInterval(timer);
    setCount((count) => 0);
  };

  return (
    <div className="App">
      <div className="title">Image Rotate</div>
      <div onMouseOver={updateCount} onMouseOut={origCount}>
        <img src={images[count].source} alt={images.name} />
        <p>count is: {count}</p>
      </div>
    </div>
  );
}
4 Answers

Anything involving timers/intervals is an excellent candidate for useEffect, because we can easily register a clear action in the same place that we set the timer using effects with cleanup. This avoids the common pitfalls of forgetting to clear an interval, e.g. when the component unmounts, or losing track of interval handles. Try something like the following:

import React, { useState, useEffect } from "react";
import { images } from "./Data";
import "./styles.css";

export default function App() {
  const [count, setCount] = useState(0);
  const [mousedOver, setMousedOver] = useState(false);

  useEffect(() => {
    // set an interval timer if we are currently moused over
    if (mousedOver) {
      const timer = setInterval(() => {
        // cycle prevCount using mod instead of checking for hard-coded length
        setCount((prevCount) => (prevCount + 1) % images.length);
      }, 1000);
      // automatically clear timer the next time this effect is fired or
      // the component is unmounted
      return () => clearInterval(timer);
    } else {
      // otherwise (not moused over), reset the counter
      setCount(0);
    }
    // the dependency on mousedOver means that this effect is fired
    // every time mousedOver changes
  }, [mousedOver]);

  return (
    <div className="App">
      <div className="title">Image Rotate</div>
      <div
        // just set mousedOver here instead of calling update/origCount
        onMouseOver={() => setMousedOver(true)}
        onMouseOut={() => setMousedOver(false)}
      >
        <img src={images[count].source} alt={images.name} />
        <p>count is: {count}</p>
      </div>
    </div>
  );
}

Edit hungry-einstein-d8pe0

As to why your code didn't work, a few things:

  1. You meant to say if (count === 2) ..., not count === 3. Even better would be to use the length of the images array instead of hardcoding it
  2. Moreover, the value of count was stale inside of the closure, i.e. after you updated it using setCount, the old value of count was still captured inside of updateCount. This is actually the reason to use functional state updates, which you did when you said e.g. setCount((prevCount) => prevCount + 1)
  3. You would have needed to loop the count inside the interval, not clear the interval on mouse over. If you think through the logic of it carefully, this should hopefully be obvious
  4. In general in react, using a function local variable like timer is not going to do what you expect. Always use state and effects, and in rarer cases (not this one), some of the other hooks like refs

Your setCount should use a condition to check to see if it should go back to the start:

setCount((prevCount) => prevCount === images.length - 1 ? 0 : prevCount + 1);

This will do setCount(0) if we're on the last image—otherwise, it will do setCount(prevCount + 1).

A faster (and potentially more readable) way of doing this would be:

setCount((prevCount) => (prevCount + 1) % images.length);
  1. The timer reference is reset each render cycle, store it in a React ref so it persists.
  2. The initial count state is closed over in interval callback scope.
  3. There are only 3 images so the last slide will be index 2, not 3. You should compare against the length of the array instead of hard coding it.
  4. You can just compute the image index by taking the modulus of count state by the array length.

Code:

export default function App() {
  const timerRef = useRef();
  const [count, setCount] = useState(0);

  // clear any running intervals when unmounting
  useEffect(() => () => clearInterval(timerRef.current), []);

  const updateCount = () => {
    timerRef.current = setInterval(() => {
      setCount((count) =>  count + 1);
    }, 1000);
  };

  const origCount = () => {
    clearInterval(timerRef.current);
    setCount(0);
  };

  return (
    <div className="App">
      <div className="title">Image Rotate</div>
      <div onMouseOver={updateCount} onMouseOut={origCount}>
        <img
          src={images[count % images.length].source} // <-- computed index to cycle
          alt={images.name}
        />
        <p>count is: {count}</p>
      </div>
    </div>
  );
}

Edit how-do-i-loop-an-image-carousel-with-react-setstate-and-setinterval

I believe that setInterval does not work well with function components. Since callback accesses variables through closure, it's really easy to shoot own foot and either get timer callback referring to stale values or even have multiple intervals running concurrently. Not telling you cannot overcome that, but using setTimeout is much much much easier to use

useEffect(() => {
  if(state === 3) return;
  const timerId = setTimeout(() => setState(old => old + 1), 5000);
  return () => clearTimeout(timerId);
}, [state]);

Maybe in this particular case cleanup(clearTimeout) is not required, but for example if user is able to switch images manually, we'd like to delay next auto-change.

Related