storedevguide logo

How to Create Infinite Scroll Using Shopify Storefront API and React JS (Step-by-Step Guide)

Deepak KharwareDeepak Kharware
February 18, 2026
10 min read
2,543 views
Shopify infinite scroll guide with React JS

Introduction: Infinite scroll in a Shopify storefront using React js

When we talk about infinite scroll in a Shopify storefront using React, what we’re really trying to achieve is a smooth user experience where products keep loading automatically as the user scrolls down, instead of clicking a pagination button. Think about Instagram or Amazon product listing pages — when you scroll, more content appears without refreshing the page. That’s the experience we want to build.

Now let’s break this down in a natural flow.

When the component mounts for the first time, React runs the initial logic inside useEffect. This is where we usually trigger our first API call. At this point, the page is empty — no products are loaded yet. So our job is to fetch the first batch of products from Shopify’s Storefront API.

To do that, we send a GraphQL query. In that query, we usually pass parameters like:

  • first → how many products we want per request (for example, 8 or 10)

  • after → the cursor value (used for pagination)

  • handle → if we are fetching collection products

For the first load, the after cursor will be null because we are loading from the beginning.

So what happens?

The component mounts → fetchProducts() runs → API request goes to Shopify →
Shopify returns product data along with pageInfo.

That pageInfo is extremely important. It gives us two key things:

  • hasNextPage

  • endCursor

hasNextPage tells us if more products exist.
endCursor gives us the position to fetch the next set.

Once the data returns, we store it inside React state. For example:

  • products → array of product data

  • cursor → store endCursor

  • hasMore → store hasNextPage

  • loading → false

At this moment, the first page of products is visible to the user.

Now here’s where infinite scroll logic starts getting interesting.

At the bottom of your product grid, you place a small empty <div> — this is your “loader div.” It doesn’t need to look fancy. It can just contain a spinner or “Loading…” text. But technically, this div acts as a trigger point.

This loader div is important because we will attach something called an Intersection Observer to it.

Now what is Intersection Observer?

It’s a browser API that watches when an element enters the viewport. In simple words, it detects when something becomes visible on screen.

So when the user scrolls down and that loader div comes into view, the observer gets triggered.

Here’s the lifecycle flow:

User opens page → Products render → Loader div is sitting at bottom → User scrolls → Loader enters viewport → Observer fires callback → fetchProducts() runs again.

Now, very important — we don’t want to create an infinite loop. So we must add conditions.

Before calling fetchProducts() again, we check:

  • hasMore === true

  • loading === false

If hasMore is false, that means no more products exist. So we stop observing.

If loading is true, that means an API call is already running. We prevent duplicate calls.

So let’s imagine the second fetch.

The observer detects intersection.


It calls fetchProducts().
Now this time, we pass:

  • first = 8

  • after = previousCursor

Shopify returns the next set of products. Now instead of replacing our products state, we append new products to the existing array.

This is key.

Instead of:

setProducts(newProducts)

We do:

setProducts(prev => [...prev, ...newProducts])

That way, the old products stay, and new ones get added at the bottom.

Then we update:

  • cursor = newEndCursor

  • hasMore = newHasNextPage

  • loading = false

Now here’s the magic part.

Because new products were added, the page height increases.
The loader div automatically moves down.
It is no longer in the viewport.

So nothing happens until the user scrolls again.

1. User scrolls again →
2. Loader enters viewport →
3. Observer triggers →
4. fetch runs again.

This cycle keeps repeating.

Now eventually, Shopify will return:

hasNextPage = false

At that point:

  • hasMore becomes false.

  • We disconnect the observer.

  • Optionally, we hide the loader div.

  • We may show a message like “No more products.”

That’s it. Infinite scroll is complete.

Ultimate Guide to Remix: Remix JS, Remix Project, RemixIDE, Remix Shopify & Remix Run (2026)

 


Now let’s talk about the React implementation flow in real-world terms.

React infinite scroll implementation guide

Step 1: State Setup

You create states like:

  • products

  • loading

  • cursor

  • hasMore

These are the backbone of the system.

Step 2: Initial Fetch

Inside useEffect(() => {}, []), call fetchProducts().

This loads page one.

Step 3: Setup Observer

Create a useRef() for the loader div.

Then inside another useEffect, create an IntersectionObserver.

Attach observer to loaderRef.current.

Cleanup by disconnecting observer on unmount.

Step 4: Fetch Function Logic

