This post is part of our ‘The Miners’ Guide to Code Crafting’ series, designed to help aspiring developers learn and grow. Stay tuned for more!
Learning to consume APIs is an essential step for anyone looking to create dynamic and interactive React applications. In this practical guide, we will explore how to use the fetch
API and the async/await
syntax to perform HTTP requests. We’ll also demonstrate how to integrate these concepts directly into React components, whether to fetch data from an API, send information to the backend, or handle errors efficiently. Here, you’ll find everything you need to get started, including examples and tips to simplify API consumption in your next React project.
- Introduction
- A Bit About the Flow
- What is Fetch and Why Use It?
- Asynchronous Code with Async/Await
- How to Integrate Fetch in React Components
- Handling Request Concurrency
- Handling Loading
- Handling Errors
- Conclusion
Introduction
A topic that often confuses people outside the tech field is understanding the difference between a “website” and a web application. Well, the difference is simple: a static website consists of pages that do not change—the content is always the same. A web application, on the other hand, is typically dynamic, meaning it can change based on user interactions.
To make an application dynamic, it’s necessary to establish a way for data to communicate between the client and the server of the application, whether to retrieve or modify data. These requests are usually made through APIs (Application Programming Interface), which are sets of rules and standards designed to enable communication between users and the application.
A well-structured and documented API can receive and interpret requests from any device, whether it’s a computer, a smartphone, or even an embedded system, as long as the devices adhere to the standards established by the API for communication.
To help developers structure their APIs better, various standards and technologies such as REST, SOAP, GraphQL, and countless other options assist in organizing endpoints, headers, and payloads to enhance security in data mutation and ensure consistent information in their operations.
However, today’s topic is not a comparison of the main technologies for building an API but rather a tutorial on how to consume these APIs in React applications. For this, we will dive into how the fetch
method and async/await
syntax are used to communicate with an API, their differences, pros and cons, and how to efficiently integrate them into React components. This includes managing the request state in cases of delays or even errors in the request/response process.
A Bit About the Flow
Source: What is API: How APIs Work and What Types Are Used by DHTMLX
The image above represents the flow of a typical HTTP request, where the client, through the web application (or another device with access to the endpoint), makes a request to the API. The API is then interpreted by the application server, which queries or performs mutations on the database according to the received information and returns the response to the client.
Although this is a relatively simple flow, several factors that can cause issues on both sides. Some examples include: slow requests, request concurrency, request format, authentication/authorization to access requested data, delays in processing responses, large data volumes, connection failures with the database/external services, and many others. All these aspects directly affect the user experience with the application.
As developers, we can use several tools to improve the user experience, such as loading states, user-friendly error messages, data caching, database query optimization, data typing for documentation, and many other approaches. Before we dive into each case, let’s understand a bit more about how to make a request to an API and how it behaves.
What is Fetch and Why Use It?
The fetch()
method (Fetch API) is used to make a request and fetch a resource, whether it’s an image, an audio file, a web page, or even a binary file. This method requires at least one mandatory argument, which is the path to the resource you want to retrieve. This argument is known as the ‘Request‘.
The request object can either be a simple string, representing the path to the resource you want to access, or an instance of a Request
object that contains additional information, such as the request method (GET, POST, DELETE, …), headers, which are typically used to send authentication and origin information, the request body, where the data to be sent to the server is defined, and it also allows sending other properties with their respective responsibilities. In a simplified form, just the resource URL is enough for our example, but it’s worth exploring the documentation to understand all the possibilities.
Once the fetch
method is called, it returns a Promise
that resolves to the Response
of the request, whether successful or not. Therefore, it is highly recommended to use the then
method to handle the response and catch
to handle any potential errors that might occur during the request.
Once the fetch
method is called, it returns a Promise
that resolves to the Response
of the request, whether successful or not. Therefore, it is highly recommended to use the then
method to handle the response and catch
any potential errors that might occur during the request.
fetch('https://api.example.com/data')
.then((response) => console.log(response.json()))
.catch((error) => console.error('Erro:', error))
In this example, we are consuming a fictional API in a purely representational way. However, in a real scenario, this would hardly be done like this, as requests to robust APIs require more information to provide greater security and control over the data being sent and received.
A frequent and trivial example to understand the concept of a private application that only accepts internal requests (meaning they need to come from the same domain to be considered valid) is requiring requests to include an authorization token from the user making the request.
As the application grows, providing all the necessary data for every request can become cumbersome. But, to assist with this, fetch
allows an optional second argument, which consists of an initialization object. This object enables you to define additional settings for the request being made.
Next, let’s look at two examples of fetch
with and without the initialization object:
const myRequest = new Request('https://api.example.com/data', {
method: 'POST',
cache: 'default',
body: '{"foo":"bar"}',
credentials: 'include',
mode: 'cors',
redirect: 'follow',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://example.com'
}
})
fetch(myRequest)
.then((response) => console.log(response.json()))
.catch((error) => console.error('Erro:', error))
Notice that in the example above, all the relevant configurations for the request are declared directly in the instance of the Request
object. However, not all developers on the project need to know exactly how the request should be made. Therefore, it’s possible to abstract these configurations into an initialization object, which can be passed as the second argument to the fetch
method. This way, it’s possible to isolate the resources related only to the request from the information relevant to the API as a whole. Let’s look at an example of how to do this:
const myRequest = new Request('https://api.example.com/data', {
method: 'POST',
body: '{"foo":"bar"}',
cache: 'default'
})
const myInit = {
credentials: 'include',
mode: 'cors',
redirect: 'follow',
headers: {
'Content-Type': 'application/json',
'Origin': 'https://example.com'
}
}
fetch(myRequest, myInit)
.then((response) => console.log(response.json()))
.catch((error) => console.error('Erro:', error))
In this example, myInit
is being defined in the same component. However, this configuration could be abstracted elsewhere in the application and used when necessary to compose more complex requests. It is important to remember that, when using the fetch
method, it is still necessary to manually handle the response of the request. This means converting the response body to the desired format, handling errors, and dealing with device incompatibilities.
To help with this process, it’s recommended to use libraries that make request handling easier, such as Axios, which is a very popular and widely used library for making HTTP requests. Below is a comparative table between fetch
and axios
:
Aspect | Fetch | Axios |
---|---|---|
Installation | Native, no extra dependencies required | Requires installation (npm/yarn) |
Size | Lightweight (built-in to the browser) | Slightly larger due to extra features |
HTTP Errors | Must be handled manually | Handled automatically |
Header Configuration | Manual | More intuitive |
JSON Parsing | Must be called explicitly | Automatic |
Interceptors | Not available | Native support |
Compatibility | Only modern browsers | Wider, including older browsers |
State Management | Manual | Native support |
It’s important to remember that, just like Axios, there are other libraries designed to facilitate request management.
Now that we know how to correctly invoke the fetch()
method, it’s important to understand a little bit about its behavior. The fetch()
method is asynchronous, meaning it doesn’t block the execution of the code, allowing the code to continue running while the request is being processed by the server. However, this is not always expected, and that’s where async/await
comes in.
Asynchronous Code with Async/Await
async/await
is a syntax introduced in JavaScript (ES2017) to work with Promises
in a simpler and more readable way, compared to using methods like .then()
and .catch()
directly.
async
: marks the function as asynchronous, allowing the use ofawait
within it, and makes the function return aPromise
.await
: pauses the execution of the function until the Promise is resolved or rejected, returning the resolved value or throwing the corresponding error.
This approach provides a structure that looks like synchronous code, making it easier to understand and maintain complex flows.
Consider, for example, an application that depends on a request to display data, such as a weather forecasting system that relies on user input like location and the date they want to check for weather data of that specific location and date.
Also, consider another feature of this same application, like a notification system for climate alerts based on historical data, which doesn’t necessarily depend on user action to be displayed and can be processed asynchronously.
A function marked with async
always returns a Promise
, even if there’s no explicit Promise
returned. The value returned from it will be automatically wrapped in a Promise
.
async function example() {
return 'Hello, World!'
}
example().then(console.log) // "Hello, World!"
In the case of await
, JavaScript waits for the resolution of the Promise
before continuing the execution of the rest of the function. This is useful for simplifying the handling of chained Promises
, which occurs when multiple requests need to be processed to display the correct information.
Another advantage is the ability to manage the state, in other words, knowing when the request is being processed (loading state), when it has been completed, and if there was an error. These features are essential for providing a good user experience and need to be manually managed when using fetch
.
Let’s see how we can rewrite the previous example implemented with fetch
using async/await
:
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data')
console.log(await response.json())
} catch (error) {
console.error('Erro:', error)
}
}
fetchData()
In this example, the fetchData
function is marked as asynchronous with async
, allowing the use of await
to wait for the resolution of the Promise returned by fetch
. The try...catch
block is used to capture errors in the request and handle them appropriately.
How to Integrate Fetch in React Components
With the introduction of Hooks, it became possible to consume APIs directly in functional components, without the need for explicit lifecycle methods as was done in class components, resulting in cleaner and more concise code. Let’s see the following example:
import { useEffect, useState } from 'react'
const App = () => {
const [data, setData] = useState([])
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data')
setData(await response.json())
} catch (error) {
console.log('Erro:', error)
}
}
fetchData()
}, [])
return (
<div>
<h1>Data:</h1>
<p>{JSON.stringify(data)}</p>
</div>
)
}
export default App
An important point to highlight here is that caution is needed when fetching data inside a useEffect
. This is because we don’t want the request to be made every time the component gets rendered, but rather only once, when the component is mounted. To achieve this, we pass an empty array as the second argument to useEffect
, indicating that the effect function should only run once, when the component is initialized.
If the request needs to be made again, it’s possible to specify one or more variables in the dependency array, causing the effect function to run again whenever any of these variables change. But be careful, it’s important to understand reference comparisons versus value comparisons.
import { useEffect, useState } from "react";
const ExampleComponent = () => {
const [state, setState] = useState({ value: 1 });
useEffect(() => {
console.log("Effect triggered by change in the entire object.");
}, [state])
useEffect(() => {
console.log("Effect triggered by a change in the object's 'value' attribute.");
}, [state.value])
return (
<div>
<button onClick={() => setState({ value: state.value })}>
Update object (same value, new reference)
</button>
<button onClick={() => setState({ value: state.value + 1 })}>
Increase value (new value, new reference)
</button>
</div>
)
}
export default ExampleComponent;
In the example above, we have two useEffect
hooks that are triggered when the state
changes. However, the first useEffect
is triggered every time the state
is changed, while the second useEffect
is only triggered when the value
attribute of the state
changes.
Handling Request Concurrency
In the first example from the previous section, we can see that we are basically querying and rendering the data returned by this API directly. However, imagine the data is paginated, returning 10 records at a time. In this case, if the user quickly switches between pages multiple times, the application will make several simultaneous requests, which could lead to concurrency issues because the requests are made asynchronously.
To handle this, there are several approaches, with the most common one being the use of a state to indicate whether the request is still being processed or not. Based on this state, it’s possible to prevent new requests by disabling the button if there is an ongoing request. Alternatively, if you want to allow the user to send multiple requests, you can validate the already processed responses while the previous request is still being resolved. This means that despite the response from the last request being applied and rendered to the user, the results from the previous requests would be discarded. Let’s see how we can implement these cases:
import { useEffect, useState } from 'react'
const App = () => {
const [data, setData] = useState([])
const [page, setPage] = useState(1)
useEffect(() => {
const fetchData = async () => {
let ignore = false // used as a flag to ignore the response if the component is unmounted
try {
const response = await fetch('https://api.example.com/data')
if (!ignore) {
setData(await response.json())
}
} catch (error) {
console.log('Erro:', error)
}
return () => { ignore = true } // cleanup function
}
fetchData()
}, [page])
return (
<div>
<h1>Data:</h1>
<p>{JSON.stringify(data)}</p>
</div>
)
}
export default App
With the code described above, when the user makes multiple requests, the useEffect
cleanup function will be invoked when the component is re-rendered, and the ignore
flag will be set to true
, causing the previous request to be ignored during its execution and allowing the new request to be completed successfully. This way, even if the user changes the pagination multiple times, all requests will be processed, but only the last one will have its value updated and rendered correctly.
It’s important to note that there are other possible approaches for handling request concurrency, one of which is using debounce. This technique delays the execution of a function until a certain amount of time has passed without the function being called again. If you want to learn more about debounce, I recommend reading the post CodeTips#10: Throttle and Debounce.
In the same implementation model, another issue that can occur is that while the request is being processed, the user will still see the old data instead of the actual information, which in this case would be better represented by a loading screen.
Another important detail about this implementation is that, although the data is rendered as expected, the requests are still made even though their responses are discarded. This could be a problem for large-scale applications. A better way to handle this issue is by using the AbortController
, which is an API that allows canceling asynchronous requests. By using an AbortController, it’s possible to cancel the previous request before making a new one, thus preventing unnecessary requests. Let’s see how we can implement this case:
import { useEffect, useState } from 'react'
const App = () => {
const [data, setData] = useState([])
const [page, setPage] = useState(1)
useEffect(() => {
const controller = new AbortController()
const signal = controller.signal
const fetchData = async () => {
try {
const response = await fetch(`https://api.example.com/data?page=${page}`, { signal })
setData(await response.json())
} catch (error) {
if (error.name !== 'AbortError') {
console.log('Erro:', error)
}
}
}
fetchData()
return () => {
controller.abort()
}
}, [page])
return (
<div>
<h1>Data:</h1>
<p>{JSON.stringify(data)}</p>
<button onClick={() => setPage((prev) => prev + 1)}>Next Page</button>
</div>
)
}
export default App
By replicating the previous use case, the previous request will be canceled before making a new one, thus preventing the application from making unnecessary requests to the server.
Handling Loading
To handle the loading state, we can add an additional state to indicate whether the request is being processed or not. Based on this state, we can render a loading message instead of the previous data.
import { useEffect, useState } from 'react'
const App = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data')
setData(await response.json())
} catch (error) {
console.log('Erro:', error)
} finally {
setLoading(false)
}
}
fetchData()
}, [])
if (loading) return <p>Loading...</p>
return (
<div>
<h1>Data:</h1>
<p>{JSON.stringify(data)}</p>
</div>
)
}
export default App
With the .finally
block, regardless of whether the result was successful or not, the setLoading
function will be called, updating the loading state to false
, removing the loading message after the request has been processed, and consequently, rendering the data correctly, whether in case of success or failure.
Handling Errors
Errors are inevitable when consuming APIs. Nevertheless, as developers, it’s important to keep the application running properly, responding with appropriate failures instead of crashing the app. When dealing with APIs, it’s important to understand both HTTP status codes (which are numerical codes that indicate the result of a request) and the common sense of who will receive, read, and interpret the error message. This means considering whether the message is for the end user or another developer, and, if necessary, handling the error code to provide a user-friendly message, clearly indicating why the user’s request was rejected or what caused the failure.
For the following example, let’s assume the API will return an error with code 404, indicating that the requested resource was not found; a case where the user is not authorized to access the resource, returning an error 401; and a generic case, where the API returns a 500 error, which corresponds to an ‘Internal Server Error’.
import { useEffect, useState } from 'react'
const App = () => {
const [data, setData] = useState([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<String | null>(null)
const handleError = (status: number) => {
switch (status) {
case 404:
setError('Resource not found.')
break
case 401:
setError('Unauthorized. Please, sign in and try again.')
break
case 500:
setError('Internal Error. Please, try again later.')
break
default:
setError('Something went wrong. Please, try again later.')
}
}
useEffect(() => {
const fetchData = async () => {
try {
const response = await fetch('https://api.example.com/data')
if (response.status !== 200) {
handleError(response.status)
return
}
setData(await response.json())
} catch (e) {
setError('API is offline. Please, try again later.')
} finally {
setLoading(false)
}
}
fetchData()
}, [])
if (loading) return <p>Loading...</p>
if (error) return <p>Erro: {error}</p>
return (
<div>
<h1>Data:</h1>
<p>{JSON.stringify(data)}</p>
</div>
)
}
export default App
By managing errors, our component will be much more user-friendly, providing real-time feedback to the end user by displaying loading messages, error notifications, or correctly rendered data.
Conclusion
In this blog post, we learned the basics of the HTTP request flow and how information can be exchanged between the client and server by consuming and modifying data through APIs in React applications.
We used the fetch
method and async/await
, integrating them into React components in a practical way, managing the request state, whether in the loading state, handling concurrent requests, or dealing with errors. We also explored how to handle request concurrency in different ways, either with a flag to ignore the result or by aborting the previous request.
We want to work with you. Check out our "What We Do" section!