REST APIs - How To Connect Your React App Like The Pros (Incl Detailed Example)
Tags: react, javascript, webdev, frontend
Published: 2022-10-07 12:23:10

---
description: Data fetching = useEffect? In production apps that quickly gets out of hand. See why and how the pros use libraries for data-driven features with great UX.
---

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

1. [The Project](#the-project)
    1. [React Frontend](#react-frontend)
    2. [REST API](#rest-api)
2. [The “Simple” Approach With useEffect](#the-simple-approach-with-useeffect)
    1. [The Code](#the-code)
    2. [The Problems](#the-problems)
3. [The Effective Approach With react-query](#the-effective-approach-with-reactquery)
    1. [The Advantages](#the-advantages)
    2. [Setup](#setup)
    3. [A Simple Implementation](#a-simple-implementation)
    4. [Advanced Features For Free](#advanced-features-for-free)
    5. [Custom Hook As Best Practice](#custom-hook-as-best-practice)
    6. [Usage With TypeScript](#usage-with-typescript)
4. [Advanced Example: A Paginated List Component With Great UX](#advanced-example-a-paginated-list-component-with-great-ux)
    1. [Simple Pagination](#simple-pagination)
    2. [Prefetching The Next Page](#prefetching-the-next-page)
    3. [Reducing The Number Of Requests By Adjusting The Stale Time](#reducing-the-number-of-requests-by-adjusting-the-stale-time)
5. [Summary](#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](https://profy.dev). 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:

![](https://media.graphassets.com/QeyfpdYQDCgLl8DEU4bG)

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.

![](https://media.graphassets.com/rZxMZDfORA6TXvCld7fQ)

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.

```typescript
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](https://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](https://prolog-api.profy.dev/api). 

This is the response for the first page.

```json
{
  "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](https://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:

```typescript
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](https://axios-http.com/docs/intro) here instead of the native [fetch API](https://developer.mozilla.org/en-US/docs/Web/API/Fetch_API) since it is [simpler to use and has some additional features](https://blog.logrocket.com/axios-vs-fetch-best-http-requests/).

When we refresh the app we now see the data logged in the console of our browser:

![](https://media.graphassets.com/fR0vDWfTSKiNN8N4blM5)

Next, we have to save the data in a state variable to render it in the UI.

```typescript
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.

![](https://media.graphassets.com/Uldpyu5CRVMzMZdqugOd)

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?

```typescript
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?

```typescript
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.

[![Around React Email Course Signup](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vhfnci4btbcu10daw938.png)](https://profy.dev/article/react-rest-api#around-react-signup)

## 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.](https://profy.dev/article/react-tech-stack#react-query-or-apollo-as-popular-react-fetching-library) 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](https://tanstack.com/query). But I think this screenshot says it all.

![](https://media.graphassets.com/sxC5JAhSUiibFXfuMcGC)

### 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.

```typescript
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

```typescript
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.

![](https://media.graphassets.com/pr5q5TZNT2eQnVl0H2Du)

**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.

![](https://media.graphassets.com/oObNm12iTKGwj3mxjyKM)

**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.

![](https://media.graphassets.com/DUNFJ3Ske72DmxTP4Ktg)

### 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](https://tkdodo.eu/blog/practical-react-query#create-custom-hooks).

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

```typescript
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.

![](https://media.graphassets.com/ZVZ7UmeGRxKNIeDOzSd9)

[![Around React Email Course Signup](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vhfnci4btbcu10daw938.png)](https://profy.dev/article/react-rest-api#around-react-signup)

## 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.

```typescript
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.

```typescript
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.

![](https://media.graphassets.com/7ArESgQlTKi33wAu6O52)

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`.

```typescript
export function useIssues(page) {
  return useQuery(["issues", page], () => getIssues(page), {
    keepPreviousData: true,
  });
}
```

The loading state is gone! The table doesn’t disappear anymore.

![](https://media.graphassets.com/IPt1WDATaONIRTT7lS6p)

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](https://tanstack.com/query/v4/docs/guides/prefetching) so that the user doesn’t have to wait. Here is the code from the docs:

```typescript
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](https://tanstack.com/query/v4/docs/examples/react/pagination). Let’s adapt that example to our application:

```typescript
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).

![](https://media.graphassets.com/vTwZewBhRF6w4FH8tZgQ)

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).

![](https://media.graphassets.com/SmELAAuASRKPxad814K9)

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`.

```typescript
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.

![](https://media.graphassets.com/ujC6fJyQIK4VEzdtBWJs)

## 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.](https://tanstack.com/query)

[![Around React Email Course Signup](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/vhfnci4btbcu10daw938.png)](https://profy.dev/article/react-rest-api#around-react-signup)