DEV Community

Cover image for Custom React Hooks for API Calls + Lazy Loading View-Based Data
Sachin Maurya
Sachin Maurya

Posted on

Custom React Hooks for API Calls + Lazy Loading View-Based Data

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]; }; 
Enter fullscreen mode Exit fullscreen mode

⚙️ 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> ); }; 
Enter fullscreen mode Exit fullscreen mode

♻️ 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; }; 
Enter fullscreen mode Exit fullscreen mode

Now your component looks much cleaner:

const ref = useLazyFetchOnView(() => { fetch('/api/testimonials') .then(res => res.json()) .then(setTestimonials); }); 
Enter fullscreen mode Exit fullscreen mode

🔄 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()); 
Enter fullscreen mode Exit fullscreen mode

Or dispatch a Zustand action:

const fetchData = useStore((state) => state.fetchData); const ref = useLazyFetchOnView(fetchData); 
Enter fullscreen mode Exit fullscreen mode

📈 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.

Top comments (0)