Your fetchProducts() function:

  • Check if already loading

  • Set loading true

  • Call Shopify API

  • Append results

  • Update cursor

  • Update hasMore

  • Set loading false

Simple logic, but powerful.


Now let’s talk about real-world performance considerations.

  1. Avoid Multiple Observers

Make sure you don’t recreate the observer every render unnecessarily. Dependencies should be carefully managed.

  1. Prevent Duplicate Calls

Sometimes observer triggers multiple times quickly. Always guard with loading flag.

  1. Handle Network Errors

If API fails, handle it gracefully. Maybe retry or show error message.

  1. SEO Consideration

Infinite scroll is not great for SEO because search engines prefer paginated links. So sometimes hybrid approach works better (pagination + infinite scroll).

  1. Memory Usage

If there are thousands of products, consider virtualization libraries like react-window.


Now let’s visualize the entire flow clearly:

Complete Flow Recap

Clean. Logical. Controlled.

Think of it like a waiter serving dishes at a restaurant buffet.

You don’t bring all dishes at once.
You bring 8 dishes.
Customer eats and asks for more.
You bring next 8.
You repeat until kitchen says “No more food.”

hasMore is the kitchen status.
cursor is your place in the order queue.
observer is the customer raising hand.
fetchProducts is you going back to kitchen.

When the kitchen says no more food, you stop going.

That’s infinite scroll.

 

Setting Up Shopify Storefront API (GraphQL)

Before writing React logic, we need to prepare our GraphQL query correctly.

In Shopify Storefront API, pagination works using cursor-based pagination, not page numbers. That means every request needs:

  • first → number of products to fetch

  • after → cursor value

  • handle → collection handle

Here is a proper GraphQL query for fetching collection products:


const COLLECTION_PRODUCTS_QUERY = ` query getCollectionProducts($handle: String!, $first: Int!, $after: String) { collection(handle: $handle) { products(first: $first, after: $after) { edges { node { id title handle images(first: 1) { edges { node { url } } } priceRange { minVariantPrice { amount currencyCode } } } } pageInfo { hasNextPage endCursor } } } } `;

Now let’s create a reusable function to call Shopify API.


API Utility Function


export const fetchCollectionProducts = async ({ handle, first = 8, after = null, }) => {
const response = await fetch( `https://${process.env.REACT_APP_SHOPIFY_DOMAIN}/api/2023-10/graphql.json`, {
method: "POST", headers: { "Content-Type": "application/json", "X-Shopify-Storefront-Access-Token": process.env.REACT_APP_STOREFRONT_TOKEN, }, body: JSON.stringify({ query: COLLECTION_PRODUCTS_QUERY, variables: { handle, first, after }, }), } ); const data = await response.json(); return data?.data?.collection?.products; };

This function returns:

  • edges → product data

  • pageInfo → pagination info

Now we integrate this into React.


React Infinite Scroll Component

Let’s build the full component step by step.


