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.
- The call to the
fetch()
method could return aTypeError
if there's some kind of network error. - If the value of the
response.ok
property isfalse
, it means the response was unsuccessful, so we're manually throwing anError
.
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:
- Update the
catch
block to include theerror
variable. - Call the
Error.prototype.toString()
method. - 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:
TypeError: Failed to fetch.
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:
Error: Tried to fetch your to-dos but encountered a network error.
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.