How to implement infinite scroll using React Query?
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 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 pagesdata.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.