React Fetching Data with useEffect
Table of Contents + −
In the last lesson you learned the React useEffect Cleanup Function, so you know how to tidy up after an effect runs. Now let’s use an effect for the job people reach for most: asking a server for data once when the component appears, then showing it on screen.
🤔 Why fetch inside useEffect?
Here’s the problem. Fetching data reaches outside your component, over the network. That’s a side effect, not part of rendering. So where do you put it?
- You can’t fetch during render. Render should only calculate and return JSX, nothing more.
- If you call
fetchstraight in the component body, it runs on every single render and fires request after request. - You want the request to run once, right after the component shows up, and not again on every redraw.
That’s exactly what an effect with an empty dependency array gives you. So useEffect is the right home for fetching: it runs after render, and the empty array makes it run just once.
🧩 Fetch once on mount
The shape of the pattern is an effect with an empty array. That empty array is the whole trick.
useEffect(() => { ... }, [])runs the function after the first render and never again.- “After the first render” is what people mean by on mount, when the component first appears on screen.
- So the request fires once, when the component shows up, and that’s it.
Here’s the bare shape, before we add the real work. It just shows where the fetch will go.
import { useEffect } from "react";
function Posts() { useEffect(() => { // the fetch will go here, runs once on mount }, []);
return <p>Posts will appear here.</p>;}So the empty [] is the part doing the work. It tells React there’s nothing to watch, so run the effect one time and leave it alone after that.
⚠️ Don’t make the effect callback async
The first thing people try trips them up. You want await inside, so you reach for async on the effect function, but the callback itself must not be async.
- React expects the effect function to return either nothing or a cleanup function.
- An
asyncfunction always returns a Promise instead, so React gets confused and your cleanup breaks. - The fix is simple: define an
asyncfunction inside the effect, then call it. The callback stays normal.
Here’s the wrong way next to the right way, so the difference is clear.
// ❌ Wrong: the effect callback itself is async, returns a PromiseuseEffect(async () => { const res = await fetch("https://api.example.com/posts"); const data = await res.json();}, []);
// ✅ Right: define an async function inside, then call ituseEffect(() => { async function loadPosts() { const res = await fetch("https://api.example.com/posts"); const data = await res.json(); } loadPosts();}, []);So the rule is short. The effect callback stays a plain function. You put your async work in a named function inside it, like loadPosts, and then call that function on the next line.
Never put async on useEffect directly
useEffect(async () => ...) looks tidy but it’s a mistake. The callback would return a Promise, and React reads the return value as a cleanup function. Always define an inner async function and call it instead.
🔀 The three pieces of state
A fetch can be in three different situations, and your screen has to handle all of them. So you keep three pieces of state.
- data: what came back from the server. Starts as
nullor an empty array, since nothing has arrived yet. isLoading: a true/false flag for “is the request still running?”. Starts attrue.error: holds an error message if the request failed. Starts atnull.
Here’s how those three set up at the top of the component.
import { useState, useEffect } from "react";
function Posts() { const [data, setData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null);
// effect comes next...}So before anything loads, the component knows it’s loading, it has no data yet, and nothing has gone wrong. Each piece flips as the request plays out.
🛠️ try, catch, finally
Inside the async function you handle all three outcomes with try, catch, and finally. Each block has one clear job.
try: do the fetch, read the result, and save it withsetData. This is the happy path.catch: if anything throws, save a message withsetError. This is the failure path.finally: runs no matter what, success or failure. FlipisLoadingtofalsehere, since the request is over either way.
Here’s the full async function with all three blocks.
async function loadPosts() { try { const res = await fetch("https://api.example.com/posts"); if (!res.ok) { throw new Error("Request failed"); } const json = await res.json(); setData(json); } catch (err) { setError(err.message); } finally { setIsLoading(false); }}A couple of things worth pointing out here.
fetchdoes not throw on a bad response like a 404 or 500, so you checkres.okyourself andthrowif it’s false, which sends control intocatch.finallyis the clean place forsetIsLoading(false), because you want loading to stop whether the request worked or not.
fetch only rejects on network failure
fetch only rejects the Promise when the network itself fails, like no connection. A server reply of 404 or 500 still counts as a “successful” fetch to the Promise. That’s why you check res.ok and throw your own error inside try.
💡 The complete fetching component
Now let’s put every piece together into one working component that fetches a list and shows it. This is the pattern you’ll reuse again and again.
import { useState, useEffect } from "react";
function Posts() { const [data, setData] = useState([]); const [isLoading, setIsLoading] = useState(true); const [error, setError] = useState(null);
useEffect(() => { async function loadPosts() { try { const res = await fetch("https://api.example.com/posts"); if (!res.ok) { throw new Error("Request failed"); } const json = await res.json(); setData(json); } catch (err) { setError(err.message); } finally { setIsLoading(false); } } loadPosts(); }, []);
if (isLoading) return <p>Loading...</p>; if (error) return <p>Something went wrong: {error}</p>;
return ( <ul> {data.map((post) => ( <li key={post.id}>{post.title}</li> ))} </ul> );}Let’s read it from the top.
- The three state pieces set up first: an empty list, loading as
true, and no error. - The effect runs once on mount because of the empty
[]. Inside it definesloadPostsand calls it. - The three return lines pick the screen. Loading shows “Loading…”, an error shows the message, and otherwise the list renders.
- Once the data arrives,
setDataandsetIsLoading(false)re-render the component, so the list shows up on its own.
So the same component shows three different screens over its life: first the spinner, then either the list or the error. That’s the whole data-fetching pattern in one place.
🧹 A note on cleanup
One small thing to guard against. If the component disappears before the request finishes, calling setData afterward sets state on something that’s gone. So a common habit is an ignore flag.
- Make a local
ignorevariable inside the effect, starting atfalse. - After the data arrives, only update state if
ignoreis stillfalse. - Return a cleanup function that sets
ignore = true, so a finished request just gets ignored if the component already left.
Here’s the same effect with the ignore flag added.
useEffect(() => { let ignore = false;
async function loadPosts() { const res = await fetch("https://api.example.com/posts"); const json = await res.json(); if (!ignore) { setData(json); } } loadPosts();
return () => { ignore = true; };}, []);So if the component is still around, ignore is false and the data gets saved like normal. But if it left, the cleanup already set ignore to true, so the late result is simply skipped. Don’t worry about memorizing this yet, just know the flag exists and why people add it.
🧩 What You’ve Learned
- ✅ Fetching data is a side effect, so it belongs in
useEffect, not in the render - ✅ An empty dependency array
[]makes the effect fetch once, on mount - ✅ The effect callback must not be
async— define an innerasyncfunction and call it instead - ✅ You track the request with three state pieces:
data,isLoading, anderror - ✅
trysaves the data,catchsaves the error, andfinallyflipsisLoadingtofalse - ✅
fetchdoesn’t throw on a 404 or 500, so you checkres.okand throw your own error - ✅ You render loading, error, and data with conditional rendering
- ✅ An
ignoreflag in cleanup avoids setting state after the component unmounts
Check Your Knowledge
Test what you learned. Pick an answer for each question, then click Check.
- 1
Why do you put a fetch inside useEffect instead of in the component body?
Why: Fetching reaches outside the component, so it's a side effect. Put it in the body and it fires on every render. An effect with an empty array runs it once, on mount.
- 2
What is wrong with useEffect(async () => { ... }, [])?
Why: An async callback always returns a Promise. React reads the effect's return value as a cleanup function, so an async callback breaks that. Define an inner async function and call it instead.
- 3
Why do you check res.ok inside the try block?
Why: fetch only rejects on a network failure. A 404 or 500 still resolves successfully, so you check res.ok yourself and throw, which sends control into catch.
- 4
Why put setIsLoading(false) in the finally block?
Why: The request is finished whether it worked or threw an error. finally always runs, so it's the clean place to turn loading off in both cases.
🚀 What’s Next?
Now you can fetch data the proper way: once on mount, with loading and error handled, and the async work tucked inside the effect. But there are a few traps with effects that catch almost everyone, like missing dependencies and accidental loops. Next you’ll learn how to spot and avoid them.