How to implement infinite scroll using React Query?

Bicky Tamang
wesionaryTEAM
Published in
9 min readFeb 27, 2023

--

React Query is one of the most robust libraries in the React ecosystem which handles many challenges related to fetching, updating, caching, and retries.

Infinite Scroll using React Query

Infinite Scroll is a common pattern of loading chunks of data as the user scrolls toward the end of the loaded content. A common example is Facebook's news feed where you keep on scrolling as there is no end to it. It is often used to provide a more seamless and engaging browsing experience for users. Another common pattern is pagination.

In this article, we’ll be loading a bunch of to-dos content from JSONPlaceholder. JSONPlaceholder is a free online REST API that you can use whenever you need some fake data. This is what we’re going to build:

Installation

You can clone the starter project from GitHub. In this article, we’re solely going to focus on React Query implementation for infinite scroll so I’ve already installed the necessary packages in the starter repo. You can try it out using the following commands:

git clone -b starter https://github.com/bicky-tmg/react-query-infinite-scrolling.git

cd react-query-infinite-scrolling

npm install

If you look into App.js and a bunch of files you might notice React Query has been configured for you and a sample implementation has also been done. Hit npm run start in the terminal. Visit http://locahost:3000 if not redirected to the browser and voilà.

React Query supports a useful version of useQuery hook called useInfiniteQuery for rendering a list of additive data onto an existing set of data which is a common pattern known as as ‘Infinite Scroll’. useInfiniteQuery hook includes a bunch of options and returns an object that contains a bunch of useful properties. We’ll only discuss those options and properties that we’ll be implementing throughout this article.

Options

queryKey : Query keys are unique to the query’s data. It can be as simple as an Array with a single string, or as complex as an array of many strings and nested objects.

queryFn : The function that the query will use to request data. In our case, we’ll be passing in pageParams with a default value of 1 to the queryFn.

getNextPageParam : It helps to determine if there is more data to load and the information to fetch it. This information is either a single variable that is supplied as an additional parameter in the query function or undefined indicates there is no next page available. When new data is received for this query, this function receives both the last page of the infinite list of data and the full array of all pages.

Returns

data : The returned data is an object containing infinite query data. The object consists of;

  • data.pages an array containing the fetched pages
  • data.pageParams an array containing the page params used to fetch the pages.

hasNextPage : It is a boolean true if getNextPageParam returns a value other than undefined

fetchNextPage : This function allows fetching the next “page” of results. You can manually specify a pageParam instead of using getNextPageParam .

isFetchingNextPage : It is a boolean true while fetching the next page with fetchNextPage . It helps to distinguish between a background refresh state and a loading more state.

Now that we have basic information about all the options and return properties of the useInfiniteQuery hook let’s get started with the project.

Let's get started

In the App.js file replace the import of useQuery hook with useInfiniteQuery hook. Then we need to make changes to the query function fetchTodos as below:

// file: App.js

function App() {
const LIMIT = 10;

const fetchTodos = async (page) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=${LIMIT}`
);
return response.json();
};

...

Here the fetchTodos accepts a page as an argument and will fetch the todos per page basis. We’ve also added a LIMIT constant of 10 which limits the page size to 10. The data fetched will be at most 10. Next, completely replace the useQuery hook with the below code:

// file: App.js

const { data, isSuccess, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery("todos", ({ pageParam = 1 }) => fetchTodos(pageParam), {
getNextPageParam: (lastPage, allPages) => {
const nextPage =
lastPage.length === LIMIT ? allPages.length + 1 : undefined;
return nextPage;
},
});

Woah! What the hell is this? You don’t have to panic let’s break down the code step by step. The useInfiniteQuery hook takes three arguments: The first argument is a string representing the query key, which is used to identify the query in the cache. The second argument is a function that takes an object containing a pageParam property (which defaults to 1 if not provided) and returns a promise that resolves to the todos for that page. The third argument is an object containing options for the query, such as a getNextPageParam function that indicates whether a next page is available or not. The function receives both the last page of the infinite list of data and the full array of all pages. The function checks if the lastPage array has a length of LIMIT indicating that the last page was a full page of todos. Then it sets nextPage to allPages.length + 1, indicating that there is at least one more page to fetch. Otherwise, it sets nextPage to undefined, indicating that there are no more pages to fetch. It might not be the best logic for now but it works for us. The return value nextPage is passed into the queryFn in our case tofetchTodos under the hood by React Query. Now we need to change how we render the data object onto our UI.

// file: App.js

function App() {
...

return (
<div className="app">
{isSuccess &&
data.pages.map((page) =>
page.map((todo, i) => (
<article className="article">
<h2>{todo.title}</h2>
<p>Status: {todo.completed ? "Completed" : "To Complete"}</p>
</article>
))
)}
</div>
);
}

With these changes, the fetched todos must be displayed on the browser. So far what we’ve done is fetched the todos using useInfiniteQuery hook. We’ve laid down the groundwork for the infinite scrolling to work. To make it work we need to call the fetchNextPage hook whenever we scroll down to the last fetched todo. In order to achieve this we’ll be using a package called react-intersection-observer it is the React implementation of the Intersection Observer API to tell you when an element enters or leaves the viewport. Let’s install the package using the following command:

npm i react-intersection-observer

Once the package has been installed we need to import the package. Right below our React Query import, import the react-intersection-observer :

// file: App.js

import { useInfiniteQuery } from "react-query";
import { useInView } from "react-intersection-observer";

React Intersection Observer provides us with the useInView hook. The useInView hook makes it easy to monitor the inView state of the components. It will return an array containing a ref, the inView status, and the current entry. We need to assign the ref to the DOM element we want to monitor, and the hook will report the status.

// file: App.js

function App() {
const { ref, inView } = useInView();
const LIMIT = 10;

...

In our case, we need to attach the ref to the last fetched todo. But before that let’s create a separate Todo component. Create a Todo.js file and paste the code below:

// file: Todo.js

import React from "react";

const Todo = React.forwardRef(({ todo }, ref) => {
const todoContent = (
<>
<h2>{todo.title}</h2>
<p>Status: {todo.completed ? "Completed" : "To Complete"}</p>
</>
);

const content = ref ? (
<article className="article" ref={ref}>
{todoContent}
</article>
) : (
<article className="article">{todoContent}</article>
);
return content;
});

export default Todo;

Here since we will be assigning the ref from the useInView hook, we need to forward the ref down to the Todo component from App component. Ref forwarding is an opt-in feature that lets some components take a ref they receive, and pass it further down (in other words, “forward” it) to a child. React passes the ref to the ({todo}, ref) => ... function inside forwardRef as a second argument. We forward this ref argument down to <article className=”article” ref={ref}> by specifying it as a JSX attribute. When the ref is attached, ref.current will point to the <article> DOM node. Since we don’t want to attach ref to the list of todos, only the last todo from the fetched lists which will be covered later. The code below describes the implementation of attaching if ref has been passed attach the ref else not.

const content = ref ? (
<article className="article" ref={ref}>
{todoContent}
</article>
) : (
<article className="article">{todoContent}</article>
);

Now in App.js, we need to import the Todo component and make changes as:

// file: App.js

import { useInfiniteQuery } from "react-query";
import { useInView } from "react-intersection-observer";
import Todo from "./Todo";
import "./App.css";

function App() {
const { ref, inView } = useInView();
const LIMIT = 10;

const fetchTodos = async (page) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=${LIMIT}`
);
return response.json();
};