import React, { useEffect, useState, useRef, useCallback } from "react"; import { fetchCollectionProducts } from "./api"; const CollectionProducts = ({ handle }) => { const [products, setProducts] = useState([]); const [cursor, setCursor] = useState(null); const [hasMore, setHasMore] = useState(true); const [loading, setLoading] = useState(false); const loaderRef = useRef(null); // Fetch Products Function const fetchProducts = useCallback(async () => { if (loading || !hasMore) return; setLoading(true); try { const result = await fetchCollectionProducts({ handle, first: 8, after: cursor, }); const newProducts = result.edges.map(edge => edge.node); setProducts(prev => [...prev, ...newProducts]); setCursor(result.pageInfo.endCursor); setHasMore(result.pageInfo.hasNextPage); } catch (error) { console.error("Error fetching products:", error); } setLoading(false); }, [handle, cursor, hasMore, loading]); // Initial Fetch useEffect(() => { fetchProducts(); }, []); // Intersection Observer useEffect(() => { if (!loaderRef.current) return; const observer = new IntersectionObserver( entries => { if (entries[0].isIntersecting) { fetchProducts(); } }, { threshold: 1 } ); observer.observe(loaderRef.current); return () => observer.disconnect(); }, [fetchProducts]); return ( <div> <div className="product-grid"> {products.map(product => ( <div key={product.id} className="product-card"> <img src={product.images.edges[0]?.node?.url} alt={product.title} /> <h3>{product.title}</h3> <p> {product.priceRange.minVariantPrice.amount}{" "} {product.priceRange.minVariantPrice.currencyCode} </p> </div> ))} </div> {hasMore && ( <div ref={loaderRef} style={{ height: "50px", textAlign: "center" }}> {loading && <p>Loading more products...</p>} </div> )} </div> ); }; export default CollectionProducts;

Let’s Deeply Understand This Code

Now I’ll explain every part carefully.

1. Why use useCallback for fetchProducts?

If we don’t wrap fetchProducts in useCallback, the function will be recreated on every render. That means our IntersectionObserver will also reinitialize unnecessarily.

Using useCallback stabilizes the function reference.


2. Why check if (loading || !hasMore)?

Because sometimes IntersectionObserver triggers multiple times quickly.

If we don’t protect the function:

  • Multiple API calls will fire

  • Products may duplicate

  • Performance issues will occur

So this condition prevents race conditions.


3. Why append products instead of replacing?

This is crucial:


setProducts(prev => [...prev, ...newProducts]);

If we replace products, infinite scroll breaks because old items disappear.

Appending ensures smooth stacking behavior.


4. Why use threshold: 1?

threshold: 1 means the loader must be fully visible before triggering.

You can also experiment with:


{ rootMargin: "200px" }

This preloads products before the user fully reaches bottom.

Better UX.

 

Webhooks Explained: Shopify, Zapier, Slack & Discord Automation Made Simple (2026 Guide)

 


Advanced Improvements (Production Level)

React Infinite Scroll improvements infographic

Now let’s improve it further.

1. Reset When Handle Changes

If user switches collection, we must reset state.


useEffect(() => { setProducts([]); setCursor(null); setHasMore(true); }, [handle]);

Then trigger initial fetch again.


2. Add Debouncing Protection

Sometimes rapid scroll causes double triggers.

You can guard using a ref:

const fetchingRef = useRef(false); if (fetchingRef.current) return; fetchingRef.current = true; // After API complete fetchingRef.current = false;

3. Show End Message

When no more products:


{!hasMore && <p style={{ textAlign: "center" }}>No more products</p>}

Small detail, better UX.


4. Error Handling UI

Instead of only console error:


const [error, setError] = useState(null);

Display:


{error && <p>Something went wrong. Try again.</p>}

Performance Optimization

If collection has 1000+ products, infinite scroll may cause performance drop because DOM grows continuously.

Solutions:

  • Use react-window

  • Use react-virtualized

  • Limit max items

  • Combine with “Load More” fallback


SEO Consideration (Important)

Infinite scroll alone is not SEO-friendly.

Google prefers paginated URLs like:


?page=2 ?page=3

So best practice:

  • Keep backend pagination

  • Add canonical links

  • Or use hybrid solution

For Shopify headless SEO, sometimes better to combine SSR + client infinite scroll.

Since you are working with Shopify and possibly Next.js, you could:

  • SSR first page

  • CSR infinite scroll next pages

Best of both worlds.

Conclusion

Infinite scroll using Shopify Storefront API and React is not just about loading more products — it’s about building a smooth, modern user experience that feels fast, natural, and interactive.

At its core, the entire system depends on three powerful ideas working together:

  1. Cursor-based pagination from Shopify

  2. Controlled state management in React

  3. Intersection Observer to detect scroll position

When the component mounts, we fetch the first set of products. Once products render, the loader div sits quietly at the bottom. The observer watches that loader. As soon as the user scrolls and the loader intersects the viewport, the next batch of products is fetched and appended. The cursor updates, hasMore updates, and the loader moves further down the page. This cycle continues smoothly until Shopify tells us there are no more products left.

The beauty of this approach is that it feels effortless to the user. There are no clicks. No page refreshes. Just continuous browsing.

But from a developer’s perspective, it requires discipline:

  • Prevent duplicate API calls

  • Manage loading state carefully

  • Append data correctly

  • Stop fetching when hasNextPage becomes false

  • Disconnect observer properly

When implemented correctly, infinite scroll improves engagement and makes product discovery more enjoyable. However, it’s also important to balance performance and SEO considerations, especially in headless Shopify builds.

If you truly understand the flow then you can apply this pattern anywhere. Not just products, but blogs, reviews, search results, or even order history.

At the end of the day, infinite scroll is less about the code and more about controlling data flow intelligently.

And once you master that flow, you’re not just implementing a feature — you’re designing experience.

Share:

Join the Discussion (0)