Building Infinite Scroll React Query and Observer API

Building an Infinite Scroll with React Query and Intersection Observer 2025

Why Infinite Scroll?

Remember scrolling through Twitter, Instagram, or Facebook? You never clicked a “Next Page” button, right? The content just kept loading as you scrolled down. That’s infinite scroll — and it’s way smoother than old-school pagination.

Traditional pagination makes users click “Next” every time they want more content. That breaks the flow, slows them down, and honestly feels clunky in 2025.

With infinite scroll, new data loads automatically when users reach the bottom of the page. No clicking, no waiting, no interruption — just smooth, continuous scrolling.

In this tutorial, we’ll build a proper infinite scroll feature using React Query for smart data fetching and caching, plus the Intersection Observer API to detect when users scroll near the bottom.

Prerequisites

Before we start, make sure we have:

  • Node.js (v18 or higher)
  • Basic understanding of React
  • A backend API (we’ll build a simple Node.js one, or you can use your own)

Check your Node version:

node --version
npm --version

Both should return version numbers.

How Infinite Scroll Works — Better Than Traditional Pagination

Let’s break down how infinite scroll works compared to regular pagination.

Traditional Pagination:

  1. User sees 10 items
  2. Clicks “Next Page”
  3. Page reloads or refetches data
  4. Shows next 10 items
  5. Previous data disappears

Infinite Scroll:

  1. User sees 10 items
  2. Scrolls down
  3. App detects scroll position
  4. Automatically loads next 10 items
  5. Appends new data to the existing list
  6. User keeps scrolling without interruption

The trick is using the Intersection Observer API to detect when a “trigger element” (usually at the bottom of your list) becomes visible on screen. When it does, we fetch more data.

React Query handles the hard parts:

  • Fetching data in pages
  • Caching previous pages
  • Managing loading states
  • Preventing duplicate requests

Step 1: Create React Project

First, let’s create a new React app with Vite (it’s faster than Create React App):

npm create vite@latest infinite-scroll-app -- --template react-ts
cd infinite-scroll-app
npm install

Step 2: Install Dependencies

Now install React Query and Axios:

npm install @tanstack/react-query axios

React Query (TanStack Query) is our main tool for data fetching. It handles caching, pagination, and automatic refetching. Axios is just for making HTTP requests — you could also use fetch if you prefer.

Step 3: Project Structure

Let’s organize our files properly:

mkdir -p src/components src/api src/hooks src/types

Your structure should look like this:

src/
├── api/
│   └── posts.ts
├── components/
│   └── PostList.tsx
├── hooks/
│   └── usePosts.ts
├── types/
│   └── index.ts
├── App.tsx
└── main.tsx

Build the Backend API

For this tutorial, we need a simple API that returns paginated data. We’ll build a quick Node.js API, but you can skip this if you already have your own backend.

Step 1: Create Backend Folder

In a separate folder (outside your React project):

mkdir infinite-scroll-api
cd infinite-scroll-api
npm init -y
npm install express cors

Step 2: Create Server

Create server.js:

const express = require('express');
const cors = require('cors');

const app = express();
app.use(cors());
app.use(express.json());

// Mock data - in real apps, this comes from a database
const generatePosts = (page, limit) => {
  const start = (page - 1) * limit;
  const posts = [];
  
  for (let i = start; i < start + limit; i++) {
    posts.push({
      id: i + 1,
      title: `Post ${i + 1}`,
      content: `This is the content of post ${i + 1}. Lorem ipsum dolor sit amet, consectetur adipiscing elit.`,
      author: `User ${Math.floor(Math.random() * 10) + 1}`,
      likes: Math.floor(Math.random() * 100),
      createdAt: new Date(Date.now() - Math.random() * 10000000000).toISOString()
    });
  }
  
  return posts;
};

app.get('/api/posts', (req, res) => {
  const page = parseInt(req.query.page) || 1;
  const limit = parseInt(req.query.limit) || 10;
  
  // Simulate network delay
  setTimeout(() => {
    const posts = generatePosts(page, limit);
    const totalPosts = 100; // Total items in database
    const hasMore = page * limit < totalPosts;
    
    res.json({
      posts,
      nextPage: hasMore ? page + 1 : null,
      hasMore
    });
  }, 500);
});

const PORT = 3001;
app.listen(PORT, () => {
  console.log(`API running on http://localhost:${PORT}`);
});

Run it:

node server.js

Test it in your browser: http://localhost:3001/api/posts?page=1&limit=10

You should see JSON with 10 posts.

Set Up React Query

Now back to your React project.

Step 1: Set Up Query Client

Open src/main.tsx and wrap your app with QueryClientProvider:

import { StrictMode } from 'react'
import { createRoot } from 'react-dom/client'
import { QueryClient, QueryClientProvider } from '@tanstack/react-query'
import App from './App'
import './index.css'

const queryClient = new QueryClient({
  defaultOptions: {
    queries: {
      staleTime: 1000 * 60 * 5,
      refetchOnWindowFocus: false,
    },
  },
})

createRoot(document.getElementById('root')!).render(
  <StrictMode>
    <QueryClientProvider client={queryClient}>
      <App />
    </QueryClientProvider>
  </StrictMode>,
)

The QueryClient is React Query’s main manager. It handles:

  • Caching all your API data
  • Deciding when to refetch
  • Storing loading states

The staleTime option tells React Query how long to consider data “fresh” before refetching. Here, we set it to 5 minutes.

Step 2: Define Types

Create src/types/index.ts:

export interface Post {
  id: number
  title: string
  content: string
  author: string
  likes: number
  createdAt: string
}

export interface PostsResponse {
  posts: Post[]
  nextPage: number | null
  hasMore: boolean
}

This defines the shape of our data so TypeScript can help catch bugs.

Step 3: Create API Function

Create src/api/posts.ts:

import axios from 'axios'
import type { PostsResponse } from '@/types/index';

const API_BASE_URL = 'http://localhost:3001/api'

export async function fetchPosts(page: number): Promise<PostsResponse> {
  const response = await axios.get(`${API_BASE_URL}/posts`, {
    params: {
      page,
      limit: 10
    }
  })
  return response.data
}

export async function likePost(postId: number): Promise<void> {
  await new Promise(resolve => setTimeout(resolve, 300))
}

This is our data layer — all API calls go through here.

Build the Infinite Scroll Component

Now the fun part — building the actual infinite scroll.

Step 1: Create the Hook

Create src/hooks/usePosts.ts:

import { useInfiniteQuery } from '@tanstack/react-query'
import { fetchPosts } from '../api/posts'

export function usePosts() {
  return useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextPage,
    initialPageParam: 1,
  })
}

Let’s break this down:

  • useInfiniteQuery is React Query’s special hook for paginated data
  • queryKey: ['posts'] is the cache key — React Query uses this to store and retrieve data
  • queryFn is the function that fetches data. The pageParam tells it which page to fetch
  • getNextPageParam tells React Query what the next page number should be (from the API response)
  • initialPageParam: 1 means we start at page 1

When you call this hook, React Query automatically:

  • Fetches page 1
  • Caches the result
  • Gives you a fetchNextPage() function to load more
  • Tracks loading states

Step 2: Build the Post List Component

Create src/components/PostList.tsx:

import { useEffect, useRef } from 'react'
import { usePosts } from '../hooks/usePosts'
import './PostList.css'

export default function PostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    error
  } = usePosts()

  const observerTarget = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage()
        }
      },
      { threshold: 0.5 }
    )

    const currentTarget = observerTarget.current
    if (currentTarget) {
      observer.observe(currentTarget)
    }

    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget)
      }
    }
  }, [fetchNextPage, hasNextPage, isFetchingNextPage])

  if (isLoading) {
    return (
      <div className="loading-container">
        <div className="spinner"></div>
        <p>Loading posts...</p>
      </div>
    )
  }

  if (isError) {
    return (
      <div className="error-container">
        <p>Failed to load posts: {error.message}</p>
        <button onClick={() => window.location.reload()}>Retry</button>
      </div>
    )
  }

  return (
    <div className="post-list-container">
      <h1>Infinite Scroll Posts</h1>
      
      <div className="posts-grid">
        {data?.pages.map((page) =>
          page.posts.map((post) => (
            <article key={post.id} className="post-card">
              <div className="post-header">
                <h2>{post.title}</h2>
                <span className="post-author">by {post.author}</span>
              </div>
              <p className="post-content">{post.content}</p>
              <div className="post-footer">
                <span className="post-likes">❤️ {post.likes} likes</span>
                <span className="post-date">
                  {new Date(post.createdAt).toLocaleDateString()}
                </span>
              </div>
            </article>
          ))
        )}
      </div>

      {/* This is our Intersection Observer trigger */}
      <div ref={observerTarget} className="observer-trigger">
        {isFetchingNextPage && (
          <div className="loading-more">
            <div className="spinner"></div>
            <p>Loading more posts...</p>
          </div>
        )}
        {!hasNextPage && (
          <p className="end-message">You've reached the end! 🎉</p>
        )}
      </div>
    </div>
  )
}

Here’s what’s happening:

