How to fetch data from a back-end in React

Today, we're going to talk about fetching data in React. One of the most common things you'll see in a React application is interacting with and fetching data from a back-end. In this article, we'll demonstrate how to fetch data from an external API using the Fetch API.

Table of contents

What is Fetch?

The Fetch API provides an interface for fetching resources (including across the network). It will seem familiar to anyone who has used XMLHttpRequest, but the new API provides a more powerful and flexible feature set.

Put simply, it's a newer, more flexible interface to make HTTP requests without the need for the ancient XMLHttpRequest API (browser compatibility permitting).

Tutorial

Here's a preview of what we're going to build today (the data is fetched from a mock API).

State

Let's see how Fetch works in action and start by adding state to store our data that we are about to fetch. We are going to be interacting with a mock endpoint which returns tasks to complete (like a to do list), so let's call it tasks.

1const App = () => {
2 const [tasks, setTasks] = useState([]);
3
4 return <h1>Fetch API</h1>;
5};

Note: Remember to import useState if you haven't already.

Effects

Now, we need to fetch the tasks. Let's create an effect in our App component with no dependencies:

1const App = () => {
2 const [tasks, setTasks] = useState([]);
3
4 useEffect(() => {
5 // This code will run when the component mounts.
6 }, []);
7
8 return <h1>Fetch API</h1>;
9};

Note: Remember to import useEffect if you haven't already.

Now, we're going to be using the async await syntax in this article, but you can use the .then syntax if you prefer. As fetch is asynchronous and returns a promise, we need to await it. Effect callbacks cannot be async, so we cannot do this:

1useEffect(async () => {
2 // We cannot do this.
3}, []);

Instead, my preferred method is to declare an asynchronous function within our effect, and invoke it below. Let's see what that looks like:

1useEffect(() => {
2 const fetchTasks = async () => {
3 // ...
4 };
5
6 fetchTasks();
7}, []);

Requests

Now, let's write our request and then step through it afterwards.

1useEffect(() => {
2 const fetchTasks = async () => {
3 const response = await fetch("https://jsonplaceholder.typicode.com/todos");
4
5 const tasks = await response.json();
6
7 setTasks(tasks);
8 };
9
10 fetchTasks();
11}, []);

Let's step through this code:

  • First, we store the fetch response in a variable named response
  • Secondly, we parse the response as JSON by calling .json() on the response
  • Finally, we store these tasks in state.

Rendering

Great, now let's render our tasks.

The shape of the data returned by the API looks like this:

1{
2 id: 1,
3 title: "delectus aut autem",
4}

Note: This example only shows the data we need for this article.

Let's map over these tasks and render them:

1const App = () => {
2 const [tasks, setTasks] = useState([]);
3
4 useEffect(() => {
5 const fetchTasks = async () => {
6 const response = await fetch("https://jsonplaceholder.typicode.com/todos");
7
8 const tasks = await response.json();
9
10 setTasks(tasks);
11 };
12
13 fetchTasks();
14 }, []);
15
16 return (
17 <section>
18 <h1>Fetch API</h1>
19
20 <ul>
21 {tasks.map((task) => (
22 <li key={task.id}>{task.title}</li>
23 ))}
24 </ul>
25 </section>
26 );
27};

Throwing errors

Fantastic, but what happens if our request isn't successful? Let's simulate this by making a request to an endpoint that doesn't exist.

1const response = await fetch("https://jsonplaceholder.typicode.com/nonexistantendpoint");

Oops, we get an error. These types of operations should be wrapped in a try catch block to handle such cases.

Let's see what that looks like:

1useEffect(() => {
2 const fetchTasks = async () => {
3 try {
4 const response = await fetch("https://jsonplaceholder.typicode.com/nonexistantendpoint");
5
6 const tasks = await response.json();
7
8 setTasks(tasks);
9 } catch (error) {
10 console.error("Something went wrong...");
11 }
12 };
13
14 fetchTasks();
15}, []);

Now, you may have noticed that our catch block is never executed, and our error message is never written to the console. This is because we need to manually throw an error if our request isn't ok (more on this next).

