IntersectionObserver with React & Hooks
Asked Answered
S

6

22

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>
  );
}
Seeto answered 11/10, 2019 at 12:58 Comment(1)
You might be able to refer to this, it's a hook that utilise IntersectionObserver as well: github.com/jackyef/use-intersect/blob/master/src/index.jsClifton
S
35

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

Szechwan answered 3/6, 2021 at 17:27 Comment(4)
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<IntersectionObserver|null>(null);) it goes away, not sure if that's the right solution.Rackley
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.`Szechwan
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?Datum
I used this custom hook. While playing with it, I put a console.log to the effect that creates the IntersectionObserver, and it ran every time the useOnScreen was called. Meaning new IntersectionObserver objects are created for each usage.Equestrian
S
15

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>
  );
}
Seeto answered 14/10, 2019 at 10:4 Comment(0)
V
13

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>
}
Varnish answered 18/11, 2020 at 11:59 Comment(4)
I believe this implementation creates a new IntersectionObserver on every render. See the answer https://mcmap.net/q/196103/-intersectionobserver-with-react-amp-hooks for a way to avoid this.Plumlee
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 implementationVarnish
@Creaforge what is rootMargin here?Monarchism
@TusharShahi An example if you want to add it: new IntersectionObserver( ([entry]) => setIntersecting(entry.isIntersecting), { rootMargin: '50vh' })Varnish
C
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.

Cabman answered 20/4, 2022 at 4:18 Comment(1)
What an absolute legend!Jabberwocky
D
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>
  );
}
Devastation answered 23/7, 2021 at 6:48 Comment(0)
E
1

The above answers helped me go a long way. However, none of the solutions seems to avoid creating multiple IntersectionObserver objects. So I present the following solution which seems to me to avoid said shortcoming. Any feedback is highly appreciated if you see any shortcomings, pitfalls, or smells.

Hook

@hooks/useIntersectionObserver.tsx

import {
  PropsWithChildren,
  RefObject,
  createContext,
  useContext,
  useEffect,
  useRef,
  useState,
} from "react";

export const INTERSECTION_OBSERVER_ROOT_ID =
  "your-app-name-plus-something-to-create-a-unique-id";

// TO BE USED ONLY ONCE (wrapping the INTERSECTION_OBSERVER_ROOT element)
// OTHERWISE IT WILL CREATE MULTIPLE OBSERVERS
const IntersectionObserverContext = createContext<{
  ref: RefObject<IntersectionObserver | null>;
  intersectingEntries: IntersectionObserverEntry[];
}>({
  ref: { current: null },
  intersectingEntries: [],
});

export const IntersectionObserverProvider = ({
  children,
}: PropsWithChildren) => {
  const interserctionObserverRef = useRef<IntersectionObserver | null>(null);
  const [intersectingEntries, setIntersectingEntries] = useState<
    IntersectionObserverEntry[]
  >([]);

  useEffect(() => {
    const intersectionRoot = document.getElementById(
      INTERSECTION_OBSERVER_ROOT_ID,
    );

    const interRootBoundingClientRect =
      intersectionRoot?.getBoundingClientRect();

    // Calculating a rootMargin as it was required for my use case
    const interRootHeight = interRootBoundingClientRect?.height ?? 0;
    const rootMarginBottom = -interRootHeight;
    const rootMargin = `0px 0px ${rootMarginBottom}px 0px`;

    interserctionObserverRef.current = new IntersectionObserver(
      (entries) =>
        setIntersectingEntries(entries.filter((entry) => entry.isIntersecting)),
      {
        root: intersectionRoot,
        rootMargin: rootMargin,
      },
    );
  }, []);

  return (
    <IntersectionObserverContext.Provider
      value={{
        ref: interserctionObserverRef,
        intersectingEntries: intersectingEntries,
      }}
    >
      {children}
    </IntersectionObserverContext.Provider>
  );
};
// TO BE USED ONLY ONCE (wrapping the INTERSECTION_OBSERVER_ROOT element)

export default function useIntersectionObserver(
  ref: RefObject<HTMLElement>,
) {
  const { ref: interserctionObserverRef, intersectingEntries } = useContext(
    IntersectionObserverContext,
  );
  const [isIntersecting, setIsIntersecting] = useState(false);

  useEffect(() => {
    const currIObserverRef = interserctionObserverRef.current;
    if (ref.current && currIObserverRef) {
      currIObserverRef.observe(ref.current as HTMLElement);
    }

    return () => {
      currIObserverRef?.disconnect();
    };
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [ref]);

  useEffect(() => {
    if (intersectingEntries.length > 0) {
      const entryTarget = intersectingEntries.find(
        (entry) => entry.target === ref.current,
      );
      if (entryTarget) {
        setIsIntersecting(true);
        return;
      }
      setIsIntersecting(false);
    }
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, [intersectingEntries]);

  return isIntersecting;
}

Usage

Component.tsx

import { PropsWithChildren } from "react";

import {
  INTERSECTION_OBSERVER_ROOT_ID,
  IntersectionObserverProvider,
} from "@/hooks/useIntersectionObserver";

type ComponentProps = {};

const Component = ({children}: PropsWithChildren<ComponentProps>) => {
  return (
    <div>
      <IntersectionObserverProvider>
        <div id={INTERSECTION_OBSERVER_ROOT_ID}>
          {/* You could have as many ChildComponents that use the hook as you want*/}
          {children}
        </div>
      </IntersectionObserverProvider>
    </div>
  );
};

export default Component;

ChildComponent.tsx

import { PropsWithChildren, useRef, useState } from "react";

import useAPIDocIntersectionObserver from "@/hooks/useIntersectionObserver";

type ChildComponentProps = {
  id: string;
  title: string;
};

const ChildComponent = ({
  id,
  title,
  children,
}: PropsWithChildren<ChildComponentProps>) => {

  const ref = useRef<HTMLDivElement>(null);
  const isIntersecting = useAPIDocIntersectionObserver(ref);

  if (isIntersecting) {
    console.log(id, "Intersecting");
  }

  return (
    <div ref={ref} id={id}>
      <h1 href={`#${id}`} className={styles.link}>
        {title}
      </h1>
      <div>
        {children}
      </div>
    </div>
  );
};

export default ChildComponent;

If you find any syntactical errors, please do comment below. Cheers!

Equestrian answered 21/2 at 6:43 Comment(0)

© 2022 - 2024 — McMap. All rights reserved.