'IntersectionObserver with React & Hooks

I'm trying to track element visibility with React/Hooks and the Intersection Observer API. However, I can't figure out how to set up observation with "useEffect". Does anybody have any idea how could I do that? Mine solution does not work...

function MainProvider({ children }) {
  const [targetToObserve, setTargetToObserve] = useState([]);

  window.addEventListener("load", () => {
    const findTarget = document.querySelector("#thirdItem");
    setTargetToObserve([findTarget]);
  });

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.intersectionRatio === 0.1) {
          console.log("It works!");
        }
      },
      {
        root: null,
        rootMargin: "0px",
        threshold: 0.1
      }
    );
    if (targetToObserve.current) {
      observer.observe(targetToObserve.current);
    }
  }, []);

  return (
    <main>
     <div className="Section-item" id="firstItem"></div>
     <div className="Section-item" id="secondItem"></div>
     <div className="Section-item" id="thirdItem"></div>
    </main>
  );
}


Solution 1:[1]

Need to use React.useRef() instead of addEventListener('load', function() ), since eventListener will run before something will appear on your screen.

import React, { useRef, useEffect } from 'react'

function MainProvider({ children }) {
  const ref = useRef();

  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        console.log(entry);

        if (entry.isIntersecting) {
          //do your actions here
          console.log('It works!')
        }
      },
      {
        root: null,
        rootMargin: "0px",
        threshold: 0.1
      }
    );
    if (ref.current) {
      observer.observe(ref.current);
    }
  }, [ref]);

  return (
    <main>
     <div className="Section-item" id="firstItem"></div>
     <div className="Section-item" ref={ref} id="secondItem"></div>
     <div className="Section-item" id="thirdItem"></div>
    </main>
  );
}

Solution 2:[2]

Here is a reusable hook that is using ref and useEffect cleanup function to prevent memory leakage when mounting / unmounting large amount of components

The hook

function useOnScreen(ref) {

  const [isIntersecting, setIntersecting] = useState(false)

  const observer = new IntersectionObserver(
    ([entry]) => setIntersecting(entry.isIntersecting)
  )

  useEffect(() => {
    observer.observe(ref.current)
    return () => {
      observer.disconnect()
    }
  }, [])

  return isIntersecting
}

Usage in a component

function DumbComponent() {

  const ref = useRef()

  const onScreen = useOnScreen(ref)

  return <div ref={ref}>{onScreen && "I'm on screen!"}</div>
}

Solution 3:[3]

JavaScript

Hook

import { useEffect, useState, useRef } from 'react';

export function useOnScreen(ref) {
  const [isOnScreen, setIsOnScreen] = useState(false);
  const observerRef = useRef(null);

  useEffect(() => {
    observerRef.current = new IntersectionObserver(([entry]) =>
      setIsOnScreen(entry.isIntersecting)
    );
  }, []);

  useEffect(() => {
    observerRef.current.observe(ref.current);

    return () => {
      observerRef.current.disconnect();
    };
  }, [ref]);

  return isOnScreen;
}


Usage:

import { useRef } from 'react';
import useOnScreen from './useOnScreen';

function MyComponent() {
  const elementRef = useRef(null);
  const isOnScreen = useOnScreen(elementRef);

  console.log({isOnScreen});

  return (
    <div>
      <div style={{ paddingBottom: '140vh' }}>scroll to element...</div>
      <div ref={elementRef}>my element</div>
    </div>
  );
}

TypeScript

Hook

import { useEffect, useState, useRef, RefObject } from 'react';

export default function useOnScreen(ref: RefObject<HTMLElement>) {
  const observerRef = useRef<IntersectionObserver | null>(null);
  const [isOnScreen, setIsOnScreen] = useState(false);

  useEffect(() => {
    observerRef.current = new IntersectionObserver(([entry]) =>
      setIsOnScreen(entry.isIntersecting)
    );
  }, []);

  useEffect(() => {
    observerRef.current.observe(ref.current);

    return () => {
      observerRef.current.disconnect();
    };
  }, [ref]);

  return isOnScreen;
}

Usage:

import { useRef } from 'react';
import useOnScreen from './useOnScreen';

function MyComponent() {
  const elementRef = useRef<HTMLDivElement>(null);
  const isOnScreen = useOnScreen(elementRef);

  console.log({isOnScreen});

  return (
    <div>
      <div style={{ paddingBottom: '140vh' }}>scroll to element...</div>
      <div ref={elementRef}>my element</div>
    </div>
  );
}

