Real applications need real data, and in React that means combining the browser's fetch API with useEffect to pull information from a server into your components. This lesson covers the full lifecycle: firing a request on mount, tracking loading and error state, re-fetching when dependencies change, and cleaning up in-flight requests safely.
1API Integration Basics
Modern apps are rarely static ā they need to talk to servers to display real, current data. In React, that means combining the browser's native fetch API with the useEffect hook to load data from an external source and get it into your component.
The general flow is always the same: the component mounts, useEffect triggers the fetch, the API responds with JSON, and state updates trigger a re-render that shows the new data on screen.
// API Integration: Bringing your app to life with dataThe Data Lifecycle
Client -> Server -> Client
2The useEffect Fetch
To load data exactly once when a component first mounts, place the fetch call inside a useEffect hook with an empty dependency array, []. That empty array tells React the effect has no reactive values to watch, so it should only run one time.
Without the empty array ā or with the array omitted entirely ā the effect would re-run after every render, refetching the same data in an endless loop.
useEffect(() => {
fetch('https://api.example.com/data')
.then(res => res.json())
.then(data => setData(data));
}, []);Mount Fetch
Triggered once on load.
3The Three States of Fetching
Data fetching is asynchronous and can fail, so a robust component always tracks three distinct pieces of state: loading (the request is currently in flight), data (the request succeeded and returned a value), and error (the request failed).
All three typically start from useState calls declared together ā loading initialized to true since a fetch begins immediately, data and error both initialized to null until the request resolves one way or the other.
const [loading, setLoading] = useState(true);
const [data, setData] = useState(null);
const [error, setError] = useState(null);Async States
Data, Loading, Error
4Using async/await
async/await reads more like linear, synchronous code than chained .then() calls do, which makes fetch logic considerably easier to follow. There's one catch, though: the function passed directly to useEffect can never be declared async itself.
Instead, you declare a separate async function inside the effect and call it immediately, typically wrapping the network calls in a try/catch/finally block so both success and failure paths update state correctly.
const load = async () => {
try {
const res = await fetch(url);
const json = await res.json();
setData(json);
} catch (err) {
setError(err);
}
};Async / Await
Cleaner network code.
5Conditional Rendering
Once a component tracks loading, error, and data, conditional rendering is what actually shows the right UI at the right moment: a spinner while the request is in flight, an error message if it fails, and the real content once data has arrived.
Ordering the checks matters ā testing loading first, then error, then falling through to the success case (if (loading) return <Spinner />; if (error) return <ErrorMessage />; return <DataList items={data} />;) keeps the logic unambiguous.
if (loading) return <Spinner />;
if (error) return <ErrorMessage />;
return <DataList items={data} />;UI States
Spinner -> Error -> Data
6Reactive Fetching (Dependencies)
If a fetch depends on a prop, like loading a specific user by userId, that value needs to be included in the useEffect dependency array rather than left out or hardcoded. Whenever userId changes, React automatically re-runs the effect and fetches the new user's data.
Omitting userId from the array would mean the effect only ever fetches the very first user it saw, even as the prop keeps changing on every subsequent render.
useEffect(() => {
fetchUser(userId);
}, [userId]); // Re-fetch on ID changeReactive Fetching
Data linked to props.
7The AbortController (Cleanup)
If a user navigates away while a fetch is still in flight, the component unmounts before the request finishes ā and when it finally resolves, calling setData on an unmounted component produces a memory-leak warning, since there's no component left to update.
The fix is an AbortController: create one before the fetch, pass its signal into the fetch call, and return a cleanup function from the effect that calls controller.abort(), cancelling the request the moment the component unmounts.
useEffect(() => {
const controller = new AbortController();
fetch(url, { signal: controller.signal });
return () => controller.abort();
}, []);AbortController
Cancelling rogue fetches.
8Simulating the Network
Watching a real render makes the lifecycle concrete: the component first renders with isLoading === true, showing a spinner or skeleton, since no data has arrived yet.
Once the simulated network request resolves, state updates and the component re-renders again ā this time with the fetched data available ā transitioning the UI straight from the loading placeholder to the real content without any manual intervention.
/* Fetching Lab: API Data Simulation Rendered */Loading Data...
9Data Fetching Libraries
Writing useEffect, useState, and AbortController boilerplate for every single request gets repetitive fast, and it's easy to get subtle details wrong across dozens of components. In production, teams commonly reach for a dedicated data-fetching library like React Query (TanStack Query) or SWR instead.
These libraries handle caching, retries, request deduplication, and loading states automatically, replacing manual useEffect fetch logic with a single call like const { data, isLoading, error } = useQuery(['user', id], fetchUser).
const { data } = useQuery(['user', id], fetchUser);React Query
The modern fetching standard.
10The Infinite Loop Danger
Never place a fetch call and a state update directly in the main body of a component, outside of useEffect. Doing so fetches data, updates state, triggers a re-render ā which runs the component body again, fetching again, updating state again ā an infinite loop that will crash the tab.
useEffect exists precisely to break that cycle: it runs after the render commits, and its dependency array controls exactly when it's allowed to run again.
// ā NEVER FETCH HERE
const data = fetch(url); // DANGERInfinite Loops
Always use useEffect.
11Posting Data
Sending data to a server, like submitting a form, uses the exact same fetch API used for reading data, but with an options object as the second argument: setting method to 'POST', providing a Content-Type header, and passing the request body as a JSON string via JSON.stringify(data).
Unlike a GET request, fetch won't stringify a plain JavaScript object for you automatically ā forgetting JSON.stringify on the body is a common source of servers receiving "[object Object]" instead of real data.
fetch(url, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data)
});POST Requests
Sending data back.
12Mastery Achieved
You now have the full data-fetching lifecycle: firing a request inside useEffect on mount, tracking loading and error state alongside the data itself, re-fetching correctly when dependencies change, cleaning up in-flight requests with AbortController, and sending data back to a server with POST.
With this foundation in place, the next step is looking at how larger applications manage shared, app-wide state beyond what a single component's useState can hold ā starting with the Redux store architecture.
/* Next: Redux Store Architecture */Data Fetched ā
13Step-by-Step Breakdown
API Integration Basics. Modern apps are rarely static; they need to talk to servers to get real-time data. In React, we use the browser's native fetch API, combined with the useEffect hook, to load data from external sources and inject it into our components.
The useEffect Fetch. To load data exactly ONCE when a component first appears on the screen (mounts), we place our fetch call inside a useEffect hook with an empty dependency array []. This prevents infinite fetching loops.
The Three States of Fetching. Data fetching is asynchronous and can fail. A robust React component ALWAYS manages three distinct states: loading (the request is in flight), data (the request succeeded), and error (the request failed).
Which React hook is the standard, built-in place to perform an initial data fetch when a component loads?
- āuseState
- āuseEffect
Using async/await. While .then() chains work, async/await makes your fetching logic much cleaner. However, you cannot make the useEffect callback itself async. You must declare an async function INSIDE the effect, and then call it.
Conditional Rendering. Now that you have your three states, use conditional rendering to show the right UI to the user at the right time. Show a spinner while loading, an error message if it fails, and the actual data when it's ready.
Reactive Fetching (Dependencies). What if you need to fetch data based on a prop, like a userId? You must include userId in the useEffect dependency array. Now, whenever the userId prop changes, React will automatically re-run the effect and fetch the new user's data.
The AbortController (Cleanup). Race conditions! If a user clicks away from a page while a fetch is still running, the component unmounts. When the fetch finally finishes, it tries to setData on an unmounted component, causing a memory leak warning. Use an AbortController in the cleanup function to cancel the request.
If you are fetching data for a specific user based on a prop, which variable MUST be included in your useEffect dependency array?
- āEmpty array []
- āThe userId prop
Simulating the Network. In the browser pane, observe the rendering lifecycle. You will see the initial isLoading === true state (a skeleton or spinner), followed by the transition to the data state once the simulated network request resolves.
Data Fetching Libraries. Writing useEffect, useState, and AbortController boilerplate for every request is tedious. In production, teams use libraries like React Query (TanStack Query) or SWR. These libraries handle caching, retries, deduplication, and loading states automatically.
The Infinite Loop Danger. CRITICAL: Never put a fetch call and a setData state update directly inside the main body of your component. It will fetch, update state, trigger a re-render, which fetches again, which updates state... crashing the browser with an infinite loop.
Posting Data. So far we've looked at GET requests. To send data to the server (like submitting a form), you use the same fetch API, but provide an options object setting the method to 'POST', setting the headers, and stringifying the JSON body.
Which method on the fetch response object is required to convert the raw network stream into a usable JavaScript object?
- ātext()
- ājson()
Mastery Achieved. Fetching mastery achieved! You've learned to connect your React application to the outside world, manage the asynchronous lifecycle securely, and optimize network requests.
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 Async Loading and Error States to Assistive Technology
A loading spinner or error message that appears after a fetch resolves should live inside an `aria-live="polite"` region, or receive focus directly, so screen reader users are notified that the page content changed instead of silently missing the update.
2Don't Leave Users on a Blank Screen During a Fetch
Rendering nothing at all while `loading` is `true` can look broken to assistive technology and sighted users alike ā always render an explicit loading indicator with meaningful text or an accessible label, not just an empty container.
SEO Implications
- 1
Client-Only Fetches Leave Crawlers With Empty Initial HTML
Data fetched exclusively inside a `useEffect` after mount runs only in the browser ā a crawler evaluating the server-rendered HTML before hydration sees none of that content, which is a problem for any data that matters for indexing.
- 2
Prefer Server-Side or Static Fetching for Content That Needs to Rank
For SEO-critical content, fetch data server-side (e.g., in a Next.js Server Component or `getServerSideProps`) and pass it down as props, reserving client-side `useEffect` fetches for data that's genuinely interactive or user-specific.
Best Practices
Always Clean Up In-Flight Requests With AbortController
Returning `() => controller.abort()` from the `useEffect` that started a fetch prevents the classic 'Can't perform a React state update on an unmounted component' warning when a user navigates away before the request finishes.
Never Fetch Directly in the Component Body
A `fetch` call outside of `useEffect` runs on every single render, triggers a state update, causes another render, and fetches again ā an infinite loop. Data fetching belongs inside `useEffect`, gated by a dependency array.
Frequent Bugs
The browser console shows a warning about calling setState on an unmounted component after navigating away from a page mid-fetch.
The fetch wasn't cancelled when the component unmounted. Create an `AbortController` before the fetch, pass `{ signal: controller.signal }` to `fetch`, and return `() => controller.abort()` from the effect.
A component keeps showing data for the previous user ID even after a prop changes to a new ID.
The prop driving the fetch (e.g., `userId`) was left out of the `useEffect` dependency array, so the effect never re-runs when it changes. Add it to the array: `useEffect(() => { fetchUser(userId); }, [userId])`.
Real-World Examples
A Profile Component With Full Loading, Error, and Data States
A `UserProfile` component fetches a user by ID inside `useEffect`, tracking `loading`, `error`, and `data` separately, and renders a spinner, an error message, or the profile card depending on which state is currently active.
function UserProfile({ userId }) {
const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setData)
.catch(setError)
.finally(() => setLoading(false));
return () => controller.abort();
}, [userId]);
if (loading) return <Spinner />;
if (error) return <ErrorMessage error={error} />;
return <h1>{data.name}</h1>;
}