Consuming REST APIs with React isn’t very difficult. It only takes a useEffect
plus a few lines of code and you have the data in your frontend.
At least it seems like that at first.
But once you start adding features to your data fetching logic you quickly end up with a big mess of entangled spaghetti code.
Luckily there are a few libraries to our rescue (among them react-query). These not only make it really easy to fetch data but also deliver valuable and commonly used features right out of the box.
On this page, we’ll use an example project to see how quickly a “simple” approach to data fetching using useEffect
can get out of hand. In contrast, we’ll see how easy it is to build advanced features using react-query
by building a snappy paginated list component.
Table Of Contents
- The Project
- The “Simple” Approach With useEffect
- The Effective Approach With react-query
- Advanced Example: A Paginated List Component With Great UX
- Summary
The Project
Nobody wants to read through the setup of a new React app, I assume. So I prepared a realistic project for this article that shows a slightly more advanced use case for data fetching.
It’s an error-tracking tool similar to Sentry that I created for the React Job Simulator. It includes a React / Next.js frontend and a REST API which we will get our data from.
React Frontend
The goal is to fetch data and render it in our frontend like this:
You can see that this list has several pages (note the “Previous” and “Next” buttons at the bottom). This is very common in real-world projects.
But currently, we only see a single issue that is hard-coded in our frontend code.
The code responsible for rendering the list looks like the following at the moment. The goal is to replace the hard-coded data with data fetched from the API.
export function IssueList() {
// hard-coded issues data
const items = [
{
id: "mock-id",
projectId: "6d5fff43-d691-445d-a41a-7d0c639080e6",
name: "Mock Error",
message: "This is hard-coded data that doesn't come from an API",
stack: "Some mock stack trace",
level: "error",
numEvents: 105,
numUsers: 56,
} as Issue,
];
// hard-coded meta data used for pagination
const meta = {
hasNextPage: true,
currentPage: 1,
totalPages: 7,
}
// state variable used for pagination
const [page, setPage] = useState(1);
return (
<Container>
<Table>
<thead>
<HeaderRow>
<HeaderCell>Issue</HeaderCell>
<HeaderCell>Level</HeaderCell>
<HeaderCell>Events</HeaderCell>
<HeaderCell>Users</HeaderCell>
</HeaderRow>
</thead>
<tbody>
{(items || []).map((issue) => (
<IssueRow
key={issue.id}
issue={issue}
/>
))}
</tbody>
</Table>
<PaginationContainer>
<div>
<PaginationButton
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
Previous
</PaginationButton>
<PaginationButton
onClick={() => setPage(page + 1)}
disabled={!meta.hasNextPage}
>
Next
</PaginationButton>
</div>
<PageInfo>
Page <PageNumber>{meta.currentPage}</PageNumber> of{" "}
<PageNumber>{meta.totalPages}</PageNumber>
</PageInfo>
</PaginationContainer>
</Container>
);
}
REST API
To get our data we’ll use the REST endpoint prolog-api.profy.dev/issue (click the link to see the JSON response). You can find more details about this REST API in its Swagger API documentation.
This is the response for the first page.
{
"items": [
{
"id": "c9613c41-32f0-435e-aef2-b17ce758431b",
"projectId": "6d5fff43-d691-445d-a41a-7d0c639080e6",
"name": "TypeError",
"message": "Cannot read properties of undefined (reading 'length')",
"stack": "Cannot read properties of undefined (reading 'length')\n at eval (webpack-internal:///./pages/index.tsx:37:7)\n at invokePassiveEffectCreate (webpack-internal:///./node_modules/react-dom/cjs/react-dom.development.js:23482:20)\n at HTMLUnknownElement.callCallback (webpack-internal:///./node_modules/react-dom/cjs/react-dom.development.js:3945:14)\n at HTMLUnknownElement.sentryWrapped (webpack-internal:///./node_modules/@sentry/browser/esm/helpers.js:81:23)\n at Object.invokeGuardedCallbackDev (webpack-internal:///./node_modules/react-dom/cjs/react-dom.development.js:3994:16)\n at invokeGuardedCallback (webpack-internal:///./node_modules/react-dom/cjs/react-dom.development.js:4056:31)\n at flushPassiveEffectsImpl (webpack-internal:///./node_modules/react-dom/cjs/react-dom.development.js:23569:9)\n at unstable_runWithPriority (webpack-internal:///./node_modules/scheduler/cjs/scheduler.development.js:468:12)\n at runWithPriority$1 (webpack-internal:///./node_modules/react-dom/cjs/react-dom.development.js:11276:10)\n at flushPassiveEffects (webpack-internal:///./node_modules/react-dom/cjs/react-dom.development.js:23442:14)\n at eval (webpack-internal:///./node_modules/react-dom/cjs/react-dom.development.js:23319:11)\n at workLoop (webpack-internal:///./node_modules/scheduler/cjs/scheduler.development.js:417:34)\n at flushWork (webpack-internal:///./node_modules/scheduler/cjs/scheduler.development.js:390:14)\n at MessagePort.performWorkUntilDeadline (webpack-internal:///./node_modules/scheduler/cjs/scheduler.development.js:157:27)",
"level": "error",
"status": "resolved",
"numEvents": 105,
"numUsers": 56
},
....
],
"meta": {
"currentPage": 1,
"limit": 10,
"totalItems": 98,
"totalPages": 10,
"hasPreviousPage": false,
"hasNextPage": true
}
}
The issue
endpoint is a simple GET endpoint. But (as common in production APIs) this endpoint is paginated. That means that we don’t get all issues in a single request but only 10 at once (aka 10 per page). To get the next “page” of issues we simply append a query parameter like prolog-api.profy.dev/issue?page=2.
The endpoint also returns a meta
object that contains information related to the pagination like the current page or the number of total pages.
The “Simple” Approach With useEffect
Let’s start with a “simple” approach that many beginner tutorials teach. We’ll see that it’s in fact very simple to get the data and render it in the UI. But as soon as we go into the nitty-gritty details things start to get complex.
The Code
Inside our component, we want to fetch the data during the initial render. With the useEffect
hook this is simple:
import axios from "axios";
export function IssueList() {
useEffect(() => {
axios
.get("https://prolog-api.profy.dev/issue")
.then((response) => console.log(response));
// empty dependency array means this runs only once (at least in production)
}, []);
...
}
Note: We use axios here instead of the native fetch API since it is simpler to use and has some additional features.
When we refresh the app we now see the data logged in the console of our browser:
Next, we have to save the data in a state variable to render it in the UI.
export function IssueList() {
const [data, setData] = useState();
useEffect(() => {
axios
.get("https://prolog-api.profy.dev/issue")
.then((response) => setItems(response.data));
}, []);
const { items, meta } = data || {};
...
}
And voila, we see the data in our app.
Easy enough, isn’t it?
The Problems
Yes, just fetching data like that is easy. But in a production application, we can’t just leave it there. What if we wanted to handle loading and error state?
export function IssueList() {
const [data, setData] = useState();
const [error, setError] = useState();
const [isLoading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
axios
.get(`https://prolog-api.profy.dev/issue?page=${page}`)
.then((response) => setItems(response.data.items))
.catch((error) => setError(error))
.finally(() => setLoading(false));
}, []);
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>An error occured: {error.message}</div>;
}
...
}
What if we wanted to add a route parameter for pagination and cancel previous requests when the page changes?
export function IssueList() {
const [page, setPage] = useState(1);
const [data, setData] = useState();
const [error, setError] = useState();
const [isLoading, setLoading] = useState(false);
useEffect(() => {
setLoading(true);
const abortController = new AbortController();
axios
.get(`https://prolog-api.profy.dev/issue?page=${page}`, {
signal: abortController.signal,
})
.then((response) => setItems(response.data.items))
.catch((error) => {
// this is easily missed
if (error.message !== "canceled") {
setError(error);
}
})
.finally(() => setLoading(false));
return () => abortController.abort();
}, [page]);
if (isLoading) {
return <div>Loading...</div>;
}
if (error) {
return <div>An error occured: {error.message}</div>;
}
...
What if we wanted to add other features that are often a requirement in real-world applications? Like
- retry requests when an error occurs
- pre-fetch data for the next page to improve UX
- refresh stale data after a while
- cache response data.
You get where this is going. The initially simple-looking code can quickly become a nightmare of entangled spaghetti full of bugs. Believe me, in a previous job a few years ago we went down this road… and it wasn’t pretty.
The Effective Approach With react-query
Luckily since a few years, we have libraries that abstract a lot of common use cases of data fetching. Popular examples are react-query, Redux Toolkit Query, or Apollo. react-query is one of the most widespread and versatile ones so we use it for this tutorial.
The Advantages
To get an overview of all the advantages it is a good idea to have a look at the react-query documentation. But I think this screenshot says it all.
Setup
To use react-query
we need to wrap our app component with a QueryClientProvider
. This is common practice that you probably know from other libraries as well.
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
import { ReactQueryDevtools } from "@tanstack/react-query-devtools";
const queryClient = new QueryClient();
export default function MyApp({ Component, pageProps }) {
return (
<QueryClientProvider client={queryClient}>
<Component {...pageProps} />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Additionally, we added the dev tools that come with react-query
. We will talk about this further down the page.
A Simple Implementation
import axios from "axios";
import { useQuery } from "@tanstack/react-query";
export function IssueList() {
const { data, isLoading, isError, error } = useQuery(["issues"], () =>
axios.get("https://prolog-api.profy.dev/issue")
);
if (isLoading) {
return <div>Loading...</div>;
}
if (isError) {
return <div>An error occured: {error.message}</div>;
}
// data is here the response object returned by axios
const { items, meta } = data.data;
...
These simple three lines of code give us already a loading and error state.
The first parameter ["issues"]
that we pass to useQuery
is used as a unique identifier for the data stored in the cache. The second parameter is the callback that connects to the API (the same axios call that we used in the useEffect
). Optionally we can pass a third parameter with config options.
You can see that useQuery
doesn’t actually fetch the data itself but is more like a wrapper around the data-fetching callback that adds additional functionality.
Advanced Features For Free
We saw at first glance that the loading and error states are supported out of the box. But under the hood, we also get a few really handy advanced features for free. Here are a few examples:
1. A request cache that automatically updates in the background
In this video, you can see that a request is sent on the initial page load (the loading screen is shown). But when we navigate to another page and back we don’t see the loading screen anymore. The data is immediately there because it was cached.
This isn’t all though. In the network tab at the bottom, you can see that a request is sent in the background. The cache is automatically updated with the newest server data.
2. Automatic updates of stale data
The cache is not only updated when we navigate back and forth inside the application. When the user switches to another tab and comes back to our app the data is by default updated in the background as well.
3. Dev tools
You may have seen the “flower” icon at the bottom left of the page? That’s the button to open the dedicated react-query
dev tools. You can see exactly what kind of requests have been sent, what’s in the cache, and the data plus additional information for each request.
Very handy for debugging.
Custom Hook As Best Practice
Since data fetching logic is often shared between multiple components it’s best practice to extract custom hooks for each endpoint.
export function useIssues() {
const { data } = useQuery(["issues"], () =>
axios.get(`https://prolog-api.profy.dev/issue`)
);
return data;
}
Now we can use this hook in our component.
export function IssueList() {
const [page, setPage] = useState(1);
const issuesPage = useIssues();
if (issuesPage.isLoading) {
return <div>Loading...</div>;
}
if (issuesPage.isError) {
return <div>An error occured: {issuesPage.error.message}</div>;
}
const { items, meta } = issuesPage.data || {};
...
}
Note that we have separated the fetching logic from the component now. In the previous simple example, the component used the response object from axios
directly. Now we can easily encapsulate this in our custom hook. This can be beneficial, for example, if we want to replace axios
with something else at some point.
To further improve our custom hook it’s common to extract the data fetching callback into its own function.
export async function getIssues() {
const { data } = await axios.get("https://prolog-api.profy.dev/issue");
return data;
}
export function useIssues() {
return useQuery(["issues"], () => getIssues());
}
Now axios
is further isolated and the getIssues
function is responsible for handling the response data. Additionally, we can reuse the getIssues
function in other parts of our app (e.g. the server for server-side rendering).
Usage With TypeScript
For type safety and improved developer experience, it’s easy to use TypeScript with react-query
. Simply pass the type of the expected response data plus an error type to the useQuery
generic.
import { useQuery, useQueryClient } from "@tanstack/react-query";
import axios from "axios";
import type { IssuePage } from "./types";
async function getIssues(page: number) {
const { data } = await axios.get(
`https://prolog-api.profy.dev/issue?page=${page}`
);
return data;
}
export function useIssues(page: number) {
const query = useQuery<IssuePage, Error>(["issues", page], () =>
getIssues(page)
);
return query;
}
The types in our case might look like this.
type PageMeta = {
currentPage: number;
limit: number;
totalItems: number;
totalPages: number;
hasPreviousPage: boolean;
hasNextPage: boolean;
};
enum IssueLevel {
info = "info",
warning = "warning",
error = "error",
}
type Issue = {
id: string;
projectId: string;
name: string;
message: string;
stack: string;
level: IssueLevel;
numEvents: number;
};
export type IssuePage = {
items: Array[Issue];
meta: PageMeta;
};
Now we not only have advanced errors and warnings in our IDE. We don’t even have to remember the data structure of our API data. The IDE’s autocomplete feature (here VS Code) takes care of that.
Advanced Example: A Paginated List Component With Great UX
Great, we saw a simple example of how to make a request to our REST API. We also already saw some of the hidden powers that come with react-query
out of the box.
Now it’s time for a slightly more advanced example. We’ll implement the pagination for our issue list. At the surface, this only means adding a query parameter to the request URL. But as we’ll see in a bit there are some UX hick-ups that we’ll run into.
It’s magic how easily those are fixed just by adjusting a few react-query
options.
Simple Pagination
Let’s start by adding pagination. In our useIssues
hook we can simply add a parameter and append it to the URL.
async function getIssues(page) {
const { data } = await axios.get(
`https://prolog-api.profy.dev/issue?page=${page}`
);
return data;
}
export function useIssues(page) {
return useQuery(["issues", page], () => getIssues(page));
}
Note that we also added the page
parameter to the cache identifier of useQuery
. You can compare it to the dependency array of the useEffect
hook.
In our component, we can simply pass the page
state variable to the useIssues
hook and connect it to the pagination buttons.
export function IssueList() {
const [page, setPage] = useState(1);
const issuesPage = useIssues(page);
if (issuesPage.isLoading) {
return <div>Loading...</div>;
}
if (issuesPage.isError) {
return <div>An error occured: {issuesPage.error.message}</div>;
}
// we use the meta data returned in the response to disable
// the "Next" button below
const { items, meta } = issuesPage.data || {};
return (
<Container>
<Table>
<thead>
<HeaderRow>
<HeaderCell>Issue</HeaderCell>
<HeaderCell>Level</HeaderCell>
<HeaderCell>Events</HeaderCell>
<HeaderCell>Users</HeaderCell>
</HeaderRow>
</thead>
<tbody>
{(items || []).map((issue) => (
<IssueRow key={issue.id} issue={issue} />
))}
</tbody>
</Table>
<PaginationContainer>
<div>
<PaginationButton
onClick={() => setPage(page - 1)}
disabled={page === 1}
>
Previous
</PaginationButton>
<PaginationButton
onClick={() => setPage(page + 1)}
disabled={!meta.hasNextPage}
>
Next
</PaginationButton>
</div>
<PageInfo>
Page <PageNumber>{meta.currentPage}</PageNumber> of{" "}
<PageNumber>{meta.totalPages}</PageNumber>
</PageInfo>
</PaginationContainer>
</Container>
);
}
The pagination works already well. When clicking on the buttons at the bottom able to see the different pages of issues as expected. As a bonus, thanks to the automatic caching the user doesn’t even see a loading screen when navigating to a previous page.
But as you can see the loading state that is shown when clicking on the “Next” button is pretty annoying. The table disappears completely until the new data arrives.
It would be great if we could show the old data until the next page is loaded. And no surprise, this is very simple: we just need to add an option to useQuery
.
export function useIssues(page) {
return useQuery(["issues", page], () => getIssues(page), {
keepPreviousData: true,
});
}
The loading state is gone! The table doesn’t disappear anymore.
This looks already a lot nicer. But the drawback is the delay between the button click and the next page being rendered. Depending on how fast the request resolves the user might not understand that the button has any effect and fall into “click rage”.
Prefetching The Next Page
But of course, react-query
is there to the rescue. The docs have a section about prefetching data so that the user doesn’t have to wait. Here is the code from the docs:
const prefetchTodos = async () => {
// The results of this query will be cached like a normal query
await queryClient.prefetchQuery(['todos'], fetchTodos)
}
Even better, the docs (can’t praise them enough) provide examples for common use cases. Among them an example implementation for pagination that includes prefetching the next page. Let’s adapt that example to our application:
import axios from "axios";
import { useQuery, useQueryClient } from "@tanstack/react-query";
export function useIssues(page) {
const query = useQuery(["issues", page], () => getIssues(page), {
keepPreviousData: true,
});
// Prefetch the next page!
const queryClient = useQueryClient();
useEffect(() => {
if (query.data?.meta.hasNextPage) {
queryClient.prefetchQuery(["issues", page + 1], () =>
getIssues(page + 1)
);
}
}, [query.data, page, queryClient]);
return query;
}
This makes paginating basically instantaneous. There’s no delay for the user (as long as the prefetch request has been resolved obviously).
When you look at the network requests at the bottom you can see how the next page is already loaded upfront.
Reducing The Number Of Requests By Adjusting The Stale Time
There’s a last small issue though: when clicking the “Next” button there are two requests. On the initial page load there are two requests as expected (page 1 and 2). But when we click on the “Next” button there are again two requests (page 2 and 3).
The reason is that react-query
refetches “stale” data automatically. But for our kind of app, the data doesn’t go stale that quickly so the duplicate request for page 2 isn’t necessary. The additional requests may increase our server costs so we can as well get rid of them.
The solution is simple: we add another option called staleTime
that we pass to useQuery
.
export function useIssues(page) {
const query = useQuery(["issues", page], () => getIssues(page), {
keepPreviousData: true,
// data is considered stale after one minute
staleTime: 60000,
});
...
}
Now we only see one request for the second page. We got rid of the unnecessary request.
Summary
While useEffect
combined with fetch
or axios
seems like a simple solution at first you risk ending up with a whole bunch of unmaintainable spaghetti code.
Modern React projects use libraries to outsource the complexities of common use cases in data fetching. react-query
is currently one of the most popular data-fetching libraries. And you have seen its power.
With only a few lines of code and a few config options, we were able to create a snappy paginated list with fairly good UX.
Still, we’ve seen only a fraction of what’s possible with react-query
. Check out the awesome docs for many more features.
Top comments (1)
very useful content for binger's like me.