Last week, we learned how to achieve composition in React using the special .children prop. Today, we'll learn how to render API data in React while properly handling errors. We'll make use of the <Item> component we created last week.

The API data

We'll be using the /todos endpoint from JSONPlaceholder for this example. It's a free fake API for testing and prototyping. We'll pass in a value for the userId query string parameter to get the to-dos for a single user:

https://jsonplaceholder.typicode.com/todos?userId=1

The API returns an array of objects with the following structure. I've only included the first three objects for brevity:

[
{
"userId": 1,
"id": 1,
"title": "delectus aut autem",
"completed": false
},
{
"userId": 1,
"id": 2,
"title": "quis ut nam facilis et officia qui",
"completed": false
},
{
"userId": 1,
"id": 3,
"title": "fugiat veniam minus",
"completed": false
}
]

The array represents a list of to-dos. We'll be using the values of the .id, .title, and .completed properties.

Handling the conditional logic

Before we actually fetch the data, it's helpful to consider what we want the UI to look like based on the state of our application. Let's create two pieces of state: todos (with an initial value of null) and error (with an initial value of false).

function App() {
const [todos, setTodos] = useState(null);
const [error, setError] = useState(false);
}

Handling errors

If we encounter an error when we fetch the data, we'll set the error state to true. Let's add a conditional statement to handle this. We'll return a virtual DOM node that represents a paragraph element with an error message:

function App() {
const [todos, setTodos] = useState(null);
const [error, setError] = useState(false);

if (error) {
return (
<p className="error">Error: There was a problem fetching your to-dos.</p>
);
}
}

Handling the "loading" state

If we haven't encountered an error but the API request hasn't been fulfilled, we want to show a "loading" message. Let's add another conditional statement to handle this. Because the initial value of todos is null, we can use the logical NOT (!) operator to check if the value is falsy:

function App() {
const [todos, setTodos] = useState(null);
const [error, setError] = useState(false);

if (error) {
return (
<p className="error">Error: There was a problem fetching your to-dos.</p>
);
}

if (!todos) {
return <p>Fetching your to-dos...</p>;
}
}

Rendering the data

If we haven't encountered an error and the API request has been fulfilled, that means we have access to the data. Let's return a virtual DOM node representing an unordered list. This is where we'll use the <Item> component we created last week:

function App() {
const [todos, setTodos] = useState(null);
const [error, setError] = useState(false);

if (error) {
return (
<p className="error">Error: There was a problem fetching your to-dos.</p>
);
}

if (!todos) {
return <p>Fetching your to-dos...</p>;
}

return (
<ul role="list">
{todos.map((todo) => (
<Item done={todo.completed} key={todo.id}>
{todo.title}
</Item>
))}
</ul>
);
}

Fetching the data

With our conditional logic in place, we need to fetch the data and update our state. We can do this using the effect hook. The effect hook allows us to run a side effect once our component has been rendered. Fetching data is a type of side effect.

useEffect(() => {
// Fetch the data and update the state...
}, []);

Understanding the effect hook

Before we go any further, it's important that we understand how the effect hook works. The first argument is a callback function in which we run our side effect. The optional second argument is called the dependency array.

The dependency array is a list of values that the effect depends on. This means all values from the component scope (such as props and state) that change over time and that are used by the effect. When a value inside the dependency array changes, the side effect will run again. If any other piece of state that the effect doesn't depend on gets updated, the effect won't run. It's a performance optimization.

Because we only want to fetch the data once, we're passing in an empty array:

If you want to run an effect and clean it up only once (on mount and unmount), you can pass an empty array ([]) as a second argument. This tells React that your effect doesn’t depend on any values from props or state, so it never needs to re-run.

Writing the effect

With that in mind, we can write our effect. Let's declare an asynchronous function, getTodos(), in which we'll fetch the data and update the state. We'll invoke it inside the effect too.

useEffect(() => {
getTodos();

async function getTodos() {}
}, []);

Simple error handling

We'll use a try...catch statement inside the getTodos() function. For a simple error-handling experience, we can omit the error variable from the catch block. Because we're rendering a generic error message regardless of the actual error that occurred, we don't need the variable. We just need to set the error state to true.

