Native Lazy Loading with Intersection Observer in React

4 min read
0 views
#Web API#React#Performance
Native Lazy Loading with Intersection Observer in React

Cover photo by Sergey Semenov on Unsplash

Infinite scrolling or "lazy loading" lists is a common pattern in modern web applications. While there are many libraries available to handle this (like react-intersection-observer), implementing it natively using the Intersection Observer API is surprisingly straightforward, lightweight, and performant.

In this tutorial, we'll build a simple infinite scroll list in React without installing any extra packages.

#What is Intersection Observer?

The Intersection Observer API provides a way to asynchronously observe changes in the intersection of a target element with an ancestor element or with a top-level document's viewport.

Simply put, it triggers a callback function whenever an element you're watching enters or exits the screen.

#Browser Support

Good news! As of 2026, the Intersection Observer API has excellent browser support. It is available in all modern browsers:

  • Chrome: 51+
  • Edge: 15+
  • Firefox: 55+
  • Safari: 12.1+ (iOS 12.2+)
  • Opera: 38+

It is not supported in Internet Explorer. If you need to support IE11, you will need to use a polyfill.

#The Implementation

We will create a list that fetches more items when the user scrolls to the bottom. We'll place a transparent "sentinel" element at the end of the list. When this sentinel becomes visible, we trigger our load function.

#1. Setting up the State

First, let's set up our component with some state to hold our items and a loading status.

import { useState, useRef, useEffect, useCallback } from "react"
 
export default function InfiniteList() {
  const [items, setItems] = useState<string[]>([])
  const [loading, setLoading] = useState(false)
  const [page, setPage] = useState(1)
 
  // Ref for the element we want to observe
  const observerTarget = useRef<HTMLDivElement>(null)
 
  // ... implementation continues
}

#2. Fetching Data

Let's create a function to simulate fetching data. In a real app, this would be an API call.

const fetchData = useCallback(async () => {
  setLoading(true)
 
  // Simulate network delay
  await new Promise((resolve) => setTimeout(resolve, 1000))
 
  // Generate dummy items
  const newItems = Array.from({ length: 10 }, (_, i) => `Item ${items.length + i + 1}`)
 
  setItems((prev) => [...prev, ...newItems])
  setPage((prev) => prev + 1)
  setLoading(false)
}, [items.length])

#3. Implementing the Observer

Now for the magic. We use useEffect to attach the observer to our target element.

useEffect(() => {
  const observer = new IntersectionObserver(
    (entries) => {
      // entries[0] is our target element
      if (entries[0].isIntersecting && !loading) {
        fetchData()
      }
    },
    { threshold: 1.0 } // Trigger when 100% of the target is visible
  )
 
  const currentTarget = observerTarget.current
 
  if (currentTarget) {
    observer.observe(currentTarget)
  }
 
  // Cleanup on unmount
  return () => {
    if (currentTarget) {
      observer.unobserve(currentTarget)
    }
  }
}, [fetchData, loading])

#4. Putting It All Together

Finally, render the list and the sentinel element at the bottom.

return (
  <div className="max-w-md mx-auto p-4 space-y-4">
    <h1 className="text-2xl font-bold mb-4">Native Infinite Scroll</h1>
 
    <div className="space-y-2">
      {items.map((item, index) => (
        <div
          key={index}
          className="p-4 bg-white dark:bg-neutral-800 rounded shadow border border-neutral-200 dark:border-neutral-700"
        >
          {item}
        </div>
      ))}
    </div>
 
    {/* The Sentinel Element */}
    <div ref={observerTarget} className="h-4 w-full flex justify-center p-4">
      {loading && (
        <span className="animate-spin h-5 w-5 border-2 border-blue-500 rounded-full border-t-transparent"></span>
      )}
    </div>
 
    {!loading && items.length > 50 && (
      <p className="text-center text-gray-500">You've reached the end!</p>
    )}
  </div>
)

#Why use Native implementation?

  1. Bundle Size: You save the overhead of importing an external library.
  2. Control: You have full control over the rootMargin and threshold options.
  3. Performance: The API is native to the browser and highly optimized, running off the main thread where possible.

#Conclusion

Using IntersectionObserver directly is a great way to handle lazy loading image, infinite scrolling lists, or triggering animations on scroll. It keeps your codebase lean and relies on standardized web platform features.

Share this article