Skip to content
Advertisement

How to check which functional React Component has been viewed in the viewport latest?

My goal is to make it so I know which video the user has seen in the viewport latest. This was working until I turned the videos into functional React components, which I can’t figure out how to check the ref until after the inital render of the React parent. This is currently the top part of the component:

 function App() {
  const ref1 = useRef(null);
  const ref2 = useRef(null);
  const ref3 = useRef(null);

  function useIsInViewport(ref) {
    const [isIntersecting, setIsIntersecting] = useState(false);
  
    const observer = useMemo(
      () =>
        new IntersectionObserver(([entry]) =>
          setIsIntersecting(entry.isIntersecting)
        ),
      []
    );
  
    useEffect(() => {
      observer.observe(ref.current);
  
      return () => {
        observer.disconnect();
      };
    }, [ref, observer]);
  
    return isIntersecting;
  }

  var videoProxy = new Proxy(videoViewports, {
    set: function (target, key, value) {
     // console.log("value " + value)
     // console.log("key " + key)
     console.log(videoViewports);
     if (value) {
        setMostRecentVideo(key);
        //console.log("Most Rec: " + mostRecentVideo);
      }
      target[key] = value;
      return true;
    },
  });

  const [isGlobalMute, setIsGlobalMute] = useState(true);
  const [mostRecentVideo, setMostRecentVideo] = useState("");
   
  videoProxy["Podcast 1"] = useIsInViewport(ref1);
  videoProxy["Podcast 2"] = useIsInViewport(ref2);
  videoProxy["Podcast 3"] = useIsInViewport(ref3);

And each component looks like this:

<VideoContainer
          ref={ref1}
          videoProxy={videoProxy}
          mostRecentVideo={mostRecentVideo}
          setMostRecentVideo={setMostRecentVideo}
          title="Podcast 1"
          isGlobalMute={isGlobalMute}
          setIsGlobalMute={setIsGlobalMute}
          videoSource={video1}
          podcastName={podcastName}
          networkName={networkName}
          episodeName={episodeName}
          episodeDescription={episodeDescription}
          logo={takeLogo}
          muteIcon={muteIcon}
          unmuteIcon={unmuteIcon}
        ></VideoContainer>

I had moved the logic for checking if the component was in the viewport into each component, but then it was impossible to check which component was the LATEST to move into viewport. I tried looking online and I don’t understand how I would forward a ref here, or how to get the useIsInViewport to only start working after the initial render since it can’t be wrapped in a useEffect(() => {}, []) hook. Maybe I’m doing this completely the wrong way with the wrong React Hooks, but I’ve been bashing my head against this for so long…

Advertisement

Answer

First of all: I’m not quite sure, if a Proxy.set is the right way of accomplishing your goal (depends on your overall app architecture). Because setting data does not always mean, the user has really seen the video or is in the viewport.

I’ve created a simple solution that uses two components. First the a VideoList that contains all videos and manages the viewport calculations so you don’t have thousands of event listeners on resize, scroll and so on (or Observers respectively).

The Video component is a forwardRef component, so we get the ref of the rendered HTML video element (or in the case of this example, the encompassing div).

import { forwardRef, useCallback, useEffect, useState, createRef } from "react";

function inViewport(el) {
  if (!el) {
    return false;
  }

  const rect = el.getBoundingClientRect();
  return (
    rect.top >= 0 &&
    rect.left >= 0 &&
    rect.bottom <=
      (window.innerHeight || document.documentElement.clientHeight) &&
    rect.right <= (window.innerWidth || document.documentElement.clientWidth)
  );
}

const Video = forwardRef((props, ref) => {
  return (
    <div ref={ref}>
      <p>{props.source}</p>
      <video {...props} />
    </div>
  );
});

const VideoList = ({ sources }) => {
  const sourcesLength = sources.length;
  const [refs, setRefs] = useState([]);
  useEffect(() => {
    // set refs
    setRefs((r) =>
      Array(sources.length)
        .fill()
        .map((_, i) => refs[i] || createRef())
    );
  }, [sourcesLength]);

  const isInViewport = useCallback(() => {
    // this returns only the first but you can also apply a `filter` instead of the index
    const videoIndex = refs.findIndex((ref) => {
      return inViewport(ref.current);
    });

    if (videoIndex < 0) {
      return;
    }

    console.log(`lastSeen video is ${sources[videoIndex]}`);
  }, [refs, sources]);

  useEffect(() => {
    // add more listeners like resize, or use observer
    document.addEventListener("scroll", isInViewport);
    document.addEventListener("resize", isInViewport);

    return () => {
      document.removeEventListener("scroll", isInViewport);
      document.removeEventListener("resize", isInViewport);
    };
  }, [isInViewport]);

  return (
    <div>
      {sources.map((source, i) => {
        return <Video ref={refs[i]} source={source} key={i} />;
      })}
    </div>
  );
};

export default function App() {
  const sources = ["/url/to/video1.mp4", "/url/to/video1.mp4"];

  return (
    <div className="App">
      <VideoList sources={sources} />
    </div>
  );
}

Working example that should lead you into the right directions: https://codesandbox.io/s/distracted-waterfall-go6g7w?file=/src/App.js:0-1918

Please go over to https://stackoverflow.com/a/54633947/1893976 to see, why I’m using a useState for the ref list.

User contributions licensed under: CC BY-SA
9 People found this is helpful
Advertisement