The Intersection Observer:

  • We create an observerTarget ref that points to a div at the bottom of our list
  • When that div becomes visible on screen, entries[0].isIntersecting becomes true
  • If we also have more pages (hasNextPage), we call fetchNextPage()
  • React Query fetches the next page and automatically appends it to our data

The Rendering:

  • data?.pages is an array of all the pages we’ve loaded so far
  • We loop through each page, then loop through the posts in that page
  • All posts stay on screen — nothing disappears

The States:

  • isLoading: true when initially loading
  • isFetchingNextPage: true when loading the next page (while scrolling)
  • hasNextPage: true if there’s more data available
  • isError: true if something went wrong

Step 3: Update App Component

Open src/App.tsx:

import PostList from './components/PostList'
import './App.css'

function App() {
  return (
    <div className="app">
      <PostList />
    </div>
  )
}

export default App

Now run your app:

npm run dev

Open http://localhost:5173 and try scrolling down. When you reach the bottom, new posts should load automatically. No clicking needed!

Building Infinite Scroll React Query and Observer API

Add Optimistic Updates

One thing that makes apps feel super fast is optimistic updates — updating the UI immediately before waiting for the server to confirm.

Let’s add a “like” feature with optimistic updates.

Step 1: Update the Hook

Modify src/hooks/usePosts.ts:

import { useInfiniteQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { fetchPosts, likePost } from '../api/posts'
import type { Posts } from '@/types/index';

export function usePosts() {
  return useInfiniteQuery({
    queryKey: ['posts'],
    queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
    getNextPageParam: (lastPage) => lastPage.nextPage,
    initialPageParam: 1,
  })
}

export function useLikePost() {
  const queryClient = useQueryClient()

  return useMutation({
    mutationFn: likePost,
    onMutate: async (postId) => {
      await queryClient.cancelQueries({ queryKey: ['posts'] })
      const previousData = queryClient.getQueryData(['posts'])

      queryClient.setQueryData(['posts'], (old: any) => {
        if (!old) return old

        return {
          ...old,
          pages: old.pages.map((page: any) => ({
            ...page,
            posts: page.posts.map((post: Post) =>
              post.id === postId
                ? { ...post, likes: post.likes + 1 }
                : post
            ),
          })),
        }
      })

      return { previousData }
    },
    onError: (err, postId, context) => {
      if (context?.previousData) {
        queryClient.setQueryData(['posts'], context.previousData)
      }
    },
    onSettled: () => {
      queryClient.invalidateQueries({ queryKey: ['posts'] })
    },
  })
}

Here’s what this does:

onMutate (runs immediately):

  • Cancels any ongoing fetches to prevent race conditions
  • Saves the current data (in case we need to rollback)
  • Updates the cache optimistically — increases the like count right away

onError (runs if API call fails):

  • Restores the previous data (rollback)
  • User sees the like button go back to normal

onSettled (runs after everything):

  • Tells React Query to refetch in the background
  • Ensures the UI matches the real server state

Step 2: Add Like Button to UI

Update src/components/PostList.tsx:

import { useEffect, useRef, useState } from 'react'
import { usePosts, useLikePost } from '../hooks/usePosts'
import './PostList.css'

export default function PostList() {
  const {
    data,
    fetchNextPage,
    hasNextPage,
    isFetchingNextPage,
    isLoading,
    isError,
    error
  } = usePosts()

  const likeMutation = useLikePost()
  const [likedPosts, setLikedPosts] = useState<Set<number>>(new Set())

  const observerTarget = useRef<HTMLDivElement>(null)

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
          fetchNextPage()
        }
      },
      { threshold: 0.5 }
    )

    const currentTarget = observerTarget.current
    if (currentTarget) {
      observer.observe(currentTarget)
    }

    return () => {
      if (currentTarget) {
        observer.unobserve(currentTarget)
      }
    }
  }, [fetchNextPage, hasNextPage, isFetchingNextPage])

  const handleLike = (postId: number) => {
    if (likedPosts.has(postId)) return // Already liked
    
    setLikedPosts(prev => new Set(prev).add(postId))
    likeMutation.mutate(postId)
  }

  if (isLoading) {
    return (
      <div className="loading-container">
        <div className="spinner"></div>
        <p>Loading posts...</p>
      </div>
    )
  }

  if (isError) {
    return (
      <div className="error-container">
        <p>Failed to load posts: {error.message}</p>
        <button onClick={() => window.location.reload()}>Retry</button>
      </div>
    )
  }

  return (
    <div className="post-list-container">
      <h1>Infinite Scroll Posts</h1>
      
      <div className="posts-grid">
        {data?.pages.map((page) =>
          page.posts.map((post) => (
            <article key={post.id} className="post-card">
              <div className="post-header">
                <h2>{post.title}</h2>
                <span className="post-author">by {post.author}</span>
              </div>
              <p className="post-content">{post.content}</p>
              <div className="post-footer">
                <button
                  className={`like-button ${likedPosts.has(post.id) ? 'liked' : ''}`}
                  onClick={() => handleLike(post.id)}
                  disabled={likedPosts.has(post.id)}
                >
                  {likedPosts.has(post.id) ? '❤️' : '🤍'} {post.likes} likes
                </button>
                <span className="post-date">
                  {new Date(post.createdAt).toLocaleDateString()}
                </span>
              </div>
            </article>
          ))
        )}
      </div>

      <div ref={observerTarget} className="observer-trigger">
        {isFetchingNextPage && (
          <div className="loading-more">
            <div className="spinner"></div>
            <p>Loading more posts...</p>
          </div>
        )}
        {!hasNextPage && (
          <p className="end-message">You've reached the end! 🎉</p>
        )}
      </div>
    </div>
  )
}

