Search code examples
reactjsuse-effectintersection-observer

React life cycles and Intersection observer


I am building a image slider in React, based on CSS vertical snapping. There are 2 ways to interact with it, either throught scroll vertically or click the navigation buttons. I am using the Intersection Observer API in a React useEffect() to detect the active item. However, I can't seem to get it right without any useEffect lint errors. Whenever I include the functions in the dependecy array as suggested by the lint, the active item isn't set when scrolling.

Am I using a React anti pattern or am I just missing something?

Live demo

Code:

const Slider = ({images}) => { 
  const [currentSlide, SetCurrentSlide] = React.useState(0);
  
  const setSlide = (id) => {
      SetCurrentSlide(id);
  };
  
  const moveToSlide = (id) => {
    if(id > -1 && id < images.length) {
      SetCurrentSlide(id);
    }
  } 

  return (
    <StyledSlider id="slider">
      <SliderWrapper items={images} setSlide={setSlide} currentSlide={currentSlide} />
      <SliderNav currentSlide={currentSlide} moveToSlide={moveToSlide} maxItems={images.length}/>
    </StyledSlider>
    )
}
    
const SliderWrapper = ({items, setSlide, currentSlide}) => {
  const containerRef = React.useRef(null);
  const { ref, inView, entry } = useInView({
    /* Optional options */
    threshold: 0,
  });
  
  const handleSetSlide = (id) => {
    setSlide(id);
  }; 
  
  const handleIntersection = (entries) => {
    const [entry] = entries;
    const activeSlide = Number(entry.target.dataset.slide);
    if (!entry.isIntersecting || activeSlide === "NaN") return;

    handleSetSlide(activeSlide);
  };
  
  React.useEffect(() => {
    const observer = new IntersectionObserver(
      handleIntersection,
      {
        root: containerRef.current,
        threshold: 0.45
      }
    );
    Array.from(containerRef.current.children).forEach((item) => {
        observer.observe(item);
    });
    return function() {
       observer.disconnect();
    }
  }, [items]);
  
  return (
        <StyledSliderWrapper ref={containerRef} >
            {items.map((item, index) => {
                return <SliderItem key={index} index={index} image={item} isActive={currentSlide === index} />
                })}
        </StyledSliderWrapper>
  )
};

const SliderItem = ({index, image, isActive}) => {
    const imageContent = getImage(image.url);
    const imageRef = React.useRef()

    React.useEffect(() => {
      if(!isActive) return;

      imageRef.current.scrollIntoView({behavior: "smooth", block: "center", inline: "center"});
    },[isActive]);

    return (
        <StyledSliderItem data-slide={index} ref={imageRef}>
            <GatsbyImage image={imageContent} alt={image.description} />
        </StyledSliderItem>
    )
}

Solution

  • So you've missing dependencies in the useEffect of SliderWrapper. You can simplify the code a bit as well.

    SliderWrapper

    Since nothing else calls handleIntersection callback other than the Observer you can safely move it into the useEffect callback body. This makes the only dependency the setSlide callback that's passed as a prop from the parent component.

    const SliderWrapper = ({ items, setSlide, currentSlide }) => {
      const containerRef = React.useRef(null);
    
      React.useEffect(() => {
        const handleIntersection = (entries) => {
          const [entry] = entries;
          const activeSlide = Number(entry.target.dataset.slide);
          if (!entry.isIntersecting || activeSlide === "NaN") return;
    
          setSlide(activeSlide);
        };
    
        const observer = new IntersectionObserver(handleIntersection, {
          root: containerRef.current,
          threshold: 0.45
        });
        Array.from(containerRef.current.children).forEach((item) => {
          observer.observe(item);
        });
        return function () {
          observer.disconnect();
        };
      }, [setSlide]);
    
      return (
        <StyledSliderWrapper ref={containerRef}>
          {items.map((item, index) => (
            <SliderItem
              key={index}
              index={index}
              image={item}
              isActive={currentSlide === index}
            />
          ))}
        </StyledSliderWrapper>
      );
    };
    

    Slider

    The other issue what that you were memoizing the setSlide prop in the child instead of the parent where it's being passed down. This caused the setSlide prop to be a new reference each render and re-memoized via useCallback in the child. React useState updater functions are stable however, so you can directly pass them to children.

    const Slider = ({ images }) => {
      const [currentSlide, setCurrentSlide] = React.useState(0);
    
      const moveToSlide = (id) => {
        setCurrentSlide(id);
      };
    
      return (
        <StyledSlider id="slider">
          <SliderWrapper
            items={images}
            setSlide={setCurrentSlide} // <-- pass directly to child
            currentSlide={currentSlide}
          />
          <SliderNav
            currentSlide={currentSlide}
            moveToSlide={moveToSlide}
            maxItems={images.length}
          />
        </StyledSlider>
      );
    };
    

    Edit react-life-cycles-and-intersection-observer

    If you wanted to remain with the setSlide handler in the parent, here is where you'd memoize the callback so the parent is providing a stable reference. Note that this is only useful if memoizing non-useState functions.

    const setSlide = React.useCallback(
      (id) => {
        setCurrentSlide(id);
      },
      [setCurrentSlide]
    );