Static markup only gets you so far β real apps need to talk to servers to fetch users, posts, or products. This lesson covers calling APIs from a React component with useEffect and fetch, and safely handling the loading, error, and success states that come with any network request.
1APIs in React
React components render UI from local state, but real applications need data that lives on a server β user lists, tweets, product catalogs. To fetch that data, we combine the useEffect hook, which lets a component reach outside itself after rendering, with the browser's native fetch API to make the actual network request.
This pairing is the standard pattern for pulling in remote data: useEffect decides when the request happens, and fetch (or a library like Axios) does the actual work of talking to the server.
// Example
console.log("Running React...");Network Requests
Talking to the world.
2Setting up State
Before making a request, a component needs somewhere to put the result. A robust data-fetching component tracks three separate pieces of state: the actual data returned by the server, a loading boolean, and an error message string. This is often called the '3 Core States' pattern.
Tracking all three separately β rather than just the data β is what lets the UI show a spinner while waiting, an error message if the request fails, and the real content only once it has safely arrived.
function UserList() {
const [users, setUsers] = useState([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
return <div>Loading...</div>;
}Local State
Preparing the variables.
3The useEffect Hook
useEffect is what actually triggers the network request. The critical detail is the empty dependency array [] passed as its second argument β it tells React to run the effect exactly once, right after the component first appears on screen, rather than on every re-render.
Getting the dependency array wrong here is a classic source of bugs: omit it entirely and the effect re-runs after every render, potentially firing the same fetch in an infinite loop.
useEffect(() => {
// Fetch logic will go here
}, []);Lifecycle
Running on Mount.
4Using Async/Await
The function passed directly to useEffect cannot itself be async. The workaround is to define a separate async function inside the effect and call it immediately, which lets the fetch logic use clean await syntax instead of chaining .then() calls.
This inner-function pattern is the standard way to combine useEffect with asynchronous code, and it's what you'll see in almost every real-world data-fetching component.
useEffect(() => {
const getData = async () => {
try {
const res = await fetch('/users');
const data = await res.json();
setUsers(data);
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
getData();
}, []);Async / Await
Clean Promises.
5Error Handling & Finally
Networks fail and servers crash, so API calls should always be wrapped in a try/catch block. The catch block is where you set the error state when the request fails, capturing the failure instead of letting it crash the component.
The finally block runs regardless of whether the request succeeded or failed, which makes it the ideal place to turn off the loading spinner β it guarantees the loading state is cleared no matter what happened.
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error}</div>;
return (
<ul>
{users.map(u => <li key={u.id}>{u.name}</li>)}
</ul>
);Try / Catch
Handling Failure.
6Conditional Rendering: Loading
With the three states in place, the UI itself is built with guard clauses. Before returning the main content, the component checks the loading state first, and if it's true, returns early with a loading indicator instead of falling through to code that expects data to already exist.
This early return is what prevents React from trying to render a list or access properties on data that hasn't arrived from the server yet.
<h1>API Master Unlocked!</h1>Guard Clauses
Loading UI.
7Step-by-Step Breakdown
APIs in React. Static websites are boring. To build dynamic applications, React needs to talk to external servers (APIs) to fetch data like users, tweets, or products. We do this using the useEffect hook and the browser's native fetch API.
Setting up State. Before we fetch, we need a place to put the data. A robust component tracks three states: the actual data, a loading boolean, and an error message string. This is known as the '3 Core States' pattern.
The useEffect Hook. We use useEffect to trigger the network request. The most critical part is the empty dependency array []. This tells React: 'Run this effect exactly ONCE, right after the component appears on the screen'.
Why must we pass an empty array [] as the second argument to useEffect when making our initial data fetch?
- βTo run every time state changes
- βTo run exactly once on mount
Using Async/Await. Because useEffect's main callback cannot be async, we define an async function *inside* the effect and then immediately call it. This lets us use the clean await syntax for our fetch calls.
Error Handling & Finally. Networks fail. Servers crash. You MUST wrap your API calls in a try/catch block. The catch block sets the error state. The finally block runs whether it succeeds or fails, making it the perfect place to turn off the loading spinner.
Which block in a try/catch/finally statement is the best place to call setLoading(false), ensuring the spinner stops whether the API succeeded or failed?
- βtry
- βfinally
Conditional Rendering: Loading. Now we build the UI. Before returning our main content, we check the loading state. If it's true, we return early with a loading indicator. This prevents React from trying to map over an empty array.
Conditional Rendering: Error. Next, we check for errors. If the error string is not null, we render an error message. Using these 'guard clauses' keeps our main return statement clean and focused on the happy path.
Rendering the Data. Because of our guard clauses, if the code reaches the final return, we GUARANTEE that we have data and we aren't loading. Now we safely use .map() to iterate over our array and generate our UI elements.
Providing Unique Keys. Wait! When you map over an array in React, you MUST provide a unique key prop to the outermost returned element. This is how React efficiently updates the DOM if the API data changes order later.
When iterating over data from an API using .map(), what prop MUST you assign to the top-level element returned so React can track it efficiently?
- βid
- βkey
Cleaning Up APIs. Advanced: If a user clicks away from a page BEFORE the API finishes loading, React will try to set state on an unmounted component, causing memory leaks. You should use AbortController in the useEffect cleanup function.
Axios vs Fetch. While fetch is built into browsers, many developers prefer Axios. It automatically parses JSON, has better error throwing, and cleaner syntax. The logic remains exactly the same.
API Master. Amazing! You've mastered the lifecycle of fetching data in React. You know how to track loading, catch errors, cancel requests, and safely map data. You're ready to build real-world applications!
Level Up π
Advanced cheat sheets, SEO tricks, and interview prep for this topic.
Browser Support
Fully supported.
Fully supported.
Fully supported.
Fully supported.
Accessibility (A11y)
1Announce Loading and Error States to Assistive Technology
A loading spinner or an error message that only changes visually is invisible to screen reader users unless it's placed in (or paired with) an `aria-live` region, so the fetch outcome is announced automatically rather than requiring the user to go looking for it.
<div aria-live="polite">{loading ? 'Loading usersβ¦' : null}</div>2Keep Focus Predictable Across Loading, Error, and Data States
Swapping between a spinner, an error message, and a populated list can shift or destroy the currently focused element; make sure focus lands somewhere sensible (like a heading or the first result) once the real content renders, instead of being silently lost.
SEO Implications
- 1
Client-Fetched Data Is Invisible to Crawlers That Don't Execute JavaScript
Content rendered only after a `useEffect`-triggered fetch resolves is not present in the initial HTML payload; if that content matters for search visibility, fetch it during server-side rendering or static generation instead of exclusively on the client.
- 2
Perpetual Loading or Error States Hurt Indexable Content
If a crawler's snapshot happens to catch the component before the fetch resolves, it may only see a loading indicator or an empty guard clause β make sure critical content has a reasonable, fast path to being rendered server-side.
Best Practices
Track Loading, Error, and Data as Three Separate State Variables
Collapsing them into one value makes it hard to represent 'still loading' versus 'failed' versus 'succeeded with empty results' correctly; three explicit states keep each render branch unambiguous.
Always Clean Up In-Flight Requests With AbortController
If a component unmounts before its fetch resolves, calling a state setter afterward triggers a React warning and can mask a real memory leak β cancel the request in the effect's cleanup function instead.
Frequent Bugs
The fetch effect runs in an infinite loop, hammering the API repeatedly.
The dependency array was omitted entirely (or included a value that changes every render), so `useEffect` re-runs after every re-render it itself triggers. Pass `[]` for a one-time fetch on mount, or a stable, correctly scoped dependency list.
Console warning about setting state on an unmounted component after navigating away.
The fetch was still in flight when the component unmounted. Use an `AbortController` in the effect's cleanup function to cancel the request, or track a mounted flag before calling any setter.
Real-World Examples
Fetching a User List With Loading and Error Guards
A dashboard component fetches an array of users on mount, showing a spinner while the request is pending and an error banner if it fails, then maps over the results once data has actually arrived.
useEffect(() => {
const controller = new AbortController();
const load = async () => {
try {
const res = await fetch('/api/users', { signal: controller.signal });
setUsers(await res.json());
} catch (err) {
setError(err.message);
} finally {
setLoading(false);
}
};
load();
return () => controller.abort();
}, []);