Now when you click the like button, it instantly updates — no waiting. If the API call fails (try turning off your server), it rolls back.

Advanced Features

Scroll to Top Button

Add a “Back to Top” button that appears when you scroll down:

import { useEffect, useRef, useState } from 'react'
import { usePosts, useLikePost } from '../hooks/usePosts'
import './PostList.css'

export default function PostList() {
  // ... existing code ...
  
  const [showScrollTop, setShowScrollTop] = useState(false)

  useEffect(() => {
    const handleScroll = () => {
      setShowScrollTop(window.scrollY > 500)
    }

    window.addEventListener('scroll', handleScroll)
    return () => window.removeEventListener('scroll', handleScroll)
  }, [])

  const scrollToTop = () => {
    window.scrollTo({ top: 0, behavior: 'smooth' })
  }

  return (
    <div className="post-list-container">
      {/* ... existing JSX ... */}
      
      {showScrollTop && (
        <button className="scroll-top-button" onClick={scrollToTop}>
          ↑ Top
        </button>
      )}
    </div>
  )
}

Search and Filter

Add a search bar that filters posts:

const [searchQuery, setSearchQuery] = useState('')

const filteredPages = data?.pages.map(page => ({
  ...page,
  posts: page.posts.filter(post =>
    post.title.toLowerCase().includes(searchQuery.toLowerCase()) ||
    post.content.toLowerCase().includes(searchQuery.toLowerCase())
  )
}))

return (
  <div className="post-list-container">
    <h1>Infinite Scroll Posts</h1>
    
    <input
      type="search"
      placeholder="Search posts..."
      value={searchQuery}
      onChange={(e) => setSearchQuery(e.target.value)}
      className="search-input"
    />
    
    <div className="posts-grid">
      {filteredPages?.map((page) =>
        page.posts.map((post) => (
          // ... post card JSX ...
        ))
      )}
    </div>
  </div>
)

Loading Skeleton

Instead of showing a spinner, show skeleton cards:

function SkeletonCard() {
  return (
    <div className="skeleton-card">
      <div className="skeleton-header"></div>
      <div className="skeleton-content"></div>
      <div className="skeleton-content short"></div>
    </div>
  )
}

// In your component:
if (isLoading) {
  return (
    <div className="post-list-container">
      <h1>Infinite Scroll Posts</h1>
      <div className="posts-grid">
        {Array.from({ length: 6 }).map((_, i) => (
          <SkeletonCard key={i} />
        ))}
      </div>
    </div>
  )
}

Troubleshoting

Update the vite.config.ts

Add this to force single React instance: typescript if you see the “Invalid hook call” error

import { defineConfig } from 'vite'
import react from '@vitejs/plugin-react'
import path from 'path'

export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      '@': path.resolve(__dirname, './src'),
    },
    dedupe: ['react', 'react-dom']
  },
  optimizeDeps: {
    include: ['react', 'react-dom', '@tanstack/react-query']
  }
})

Conclusion

We’ve built a complete infinite scroll feature with React Query and Intersection Observer. This pattern is way better than traditional pagination because:

  • Users don’t have to click “Next” — content loads automatically
  • React Query handles all the caching, so previously loaded pages load instantly
  • Optimistic updates make the UI feel super fast
  • The Intersection Observer API is efficient — it doesn’t constantly check scroll position

The key parts:

  • useInfiniteQuery for smart paginated data fetching
  • Intersection Observer to detect when to load more
  • Optimistic updates for instant UI feedback
  • Proper error handling and loading states

You can use this pattern for any list: social media feeds, product catalogs, search results, notification lists — anywhere pagination slows users down.

All the source code is available on GitHub.

Leave a Comment