1useEffect(() => {
2 const fetchTasks = async () => {
3 try {
4 const response = await fetch("https://jsonplaceholder.typicode.com/nonexistantendpoint");
5
6 const tasks = await response.json();
7
8 if (!response.ok) {
9 throw new Error();
10 }
11
12 setTasks(tasks);
13 } catch (error) {
14 console.error("Something went wrong...");
15 }
16 };
17
18 fetchTasks();
19}, []);

Now, if our request doesn't respond with ok, then we know our request wasn't successful. This is a boolean which allows us to easily check if our request was successful by checking a range of HTTP status codes (2**).

The ok read-only property of the Response interface contains a Boolean stating whether the response was successful (status in the range 200-299) or not.

Additional state

You may have noticed that our application has no loading or error states, so let's add those now:

1const [loading, setLoading] = useState(false);
2const [error, setError] = useState(false);
3const [tasks, setTasks] = useState([]);

And let's set our loading state to true when we start our request, and error to true if we catch an error.

1useEffect(() => {
2 const fetchTasks = async () => {
3 try {
4 setLoading(true);
5
6 const response = await fetch("https://jsonplaceholder.typicode.com/nonexistantendpoint");
7
8 const tasks = await response.json();
9
10 if (!response.ok) {
11 throw new Error();
12 }
13
14 setTasks(tasks);
15 } catch (error) {
16 setError(true);
17 }
18 };
19
20 fetchTasks();
21}, []);

A common pattern is to set loading to false after we've set our tasks, and again once we've set our error. We can improve on this by adding a finally block which will run once everything else above it has executed.

1useEffect(() => {
2 const fetchTasks = async () => {
3 try {
4 setLoading(true);
5
6 const response = await fetch("https://jsonplaceholder.typicode.com/nonexistantendpoint");
7
8 const tasks = await response.json();
9
10 if (!response.ok) {
11 throw new Error();
12 }
13
14 setTasks(tasks);
15 } catch (error) {
16 setError(true);
17 } finally {
18 setLoading(false);
19 }
20 };
21
22 fetchTasks();
23}, []);

Loading and error handling

Awesome. Now, let's add our actual endpoint back in and handle these states with early returns. Let's see what that looks like:

1const App = () => {
2 const [loading, setLoading] = useState(false);
3 const [error, setError] = useState(false);
4 const [tasks, setTasks] = useState([]);
5
6 useEffect(() => {
7 const fetchTasks = async () => {
8 try {
9 setLoading(true);
10
11 const response = await fetch("https://jsonplaceholder.typicode.com/todos");
12
13 const tasks = await response.json();
14
15 if (!response.ok) {
16 throw new Error();
17 }
18
19 setTasks(tasks);
20 } catch (error) {
21 setError(true);
22 } finally {
23 setLoading(false);
24 }
25 };
26
27 fetchTasks();
28 }, []);
29
30 if (loading) {
31 return <p>Loading...</p>;
32 }
33
34 if (error) {
35 return <p>Error.</p>;
36 }
37
38 return (
39 <section>
40 <h1>Fetch API</h1>
41
42 <ul>
43 {tasks.map((task) => (
44 <li key={task.id}>{task.title}</li>
45 ))}
46 </ul>
47 </section>
48 );
49};

Great, now you'll notice a quick loading state whilst we make our request which then disappears once the tasks have been fetched. Also, if you simulate an endpoint which doesn't exist again, our error message shows.

Abstracting logic out to a custom hook

You may have noticed that we are beginning to pollute our component with a lot of code which could be kept elsewhere.

This is an ideal use case for writing our own custom hook. Custom hooks are often easy to write and are often an ideal solution to reusing and sharing logic between components.

Writing the hook

Let's create a new folder within src called hooks and create a file called useRequest.js.

Your hook should look like this:

1import { useState, useEffect } from "react";
2
3const useRequest = () => {
4 // ...
5};
6
7export default useRequest;

Now, let's port over our state and logic in to our new hook.

