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)