DEV Community

Anxiny
Anxiny

Posted on • Updated on

Easy Lazy Loading with React & Intersection Observer API

I have updated my implementation of useIntersectionObserver hook, please refer to this post.

In this article, I'll write a React hook and a React component that will help you achieve lazy loading in ReactJS.

What is Intersection Observer API?

Basically, Intersection Observer will monitor elements and check if they're intersect with the viewport of an document or, most of time, the browser viewport.

For more information, please refer to the MDN docs.

Create the React hook

First, let's start with an empty hook like this:

export function useIntersectionObserver(){
}
Enter fullscreen mode Exit fullscreen mode

Then we can add a state that will tell us if the component is intersecting and return that state:

export function useIntersectionObserver(){
  const [isIntersecting, setIsIntersecting] = useState(false);
  return isIntersecting;
}
Enter fullscreen mode Exit fullscreen mode

Now, we need a ref that can hold the observer:

export function useIntersectionObserver(){
  const [isIntersecting, setIsIntersecting] = useState(false);
  const observer = useRef<null | IntersectionObserver>(null);


  return isIntersecting;
}
Enter fullscreen mode Exit fullscreen mode

Since we need a target element for the observer, let's add a parameter and state to the hook function:

export function useIntersectionObserver(ref: MutableRefObject<Element | null>){
  const [element, setElement] = useState<Element | null>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);
  const observer = useRef<null | IntersectionObserver>(null);

  useEffect(() => {
        setElement(ref.current);
  }, [ref]);

  return isIntersecting;
}
Enter fullscreen mode Exit fullscreen mode

Now, we can create a observer to observe the Element:

export function useIntersectionObserver(ref: MutableRefObject<Element | null>){
  const [element, setElement] = useState<Element | null>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);
  const observer = useRef<null | IntersectionObserver>(null);

  useEffect(() => {
        setElement(ref.current);
  }, [ref]);

  useEffect(() => {
        if (!element) return;
        const ob = observer.current = new IntersectionObserver(([entry]) => {
            const isElementIntersecting = entry.isIntersecting;
            setIsIntersecting(isElementIntersecting);
        })
        ob.observe(element);
  }, [element])
  return isIntersecting;
}
Enter fullscreen mode Exit fullscreen mode

And don't forget to disconnect the observer once the component is unmounted or the target element is changed.

export function useIntersectionObserver(ref: MutableRefObject<Element | null>){
  const [element, setElement] = useState<Element | null>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);
  const observer = useRef<null | IntersectionObserver>(null);
  const cleanOb = () => {
        if (observer.current) {
            observer.current.disconnect()
        }
  }

  useEffect(() => {
        setElement(ref.current);
  }, [ref]);

  useEffect(() => {
        if (!element) return;
        cleanOb();
        const ob = observer.current = new IntersectionObserver(([entry]) => {
            const isElementIntersecting = entry.isIntersecting;
            setIsIntersecting(isElementIntersecting);
        })
        ob.observe(element);
        return () => {
            cleanOb()
        }
  }, [element])
  return isIntersecting;
}
Enter fullscreen mode Exit fullscreen mode

Now, we want to be able to configure the observer, so let's add the options to the hook function as a parameter:

export function useIntersectionObserver(ref: MutableRefObject<Element | null>, options: IntersectionObserverInit = {}){
  const [element, setElement] = useState<Element | null>(null);
  const [isIntersecting, setIsIntersecting] = useState(false);
  const observer = useRef<null | IntersectionObserver>(null);
  const cleanOb = () => {
        if (observer.current) {
            observer.current.disconnect()
        }
  }

  useEffect(() => {
        setElement(ref.current);
  }, [ref]);

  useEffect(() => {
        if (!element) return;
        cleanOb();
        const ob = observer.current = new IntersectionObserver(([entry]) => {
            const isElementIntersecting = entry.isIntersecting;
            setIsIntersecting(isElementIntersecting);
        }, { ...options })
        ob.observe(element);
        return () => {
            cleanOb()
        }
  }, [element, options ])
  return isIntersecting;
}
Enter fullscreen mode Exit fullscreen mode

For more information about the options, please refer to the MDN docs.

Last, since we usually don't want to remove the content we've rendered, let's add a parameter that allow us to choice if we want the observer to be disconnected after the target element is intersected.

export function useIntersectionObserver(ref: MutableRefObject<Element | null>, options: IntersectionObserverInit = {}, forward: boolean = true) {
    const [element, setElement] = useState<Element | null>(null);
    const [isIntersecting, setIsIntersecting] = useState(false);
    const observer = useRef<null | IntersectionObserver>(null);

    const cleanOb = () => {
        if (observer.current) {
            observer.current.disconnect()
        }
    }

    useEffect(() => {
        setElement(ref.current);
    }, [ref]);

    useEffect(() => {
        if (!element) return;
        cleanOb()
        const ob = observer.current = new IntersectionObserver(([entry]) => {
            const isElementIntersecting = entry.isIntersecting;
            if (!forward) {
                setIsIntersecting(isElementIntersecting)
            } else if (forward && !isIntersecting && isElementIntersecting) {
                setIsIntersecting(isElementIntersecting);
                cleanOb()
            };
        }, { ...options })
        ob.observe(element);
        return () => {
            cleanOb()
        }
    }, [element, options ])


    return isIntersecting;
}
Enter fullscreen mode Exit fullscreen mode

Create a Lazy Loading Component

Once we have the hook we need, it's very simple to create a lazy loading componentwith it:


interface LazyLoadProps {
    tag?: ComponentType | keyof JSX.IntrinsicElements
    children: ReactNode
    style?: CSSProperties
    className?: string
    root?: Element | Document | null
    threshold?: number | number[]
    rootMargin?: string
    forward?: boolean
}

export function LazyLoad(props: LazyLoadProps) {
    const { tag = 'div', children, style, className } = props;
    const Tag: any = tag;
    const ref = useRef<Element>(null)
    const isIntersecting = useIntersectionObserver(ref, {
        root: props.root ?? null,
        threshold: props.threshold ?? 0,
        rootMargin: props.rootMargin
    }, props.forward);

    return (
        <Tag
            ref={ref}
            style={style}
            className={className}
            children={isIntersecting ? children : null}
        />
    )
}

Enter fullscreen mode Exit fullscreen mode

And, here we go.

Thank you for reading this article. Please let me know if there is any issue I made.

The hook and the Lazyload component are included in my npm package ax-react-lib.

Top comments (4)

Collapse
 
heroneto profile image
heroneto

Thanks, very helpfull.

Eslint can warning about cleanOb return in useEffect.

As workaround you can return undefined in first line.

refercence:
stackoverflow.com/questions/676589...

Collapse
 
cyrstron profile image
cyrstron • Edited

You probably should use useLayoutEffect to avoid setting element to the state.

Collapse
 
hmassareli profile image
hmassareli

Hey, you can't put a ref as a dependency of a useEffect
epicreact.dev/why-you-shouldnt-put...

Collapse
 
anxiny profile image
Anxiny

You're right. It was just mean to get rid of lint warning about deps at the time. Thanks for pointing out.