1const useRequest = () => {
2 const [loading, setLoading] = useState(false);
3 const [error, setError] = useState(false);
4 const [tasks, setTasks] = useState([]);
5
6 useEffect(() => {
7 const fetchTasks = async () => {
8 try {
9 setLoading(true);
10
11 const response = await fetch("https://jsonplaceholder.typicode.com/todos");
12
13 const tasks = await response.json();
14
15 if (!response.ok) {
16 throw new Error();
17 }
18
19 setTasks(tasks);
20 } catch (error) {
21 setError(true);
22 } finally {
23 setLoading(false);
24 }
25 };
26
27 fetchTasks();
28 }, []);
29};

As making GET requests to an endpoint is very common, let's make this hook generic and reusable.

We can start by refactoring a few things to be more generic:

1const useRequest = () => {
2 const [loading, setLoading] = useState(false);
3 const [error, setError] = useState(false);
4 const [data, setData] = useState([]);
5
6 useEffect(() => {
7 const fetchData = async () => {
8 try {
9 setLoading(true);
10
11 const response = await fetch("https://jsonplaceholder.typicode.com/todos");
12
13 const data = await response.json();
14
15 if (!response.ok) {
16 throw new Error();
17 }
18
19 setData(data);
20 } catch (error) {
21 setError(true);
22 } finally {
23 setLoading(false);
24 }
25 };
26
27 fetchData();
28 }, []);
29};

All we need to do now is make the endpoint (the URL) a parameter of our hook so we can pass in any endpoint and reuse our request hook throughout our application.

We will also need to make this a dependency of our effect, as we want it to re-run if we change the URL.

1const useRequest = (url) => {
2 const [loading, setLoading] = useState(false);
3 const [error, setError] = useState(false);
4 const [data, setData] = useState([]);
5
6 useEffect(() => {
7 const fetchData = async () => {
8 try {
9 setLoading(true);
10
11 const response = await fetch(url);
12
13 const data = await response.json();
14
15 if (!response.ok) {
16 throw new Error();
17 }
18
19 setData(data);
20 } catch (error) {
21 setError(true);
22 } finally {
23 setLoading(false);
24 }
25 };
26
27 fetchData();
28 }, [url]);
29};

Now, we need to return our loading, error and data states from our hook so we can consume them from within our component.

Your hook should now look like this:

1import { useState, useEffect } from "react";
2
3const useRequest = (url) => {
4 const [loading, setLoading] = useState(false);
5 const [error, setError] = useState(false);
6 const [data, setData] = useState([]);
7
8 useEffect(() => {
9 const fetchData = async () => {
10 try {
11 setLoading(true);
12
13 const response = await fetch(url);
14
15 const data = await response.json();
16
17 if (!response.ok) {
18 throw new Error();
19 }
20
21 setData(data);
22 } catch (error) {
23 setError(true);
24 } finally {
25 setLoading(false);
26 }
27 };
28
29 fetchData();
30 }, [url]);
31
32 return { loading, error, data };
33};
34
35export default useRequest;

Using the hook

Now, in our App component, let's import our hook and use it. If you're used to using hooks in React, this should look familiar.

We'll pass in our endpoint to fetch from, and receive our loading, error, and data states. Here we're renaming data to tasks in our destructuring assignment to keep the naming consistent.

1const App = () => {
2 const { loading, error, data: tasks } = useRequest("https://jsonplaceholder.typicode.com/todos");
3
4 if (loading) {
5 return <p>Loading...</p>;
6 }
7
8 if (error) {
9 return <p>Error.</p>;
10 }
11
12 return (
13 <section>
14 <h1>Fetch API</h1>
15
16 <ul>
17 {tasks.map((task) => (
18 <li key={task.id}>{task.title}</li>
19 ))}
20 </ul>
21 </section>
22 );
23};

Note: Remember to import the useRequest hook if you haven't already.

That's it. Now our App component is much cleaner.

Example

Here's what we've built:

React Query

If you find you need a more advanced feature set including things such as caching and optimistic updates, I'd suggest taking a look at React Query.

Fetch, cache and update data in your React and React Native applications all without touching any "global state".

React Query is extremely powerful and can help to solve many problems when making requests in React. We've only covered making simple GET requests, but it can help you with much more than that.

Conclusion

There are many different ways to approach this, and ways to improve the code in this article. I purposefully didn't cover every edge case or make every abstraction possible to make it easier to follow.

Hopefully you learnt a thing or two about fetching data in React, and I hope you found this article insightful.