Introduction to API Fetching in React
Modern React applications frequently communicate with APIs to retrieve information from external services or backend servers. Examples include loading user profiles, displaying product catalogs, retrieving dashboard statistics, and showing articles from a content management system.
React does not provide a dedicated built-in API-fetching function. Instead, you can use browser APIs such as fetch(), combine them with React Hooks when appropriate, and choose a data-fetching library when the application requires caching, request deduplication, or more advanced synchronization.
In this guide, you will learn how to request API data, manage loading and error states, handle component cleanup, and organize reusable data-fetching logic. Understanding React state and useEffect will help you follow these examples.
Understanding the Fetch API
The browser's Fetch API sends HTTP requests and returns a Promise that resolves to a Response object. You can use it to make GET, POST, PUT, PATCH, and DELETE requests.
A basic GET request looks like this:
async function getUsers() {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
if (!response.ok) {
throw new Error("Unable to fetch users");
}
const users = await response.json();
return users;
}
The fetch() function sends the request. The response.ok property indicates whether the HTTP status is in the successful range, typically 200–299. Calling response.json() parses the response body as JSON.
An important detail is that Fetch generally does not reject its Promise for HTTP errors such as 404 or 500. You should check response.ok and handle unsuccessful status codes explicitly.
Fetching Data with useEffect
The useEffect Hook is one way to synchronize a component with an external system, such as an API request. For straightforward client-side fetching, an effect can start a request when a component mounts and update state when the response arrives.
import { useEffect, useState } from "react";
function UsersList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
async function loadUsers() {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users"
);
if (!response.ok) {
throw new Error("Failed to load users");
}
const data = await response.json();
setUsers(data);
} catch (err) {
setError(
err instanceof Error ? err.message : "Unexpected error"
);
} finally {
setLoading(false);
}
}
loadUsers();
}, []);
if (loading) {
return <p>Loading users...</p>;
}
if (error) {
return <p role="alert">{error}</p>;
}
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
export default UsersList;
This example maintains three pieces of state: the retrieved users, the loading status, and any error message. The component displays a loading message while the request is pending, an error when the request fails, or the list after a successful response.
The empty dependency array means the effect does not rerun because of ordinary state or prop changes. However, React Strict Mode can run an extra effect setup and cleanup cycle in development to help identify unsafe effects. The request should therefore be safe to start more than once, or be designed with appropriate cancellation and caching.
Managing Loading, Success, and Error States
API requests can take time, return no data, or fail because of network problems or server errors. A reliable interface should represent these different states clearly.
- Loading: Show progress feedback while the request is pending.
- Success: Display the retrieved information or an appropriate empty state.
- Error: Explain that the request failed and provide a recovery action when possible.
- Empty result: Tell the user when the request succeeded but returned no records.
For example, a product page should distinguish between a failed API request and a successful request that returned an empty product list. These outcomes require different messages and recovery paths.
Keep the state transitions consistent. Reset the error when starting a new request, set loading to true while the request is active, and handle both successful and unsuccessful responses.
Using Async and Await with Fetch
The async and await syntax makes asynchronous code easier to read. An async function returns a Promise, and await pauses that function until a Promise settles without blocking the browser's main thread.
async function fetchProduct(productId) {
try {
const response = await fetch(
"https://api.example.com/products/" + productId
);
if (!response.ok) {
throw new Error("Unable to load product");
}
return await response.json();
} catch (error) {
console.error("Product request failed:", error);
throw error;
}
}
The function checks the HTTP status, parses the JSON response, and propagates errors to its caller. The example URL is illustrative; replace it with an endpoint provided by your application or service.
Throwing an error after logging it allows the caller to decide whether to display an error message, retry the request, or perform another action. Avoid silently swallowing errors when the caller needs to know that the request failed.
Sending POST Requests to an API
GET requests commonly retrieve data, while POST requests often send data to create a resource or trigger an operation. To send JSON, specify the request method, content type, and serialized body.
async function createUser(userData) {
const response = await fetch(
"https://api.example.com/users",
{
method: "POST",
headers: {
"Content-Type": "application/json"
},
body: JSON.stringify(userData)
}
);
if (!response.ok) {
throw new Error("Unable to create user");
}
return response.json();
}
The JSON.stringify() method converts the JavaScript object into a JSON string suitable for the request body. The server must support the endpoint and accept the submitted format.
Call mutation functions from an event handler, form action, or another deliberate operation rather than automatically triggering them from an effect that may run more than once. Server-side validation and authorization are essential even when the client validates the submitted data.
Cleaning Up Requests with AbortController
A request may still be in progress when a component unmounts or when the request parameters change. The browser's AbortController lets you cancel a fetch request that is no longer needed.
import { useEffect, useState } from "react";
function UserDetails({ userId }) {
const [user, setUser] = useState(null);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
async function loadUser() {
setUser(null);
setError("");
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users/" + userId,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error("Unable to load user");
}
const data = await response.json();
setUser(data);
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return;
}
setError(
err instanceof Error ? err.message : "Unexpected error"
);
}
}
loadUser();
return () => {
controller.abort();
};
}, [userId]);
if (error) {
return <p role="alert">{error}</p>;
}
if (!user) {
return <p>Loading user...</p>;
}
return <h2>{user.name}</h2>;
}
export default UserDetails;
The effect starts a request for the current userId and aborts it during cleanup. When the ID changes, React cleans up the previous effect before running the next setup.
Aborting a request helps prevent unnecessary work and stale responses. If your fetching logic cannot be cancelled, use an appropriate cleanup or stale-result guard so an outdated request cannot overwrite newer data.
Avoiding Race Conditions
A race condition can occur when multiple requests run concurrently and finish in a different order from the order in which they started. For example, a user might select one profile and then quickly select another. If the first request finishes last, it could overwrite the newer profile data.
Using AbortController when appropriate, tracking request identifiers, or using a data-fetching library can help prevent stale results from being displayed.
When building a search interface, consider debouncing user input so the application does not send a request for every keystroke. Combine that with cancellation or stale-result protection to keep results consistent with the latest search term.
Creating a Reusable Data-Fetching Hook
When multiple components share the same fetching behavior, a custom Hook can centralize loading, error, and response handling. Custom Hooks help reuse logic while allowing each component to manage its own stateful invocation.
import { useEffect, useState } from "react";
function useUsers() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState("");
useEffect(() => {
const controller = new AbortController();
async function loadUsers() {
try {
const response = await fetch(
"https://jsonplaceholder.typicode.com/users",
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error("Failed to load users");
}
setUsers(await response.json());
} catch (err) {
if (err instanceof Error && err.name === "AbortError") {
return;
}
setError(
err instanceof Error ? err.message : "Unexpected error"
);
} finally {
if (!controller.signal.aborted) {
setLoading(false);
}
}
}
loadUsers();
return () => controller.abort();
}, []);
return { users, loading, error };
}
export default function UsersPage() {
const { users, loading, error } = useUsers();
if (loading) return <p>Loading...</p>;
if (error) return <p role="alert">{error}</p>;
return (
<ul>
{users.map(user => (
<li key={user.id}>{user.name}</li>
))}
</ul>
);
}
The useUsers Hook owns the fetching logic and returns the state needed by its consumer. The page decides how to display that state.
A custom Hook does not automatically share cached results between separate component instances. Each invocation normally has its own state and effect. Shared caching, deduplication, and background revalidation require additional logic or a suitable data-fetching library.
For more information about reusable Hooks, see the React Custom Hooks guide.
When to Use a Data-Fetching Library
Using fetch() and useEffect is useful for understanding API requests and handling straightforward cases. Larger applications often need additional capabilities, including caching, request deduplication, automatic retries, pagination, background refresh, and synchronization between components.
Libraries such as TanStack Query and SWR provide tools for managing server state and reducing the amount of manual request-management code. React Router also supports route-level data loading patterns in applications that use its data APIs.
Choose a solution based on your application's needs. Avoid adding a library for a small one-off request when a simple fetch is sufficient, but do not rebuild caching and synchronization infrastructure by hand when a mature library already addresses the problem.
Common API Fetching Mistakes
- Ignoring HTTP errors: Check
response.okbecause Fetch does not automatically reject for HTTP 404 or 500 responses. - Skipping loading and error states: Display appropriate feedback instead of leaving the interface blank during a request.
- Ignoring effect dependencies: Include reactive values used by an effect in its dependency array, or restructure the effect to avoid unnecessary dependencies.
- Leaving requests unmanaged: Abort obsolete requests or otherwise guard against stale results when necessary.
- Assuming JSON parsing always succeeds: Invalid or empty response bodies can cause parsing errors, so handle failures appropriately for the endpoint's response format.
- Fetching repeatedly without a reason: Review dependencies and component behavior. Use caching or deduplication when repeated requests are unnecessary.
- Exposing secrets in frontend code: Browser code is visible to users. Keep private API keys and privileged credentials on a secure server.
Best Practices for React API Fetching
- Separate API request logic from presentation when that improves reuse and testing.
- Check HTTP status codes before treating a response as successful.
- Represent loading, success, empty, and error states clearly.
- Use
AbortControlleror stale-result guards when requests can become obsolete. - Keep effect dependencies accurate and follow React's Hooks rules.
- Validate and safely handle data received from external services.
- Use environment configuration for public API base URLs, but never treat frontend environment variables as secret storage.
- Use a data-fetching library when caching, retries, pagination, or shared server state becomes complex.
- Test successful responses, HTTP failures, network errors, and cleanup behavior.
Conclusion
API fetching allows React applications to display current information from backend services and external APIs. The browser's Fetch API provides the underlying request mechanism, while React state and effects can coordinate loading, successful responses, and errors.
Reliable implementations check HTTP status codes, manage loading and error states, cancel obsolete requests when appropriate, and avoid stale results. For repeated or complex data-fetching requirements, reusable Hooks or dedicated libraries can simplify the implementation.
With these fundamentals, you can build React interfaces that communicate with APIs predictably and provide users with clear feedback throughout the request lifecycle.