# How to Boost React App Performance with the Intersection Observer API (No 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](https://peerlist.io/adelpro/articles/javascript-intersection-observer-api-master-animations--opti) , 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...\]](https://peerlist.io/adelpro/articles/javascript-intersection-observer-api-master-animations--opti) 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](https://peerlist.io/adelpro/articles/javascript-intersection-observer-api-master-animations--opti) . 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 is `true` when the element is visible in the viewport.
    
* `intersectionRatio`: A number between `0` and `1` indicating 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 of `0` triggers on the first visible pixel, `1` requires 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](https://medium.com/coding-beauty/intersection-observer-in-javascript-everything-you-need-to-know-cded4e80a377) 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:

```typescript
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:**

1. `useRef`: Creates a mutable ref object (`ref`) that will hold a reference to the actual DOM element we want to observe.
    
2. `useEffect`: This hook runs after the component renders. It sets up the imperative logic for creating and managing the `IntersectionObserver`.
    
3. **Observer Creation**: Inside the effect, a new `IntersectionObserver` is instantiated. Its callback uses array destructuring (`[entry]`) to get the first `IntersectionObserverEntry` from the `entries` array.
    
4. **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.
    
5. **Configuration**: The `threshold` option is passed in, allowing the hook to be configured for different use cases (e.g., trigger at 50% visibility).
    
6. **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](https://medium.com/coding-beauty/intersection-observer-in-javascript-everything-you-need-to-know-cded4e80a377) .
    

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:

```csharp
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:**

1. `useInViewClass` **Hook:** The `ref` from our custom hook is attached to the card's container. When the card enters the viewport, the `show` class is added, triggering any CSS animations (e.g., a fade-in).
    
2. **BlurHash Placeholder:** While the high-resolution image is loading, a `BlurhashCanvas` displays a compact, low-bandwidth representation of the image. This provides immediate visual feedback and prevents layout shifts.
    
3. **Image Loading State:** The `useState` hook manages the `imageLoading` state. The `onLoad` event of the `<img>` tag triggers `setImageLoading(false)`, which removes the BlurHash and reveals the final image.
    
4. **Smooth Transitions:** CSS classes and `will-change` are 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.

```typescript
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.

```javascript
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/](https://s2lwx4.csb.app/)

Source code: [https://github.com/adelpro/react-lazy-images-with-blurhash](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](https://peerlist.io/adelpro/articles/javascript-intersection-observer-api-master-animations--opti) , 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](https://gumroad.adelpro.us.kg/l/aehleb).

---

Cover image credit: [StockSnap](https://pixabay.com/users/stocksnap-894430/?utm_source=link-attribution&utm_medium=referral&utm_campaign=image&utm_content=2558279)
