How to Boost React App Performance with the Intersection Observer API (No Libraries)
Create a Smooth Lazy-Loading Experience from Scratch with Native TypeScript and React Hooks (No External Libraries)

Introduction: Solving Performance Issues in React with Native APIs
Loading a gallery of large, high-resolution images can cripple your React application's performance, leading to slow initial load times, a poor Largest Contentful Paint (LCP) score, and a frustrating user experience. The solution isn't to reduce image quality, but to be smarter about when you load them.
In our previous article , we explored the fundamentals of the Intersection Observer API in vanilla JavaScript, learning how it provides a performant, asynchronous way to detect when elements enter the viewport—without the jank of traditional scroll listeners.
Now, we're going to apply that powerful native browser API directly within a React and Next.js application. We'll build a real-world example: a heavy image gallery that uses the Intersection Observer to implement lazy loading and infinite scroll from the ground up, using only React's core hooks (useRef, useEffect, useState) and no third-party libraries.
By the end of this guide, you'll know how to integrate the Intersection Observer API into your React components to dramatically improve performance, reduce bandwidth usage, and create a seamless, professional user experience. This is the essential technique for building fast, scalable React applications that handle large amounts of content.
👉 This article is part 2 of Intersection Observer series. If you haven’t yet, start with [JavaScript Intersection Observer API: Master Scroll-Triggered Animations...] before continuing
How the Intersection Observer API Works in React: Core Concepts
To effectively use the Intersection Observer API in React, it's crucial to understand its core mechanics, as detailed in our previous guide . The API provides an asynchronous way to monitor when a target element intersects with the browser's viewport (or a specified root element), firing a callback function when the visibility changes.
The API is created with the new IntersectionObserver(callback, options) constructor. The callback function is executed whenever the intersection state of a target element changes. It receives an array of IntersectionObserverEntry objects, each containing vital properties:
isIntersecting: A boolean that istruewhen the element is visible in the viewport.intersectionRatio: A number between0and1indicating the percentage of the element that is visible.
The options object allows you to fine-tune the observation:
threshold: Defines at what percentage of visibility the callback should fire. A value of0triggers on the first visible pixel,1requires the entire element to be visible, and an array like[0, 0.25, 0.5, 0.75, 1]triggers at multiple points.rootMargin: Applies a margin (like CSS) around the viewport. A positive value like'50px'triggers the callback before the element enters the viewport, which is perfect for pre-loading content. A negative value like'-50px'requires the element to be deeper inside the viewport to trigger.
The key to using this in React is the second parameter of the callback: a reference to the IntersectionObserver instance itself. This is essential for cleanup and is the foundation of our custom hook. As the Medium article explains, this allows us to call unobserve() on a specific element or disconnect() on the entire observer, which is critical for preventing memory leaks when components unmount.
Real-World Example: Building a Performant Image Gallery in React
Imagine a portfolio site or a stock photo app. A page with 50 large images (each 1-2MB) could easily exceed 100MB of data. This leads to:
Poor LCP (Largest Contentful Paint): The main content takes forever to appear.
High Bandwidth Usage: Wastes data for mobile users.
Janky Scrolling: The browser struggles to render so many heavy elements.
Our solution is a lazy-loading image with hashed light placeholder. We'll use the Intersection Observer to ensure images are only fetched and rendered when they're about to enter the viewport.
Creating a Reusable useInViewClass Hook in React and TypeScript
To integrate the Intersection Observer API seamlessly into our React components, we can encapsulate its logic into a custom hook. This promotes reusability, adheres to the DRY (Don't Repeat Yourself) principle, and keeps our component code clean and declarative.
The useInViewClass hook provided in your CodeSandbox is a perfect example of this. Let's break it down:
import { useEffect, useRef } from "react";
export function useInViewClass(className = "show", threshold = 0.5) {
const ref = useRef<HTMLDivElement | null>(null);
useEffect(() => {
// Guard clause: Exit if the ref is not attached to a DOM element
if (!ref.current) return;
// Create a new Intersection Observer
const observer = new IntersectionObserver(
([entry]) => {
// Use destructuring to get the first (and only) entry
// Toggle the specified class based on visibility
entry.target.classList.toggle(className, entry.isIntersecting);
},
{ threshold } // Configuration object
);
// Start observing the DOM element referenced by `ref`
observer.observe(ref.current);
// Cleanup function: Disconnect the observer when the component unmounts
return () => observer.disconnect();
}, [className, threshold]); // Re-run the effect if these dependencies change
// Return the ref so it can be attached to a JSX element
return ref;
}
How It Works:
useRef: Creates a mutable ref object (ref) that will hold a reference to the actual DOM element we want to observe.useEffect: This hook runs after the component renders. It sets up the imperative logic for creating and managing theIntersectionObserver.Observer Creation: Inside the effect, a new
IntersectionObserveris instantiated. Its callback uses array destructuring ([entry]) to get the firstIntersectionObserverEntryfrom theentriesarray.Class Toggling: The callback uses
classList.toggle(className, entry.isIntersecting)to add the class (e.g.,show) when the element is visible and remove it when it's not. This directly links the element's visibility to its visual state.Configuration: The
thresholdoption is passed in, allowing the hook to be configured for different use cases (e.g., trigger at 50% visibility).Cleanup: The
return () => observer.disconnect();line is critical. It ensures the observer stops all observation when the component is unmounted, preventing memory leaks—a best practice emphasized in the Medium article .
This hook perfectly demonstrates how to bridge the imperative nature of the DOM API with React's declarative paradigm, providing a simple, reusable tool for scroll-triggered effects. In the next section, we'll use this hook to build our heavy image gallery.
Let's apply the useInViewClass hook to a real-world performance challenge: a gallery of large, high-resolution images. Loading all these images at once can cause a massive initial payload, slow down your app, and waste bandwidth for users who may never scroll to see them all.
The Recipe Card Component
Here is a LazyRecipe component that uses our useInViewClass hook to create a smooth, professional loading experience:
import { useState } from "react";
import { BlurhashCanvas } from "react-blurhash";
import { useInViewClass } from "./useViewClass";
export default function LazyRecipe({ recipe }) {
const [imageLoading, setImageLoading] = useState(true);
const ref = useInViewClass(); // Observe this card's visibility
const {
Image_4_3_BlurHash,
imageUrl,
Short_Title,
imageWidth,
imageHeight
} = recipe;
return (
<div className="relative h-48 w-full overflow-hidden" ref={ref}>
{/* 1. BlurHash Placeholder */}
{imageLoading && (
<BlurhashCanvas
hash={Image_4_3_BlurHash}
width={imageWidth}
height={imageHeight}
punch={1} // Increases contrast
className="absolute inset-0 h-full w-full object-cover"
style={{
transition: "opacity 1.2s ease-out",
willChange: "transform, opacity",
}}
/>
)}
{/* 2. The Actual Image */}
{imageUrl && (
<img
src={imageUrl}
alt={Short_Title}
onLoad={() => setImageLoading(false)} // Hide placeholder when image loads
className={`absolute inset-0 h-full w-full object-cover transition-all duration-700 ease-out ${
imageLoading
? "scale-105 opacity-60 blur-md" // Slightly zoomed and blurred while loading
: "scale-100 opacity-100 blur-0" // Crisp and full size when loaded
}`}
loading="lazy"
decoding="async"
fetchPriority="low"
sizes="(max-width: 768px) 100vw, (max-width: 1024px) 50vw, 33vw"
style={{
willChange: "transform, opacity, filter",
}}
/>
)}
</div>
);
}
How It Works:
useInViewClassHook: Thereffrom our custom hook is attached to the card's container. When the card enters the viewport, theshowclass is added, triggering any CSS animations (e.g., a fade-in).BlurHash Placeholder: While the high-resolution image is loading, a
BlurhashCanvasdisplays a compact, low-bandwidth representation of the image. This provides immediate visual feedback and prevents layout shifts.Image Loading State: The
useStatehook manages theimageLoadingstate. TheonLoadevent of the<img>tag triggerssetImageLoading(false), which removes the BlurHash and reveals the final image.Smooth Transitions: CSS classes and
will-changeare used to create a beautiful transition from the blurred placeholder to the sharp final image, enhancing the perceived performance.
This component is a prime example of combining the Intersection Observer API with modern web techniques to build a fast, user-friendly, and visually appealing application.
The Recipes List: Our Data Source
RECIPES is powered by a static array of recipe data, containing objects, each representing a single recipe with the following properties:
Short_Title: The name of the dish.imageWidth&imageHeight: The dimensions of the image.Image_4_3_BlurHash: A compact string representation of the image's placeholder (BlurHash).imageUrl: The URL to the high-resolution image.
This structure allows the LazyRecipe component to know exactly what placeholder to show and what image to load. The consistent use of BlurHash strings ensures that every card has a fast-loading, visually representative preview, creating a cohesive and high-performance user experience across the entire recipes' gallery.
const RECIPES = [
{
Short_Title: "Pasta Delight",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://images.unsplash.com/photo-1600891964599-f61ba0e24092?w=500&auto=format&fit=crop&q=60",
},
{
Short_Title: "Avocado Toast",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://images.unsplash.com/photo-1551183053-bf91a1d81141?w=500&auto=format&fit=crop&q=60",
},
{
Short_Title: "Steak Perfection",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://images.unsplash.com/photo-1543353071-873f17a7a088?w=500&auto=format&fit=crop&q=60",
},
{
Short_Title: "Pasta Delight",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://images.unsplash.com/photo-1511690656952-34342bb7c2f2?q=80&w=764&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
Short_Title: "Fresh Salad",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LKO2?U%2Tw=^]-;C,ogD9ZNH$j[}",
imageUrl:
"https://images.unsplash.com/photo-1555939594-58d7cb561ad1?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
Short_Title: "Fruit Bowl",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LGF5]+Yk^6#M@-5c,1J5@[or[Q6.",
imageUrl:
"https://images.unsplash.com/photo-1565299624946-b28f40a0ae38?q=80&w=781&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
Short_Title: "Grilled Chicken",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://images.unsplash.com/photo-1484723091739-30a097e8f929?q=80&w=749&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
Short_Title: "Burger Stack",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://plus.unsplash.com/premium_photo-1663858367001-89e5c92d1e0e?q=80&w=715&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
Short_Title: "Berry Smoothie",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://images.unsplash.com/photo-1504754524776-8f4f37790ca0?q=80&w=1170&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
Short_Title: "Avocado Toast",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://images.unsplash.com/photo-1432139555190-58524dae6a55?q=80&w=1176&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
{
Short_Title: "Steak Perfection",
imageWidth: 500,
imageHeight: 300,
Image_4_3_BlurHash: "LEHV6nWB2yk8pyo0adR*.7kCMdnj",
imageUrl:
"https://images.unsplash.com/photo-1540189549336-e6e99c3679fe?q=80&w=687&auto=format&fit=crop&ixlib=rb-4.1.0&ixid=M3wxMjA3fDB8MHxwaG90by1wYWdlfHx8fGVufDB8fHx8fA%3D%3D",
},
];
export default RECIPES;
The Entry Point: Bringing It All Together
The final piece of our application is the App component, which serves as the entry point and orchestrates all the parts we've built. It imports the RECIPES data and the LazyRecipe component, then renders a responsive grid of recipe cards.
import LazyRecipe from "./lazy-recipe";
import "./styles.css";
import RECIPES from "./RECIPES";
export default function App() {
return (
<div className="App p-4 space-y-6">
<h1 className="text-2xl font-bold">Lazy Image</h1>
<h2 className="text-lg text-gray-600">Lazy image loading</h2>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{RECIPES.map((recipe, index) => (
<LazyRecipe key={index} recipe={recipe} />
))}
</div>
</div>
);
}
Live Demo & Source Code
Live demo: https://s2lwx4.csb.app/
Source code: https://github.com/adelpro/react-lazy-images-with-blurhash
Conclusion: Mastering Performance with Native APIs
The Intersection Observer API is a powerful tool for building performant web applications. As shown in the First guide , it allows you to detect when elements enter the viewport—enabling lazy loading, infinite scroll, and scroll animations—without causing jank or layout thrashing.
By integrating this native API into React with a custom hook, we can create smooth, efficient experiences like our lazy-loading recipe gallery. The key is combining the Observer's efficiency with techniques like BlurHash to provide instant visual feedback.
Remember to prevent memory leaks by using unobserve() or disconnect() when elements are no longer needed. This simple practice ensures your app stays fast and responsive.
This pattern—using native APIs within React—is a powerful way to solve complex frontend challenges. In the next article, we'll explore more ways to optimize performance for heavy DOM loads and other resources.
What's Next: Implementing Infinite Scroll
In this article, we focused on lazy loading individual images as they come into view. In the next part of this series, we'll build upon this foundation to implement infinite scroll. We'll learn how to use a "sentinel" element at the end of our list, observe it with the Intersection Observer, and automatically load the next batch of recipes when it enters the viewport, creating a seamless, endless browsing experience.
If you’d like a ready-to-use version with these optimizations already applied, you can find it here → Next.js Starter Me Portfolio on Gumroad.
Cover image credit: StockSnap