useEffect(() => {
getTodos();

async function getTodos() {
const API = "https://jsonplaceholder.typicode.com/todos?userId=1";

try {
const response = await fetch(API);
if (!response.ok) throw new Error();

const data = await response.json();
setTodos(data);
} catch {
setError(true);
}
}
}, []);

More specific error handling

There are two kinds of error that might occur, so we can be more specific if we want.

  1. The call to the fetch() method could return a TypeError if there's some kind of network error.
  2. If the value of the response.ok property is false, it means the response was unsuccessful, so we're manually throwing an Error.

We need to change the initial value of our error state. We'll start with null instead of false. If we encounter an error, we'll update the error state with the error message as a string. We'll render that string instead of the hard-coded error message.

const [error, setError] = useState(null);

if (error) {
return <p className="error">{error}</p>;
}

Let's update our call to the Error() constructor for an unsuccessful response. We'll add a simple message: "Unsuccessful response".

Then we'll:

  1. Update the catch block to include the error variable.
  2. Call the Error.prototype.toString() method.
  3. Update the error state with the return value.
useEffect(() => {
getTodos();

async function getTodos() {
const API = "https://jsonplaceholder.typicode.com/todos?userId=1";

try {
const response = await fetch(API);
if (!response.ok) throw new Error("Unsuccessful response");

const data = await response.json();
setTodos(data);
} catch (error) {
setError(error.toString() + ".");
}
}
}, []);

This will give us a different message depending on the error that occurred:

  1. TypeError: Failed to fetch.
  2. Error: Unsuccessful response.

To test the TypeError, try changing jsonplaceholder to jasonplaceholder. This works because jasonplaceholder.typicode.com isn't a real website. The request will fail because it encountered a network error.

To test the generic Error, try changing todos to todoes. This works because of the way JSONPlaceholder is set up. If you send a request to an invalid endpoint, the server sends back a 404 error with an empty JSON object ({}) as the response body. The response comes back just fine, but it's not a successful response.

Better error messages

This is fine, but these error messages aren't very user-friendly. How is a user supposed to know the difference between an Error and a TypeError? Let's make the error messages more human-readable.

When we throw our Error, let's change the message to "the response was unsuccessful". Then we'll declare a message variable inside the catch block. If the error is an instance of the TypeError class, we'll set the value of the message variable to the string "encountered a network error". Otherwise we'll use the value of the error object's Error.prototype.message property, which will be our custom message. Finally, we'll update the error state.

useEffect(() => {
getTodos();

async function getTodos() {
const API = "https://jsonplaceholder.typicode.com/todos?userId=1";

try {
const response = await fetch(API);
if (!response.ok) throw new Error("the response was unsuccessful");

const data = await response.json();
setTodos(data);
} catch (error) {
let message;

if (error instanceof TypeError) {
message = "encountered a network error";
} else {
message = error.message;
}

setError(`Error: Tried to fetch your to-dos but ${message}.`);
}
}
}, []);

Now the error messages will be a bit more human-readable:

  1. Error: Tried to fetch your to-dos but encountered a network error.
  2. Error: Tried to fetch your to-dos but the response was unsuccessful.

However, these errors are still similar from the user's point of view. What's the difference between a network error and an unsuccessful response? Aren't they the same thing? Because of this, I don't think the distinction improves the user experience. I'm going to stick with the simple error handling for this demo.

Summary

That's all, folks! Here's the component in its entirety:

function App() {
const [todos, setTodos] = useState(null);
const [error, setError] = useState(false);

useEffect(() => {
getTodos();

async function getTodos() {
const API = "https://jsonplaceholder.typicode.com/todos?userId=1";

try {
const response = await fetch(API);
if (!response.ok) throw new Error();

const data = await response.json();
setTodos(data);
} catch {
setError(true);
}
}
}, []);

if (error) {
return (
<p className="error">Error: There was a problem fetching your to-dos.</p>
);
}

if (!todos) {
return <p>Fetching your to-dos...</p>;
}

return (
<ul role="list">
{todos.map((todo) => (
<Item done={todo.completed} key={todo.id}>
{todo.title}
</Item>
))}
</ul>
);
}

Demo of rendering API data in React.

This example doesn't handle HTML strings. For example, if the todo.title property contained an HTML string, the special HTML characters (e.g. < and >) would be rendered as plain text. Next week, we'll learn what to do when the API data contains an HTML string that you want to render.