15

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>
  );
}
vsync
  • 118,978
  • 58
  • 307
  • 400
pawel_s
  • 295
  • 1
  • 2
  • 13
  • You might be able to refer to this, it's a hook that utilise IntersectionObserver as well: https://github.com/jackyef/use-intersect/blob/master/src/index.js – Jackyef Oct 11 '19 at 14:35

5 Answers5

26

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

  • 1
    Good example, but I get a TS warning: `Cannot assign to 'current' because it is a read-only property`. If I add `|null` to the `useRef` call (i.e. `useRef(null);`) it goes away, not sure if that's the right solution. – Paul Nov 03 '21 at 22:24
  • 1
    I changed it. Thank you for your feedback. In the @types/react is the usage note: ` * ... if you need the result of useRef to be directly mutable, include '| null' in the type * of the generic argument.` – Filip Szczepanski Nov 07 '21 at 15:48
  • 1
    I notice this fires every time the element goes off the page and reappears because isOnScreen gets reset. Why does isOnScreen get reset? Is it because the ref detaches and then reattaches with a fresh state? If I wanted it to only fire once for MyComponent, what would be the best way to do it? – zilla Oct 19 '22 at 01:07
13

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>
  );
}
pawel_s
  • 295
  • 1
  • 2
  • 13
12

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>
}
GuCier
  • 6,919
  • 1
  • 29
  • 36
  • 2
    I believe this implementation creates a new IntersectionObserver on every render. See the answer https://stackoverflow.com/a/67826055/1727807 for a way to avoid this. – Hart Simha Nov 03 '21 at 12:59
  • Using useMemo in this hook is fairly simple: const observer = useMemo(() => new IntersectionObserver(…), [ref, rootMargin]). It is a topic on it’s own and the threshold from when to use useMemo to prevent extra processing really depends on your implementation – GuCier Nov 05 '21 at 09:07
  • @Creaforge what is rootMargin here? – Tushar Shahi Mar 07 '22 at 12:05
  • 1
    @TusharShahi An example if you want to add it: `new IntersectionObserver( ([entry]) => setIntersecting(entry.isIntersecting), { rootMargin: '50vh' })` – GuCier Mar 08 '22 at 13:24
3

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.

Felipe Alves
  • 41
  • 1
  • 3
1

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>
  );
}
Joyce Lee
  • 378
  • 3
  • 9