https://codesandbox.io/s/useonscreen-uodb1?file=/src/useOnScreen.ts

Solution 4:[4]

From your example, it looks like you only need to set up your observers once, on the initial render.

function MainProvider({ children }) {
  useEffect(() => {
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.intersectionRatio === 0.1) {
          console.log("It works!");
        }
      },
      {
        root: null,
        rootMargin: "0px",
        threshold: 0.1
      }
    );

    const findTarget = document.querySelector("#thirdItem");

    if (findTarget) {
      observer.observe(targetToObserve.current);
    }
  }, []);

  return (
    <main>
     <div className="Section-item" id="firstItem"></div>
     <div className="Section-item" id="secondItem"></div>
     <div className="Section-item" id="thirdItem"></div>
    </main>
  );
}

However, if you had other dependencies that would require you to add or remove more of the elements that you're observing, you can put your observer in the useEffect hook, making sure to include dependencies of the things you're trying to observe.

If you put your observer in your dependency array as well (as your linter might suggest) you'll get a helpful error message, telling you that a new observer object will be created on every render, triggering this hook to run on every render. Instead, it suggests that you put your observer in a useMemo hook, which is recommended for expensive calculations.

function MainProvider({ children }) {
  const observer = useMemo(() => return new IntersectionObserver(
    ([entry]) => {
      if (entry.intersectionRatio === 0.1) {
        console.log("It works!");
      }
    },
    {
      root: null,
      rootMargin: "0px",
      threshold: 0.1
    }
  );
 );

  useEffect(() => {
    const findTarget = document.querySelector("#thirdItem");

    if (targetToObserve.current) {
      observer.observe(targetToObserve.current);
    }
  }, [observer]);

  return (
    <main>
     <div className="Section-item" id="firstItem"></div>
     <div className="Section-item" id="secondItem"></div>
     <div className="Section-item" id="thirdItem"></div>
    </main>
  );
}

Solution 5:[5]

Complementing Filip Szczepanski's answer, I found that it works great, except when you need your element to render conditionally, this is bad when you need to make API calls, for example (code based on Filip's examples):

import { useRef } from 'react';
import useOnScreen from './useOnScreen';

const fakeApiFetch = () => {
  return Promise.resolve(
    [
      {
        id: 0,
        name: 'Wash the dishes'
      },
      {
        id: 1,
        name: 'Make the bed'
      }
    ]
  );
}

function MyComponent() {
  const [data, setData] = useState<Array<any>>([]);
  const elementRef = useRef<HTMLDivElement>(null);
  const isOnScreen = useOnScreen(elementRef);

  useEffect(() => {
     (async() => {
         const res = await fakeApiFetch();
         setData(res);
         console.log(res);
     })();
  }, []);

  return (
    data.length > 0? (
      <div>
        <div style={{ paddingBottom: '140vh' }}>scroll to element...</div>
        <div ref={elementRef}>my element</div>
      </div>
    ) : (
      <h3>Fetching data...</h3>
    )
  );
}

This code will not work, IntersectionObserver no longer seems to find the element and does not update itself after data is fed with data from the API.

What can be done:

export default function useOnScreen(
  ref: RefObject<HTMLElement>,
  triggers: Array<any> = [] // Add triggers
) {
  const [isOnScreen, setIsOnScreen] = useState(false);
  const observerRef = useRef<IntersectionObserver>();

  useEffect(() => {
    observerRef.current = new IntersectionObserver(([entry]) =>
      setIsOnScreen(entry.isIntersecting)
    );
  }, []);

  useEffect(() => {
    if (!!observerRef.current && !!ref.current) {
      observerRef.current.observe(ref.current);

      return () => {
        observerRef.current!.disconnect();
      };
    }
  }, [ref, ...triggers]); // Let the triggers fire the effect too on changes

  return isOnScreen;
}

And:

function MyComponent() {
  const [data, setData] = useState<Array<any>>([]);
  const elementRef = useRef<HTMLDivElement>(null);
  const isOnScreen = useOnScreen(elementRef, [data]);
                                             // ^
                                             // | Add this
  ...

Hope it helps someone.

Sources

This article follows the attribution requirements of Stack Overflow and is licensed under CC BY-SA 3.0.

Source: Stack Overflow

Solution Source
Solution 1 pawel_s
Solution 2
Solution 3
Solution 4 Joyce Lee
Solution 5 Felipe Alves