In this post, I’ll walk you through a problem I encountered during a real-world Next.js project — multiple API-bound sections loading all at once, even before the user scrolled to them.
This kind of eager data fetching doesn’t just waste network requests — it hurts performance, increases TTI, and slows down Core Web Vitals.
To fix it, I built a custom React hook that delays API calls until a component enters the viewport. Think of it as useEffect
+ IntersectionObserver
bundled neatly for reuse.
📍 The Problem
In one of my recent projects (a performance-optimized energy website built with Next.js + GraphQL), we had:
- 4–5 API-heavy sections on a single page
- All triggering data fetch on initial load, regardless of visibility
- Resulting in unnecessary bandwidth usage and slow perceived performance
🛠️ The Solution: Build a Reusable Hook
The goal was simple:
Don’t fetch anything until the section is actually visible.
Let’s break it down.
🔁 Step 1 – useInView
Hook
import { useEffect, useRef, useState } from 'react';
export const useInView = () => {
const ref = useRef(null);
const [isInView, setIsInView] = useState(false);
useEffect(() => {
const observer = new IntersectionObserver(([entry]) => {
if (entry.isIntersecting) setIsInView(true);
});
if (ref.current) observer.observe(ref.current);
return () => observer.disconnect();
}, []);
return [ref, isInView];
};
⚙️ Step 2 – Use It in a Component
const ServicesSection = () => {
const [ref, isInView] = useInView();
const [data, setData] = useState(null);
useEffect(() => {
if (isInView && !data) {
fetch('/api/services')
.then((res) => res.json())
.then(setData);
}
}, [isInView]);
return (
<section ref={ref}>
<h2>Our Services</h2>
{data ? data.map(item => <p key={item.id}>{item.title}</p>) : <p>Loading...</p>}
</section>
);
};
♻️ Step 3 – Abstract It into useLazyFetchOnView
export const useLazyFetchOnView = (callback) => {
const [ref, isInView] = useInView();
const [hasFetched, setHasFetched] = useState(false);
useEffect(() => {
if (isInView && !hasFetched) {
callback();
setHasFetched(true);
}
}, [isInView]);
return ref;
};
Now your component looks much cleaner:
const ref = useLazyFetchOnView(() => {
fetch('/api/testimonials')
.then(res => res.json())
.then(setTestimonials);
});
🔄 Integration with React Query / Zustand
You can replace fetch()
with your existing state logic:
const { refetch } = useQuery('teamData', fetchTeam, { enabled: false });
const ref = useLazyFetchOnView(() => refetch());
Or dispatch a Zustand action:
const fetchData = useStore((state) => state.fetchData);
const ref = useLazyFetchOnView(fetchData);
📈 Before vs After
Metric | Before | After |
---|---|---|
API Calls | 6 | 3 |
LCP | 4.3s | 2.1s |
Lighthouse Score | 66 | 93+ |
TTI | 5.2s | 2.8s |
This small pattern helped us reduce noise, clean up component logic, and improve performance across the board.
🧠 Final Thoughts
Lazy loading is not just for images.
If you have view-based components — testimonials, blogs, services, etc. — don’t fetch data until they come into view.
This pattern can be reused across your app, and it’s easy to test and maintain.
Let me know if you’d like a reusable package out of it — happy to open source it if there’s interest.