const { data, isSuccess, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery("todos", ({ pageParam = 1 }) => fetchTodos(pageParam), {
getNextPageParam: (lastPage, allPages) => {
const nextPage =
lastPage.length === LIMIT ? allPages.length + 1 : undefined;
return nextPage;
},
});

const content =
isSuccess &&
data.pages.map((page) =>
page.map((todo, i) => {
if (page.length === i + 1) {
return <Todo ref={ref} key={todo.id} todo={todo} />;
}
return <Todo key={todo.id} todo={todo} />;
})
);

return (
<div className="app">
{content}
</div>
);
}

export default App;

What changes have we done? We imported the Todo component and slightly updated the JSX content.

// file: App.js

const content =
isSuccess &&
data.pages.map((page) =>
page.map((todo, i) => {
if (page.length === i + 1) {
return <Todo ref={ref} key={todo.id} todo={todo} />;
}
return <Todo key={todo.id} todo={todo} />;
})
);

return (
<div className="app">
{content}
</div>
);

if (page.length === i + 1) indicates we are attaching ref to the last todo from the list of fetched todos. If it is the last todo from the list, attach ref else not. We’re almost at the end of this article. Now all that’s left is to monitor the last article dom node where the ref is attached using the inView state returned from useInView hook and if it hasNextPage we will fetchNextPage . We can achieve it through the code below:

// file: App.js

useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, fetchNextPage, hasNextPage]);

We also need to know when our data is being fetched so we can add a loader indicating that the Todo is being fetched using the isFetchingNextPage boolean provided by the useInfiniteQuery hook:

// file: App.js

func App() {
...

return (
<div className="app">
{content}
{isFetchingNextPage && <h3>Loading...</h3>}
</div>
);
}

To summarize what we’ve done so far in the App.js file:

// file: App.js

import { useEffect } from "react";
import { useInfiniteQuery } from "react-query";
import { useInView } from "react-intersection-observer";
import Todo from "./Todo";
import "./App.css";

function App() {
const { ref, inView } = useInView();
const LIMIT = 10;

const fetchTodos = async (page) => {
const response = await fetch(
`https://jsonplaceholder.typicode.com/todos?_page=${page}&_limit=${LIMIT}`
);
return response.json();
};

const { data, isSuccess, hasNextPage, fetchNextPage, isFetchingNextPage } =
useInfiniteQuery("todos", ({ pageParam = 1 }) => fetchTodos(pageParam), {
getNextPageParam: (lastPage, allPages) => {
const nextPage =
lastPage.length === LIMIT ? allPages.length + 1 : undefined;
return nextPage;
},
});

useEffect(() => {
if (inView && hasNextPage) {
fetchNextPage();
}
}, [inView, fetchNextPage, hasNextPage]);

const content =
isSuccess &&
data.pages.map((page) =>
page.map((todo, i) => {
if (page.length === i + 1) {
return <Todo ref={ref} key={todo.id} todo={todo} />;
}
return <Todo key={todo.id} todo={todo} />;
})
);

return (
<div className="app">
{content}
{isFetchingNextPage && <h3>Loading...</h3>}
</div>
);
}

export default App;

With this, new todos will be fetched when we scroll to the bottom of the page in our app.

Note: For better user experience what can be done is instead of detecting the visibility of the last element/todo for fetching the next list of todos if available, we can attach ref to the third last element/todo so when the third last element/todo is visible we fetch the next list of todos already and the user will not have to wait for a loading state which provides a smooth and seamless experience. All we need to do is change the logic in App.js .

From if (page.length === i + 1)

to if (page.length >= 3 && page.length — 3 === i

Conclusion

In this article, we covered how to implement Infinite Scroll using the React Query along with a very easy-to-use React Intersection Observer package.

For the next steps, you can dive into React Query's official documentation page to learn more advanced concepts.

If you are interested to see how the code would look altogether, you can take a look at the GitHub repository.

Happy coding! See you in the next article.

--

--