React Fetching Data with useEffect

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 fetch straight 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 async function always returns a Promise instead, so React gets confused and your cleanup breaks.
  • The fix is simple: define an async function 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 Promise
useEffect(async () => {
const res = await fetch("https://api.example.com/posts");
const data = await res.json();
}, []);
// ✅ Right: define an async function inside, then call it
useEffect(() => {
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 null or an empty array, since nothing has arrived yet.
  • isLoading: a true/false flag for “is the request still running?”. Starts at true.
  • error: holds an error message if the request failed. Starts at null.

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 with setData. This is the happy path.
  • catch: if anything throws, save a message with setError. This is the failure path.
  • finally: runs no matter what, success or failure. Flip isLoading to false here, 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.

  • fetch does not throw on a bad response like a 404 or 500, so you check res.ok yourself and throw if it’s false, which sends control into catch.
  • finally is the clean place for setIsLoading(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 defines loadPosts and 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, setData and setIsLoading(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 ignore variable inside the effect, starting at false.
  • After the data arrives, only update state if ignore is still false.
  • 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 inner async function and call it instead
  • ✅ You track the request with three state pieces: data, isLoading, and error
  • try saves the data, catch saves the error, and finally flips isLoading to false
  • fetch doesn’t throw on a 404 or 500, so you check res.ok and throw your own error
  • ✅ You render loading, error, and data with conditional rendering
  • ✅ An ignore flag 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. 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. 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. 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. 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.

React useEffect Common Mistakes

Share & Connect