Menu

React Fetch Data with useEffect: Loading, Errors, Races

Fetch data in React by starting the request in useEffect, keeping loading, error and data in state, and ignoring responses that arrive after the inputs changed. Covers async/await in effects, AbortController, race conditions and when to use a library instead.

This page includes runnable editors - edit, run, and see output instantly.

To fetch data in React, start the request inside useEffect, save the response in state, and render a loading message until it arrives. Put the values the request depends on, such as an id, in the dependency array so the effect fetches again when they change.

The examples on this page cannot reach the network, so fetchUser is a fake API: a promise that resolves after a delay, the way fetch does. Change 800 to 3000 to see the loading text stay longer.

With a real API the effect body looks like this:

useEffect(() => {
    fetch('https://api.example.com/users/1')
        .then((res) => res.json())
        .then((data) => setUser(data));
}, []);

Loading, error and data

A request has three outcomes the screen should show: still loading, failed, or done. Keep each one in state, and reset them when the effect starts a new request.

Click User 3: the fake API rejects, the error message replaces the name, and the console shows the error. The finally block clears loading on both paths, so a failed request never leaves the spinner on.

When fetch talks to a real server, a 404 or 500 response does not reject the promise. Check res.ok and throw yourself:

const res = await fetch(`/api/users/${id}`);
if (!res.ok) throw new Error(`HTTP ${res.status}`);
const data = await res.json();

async/await inside an effect

The function you pass to useEffect cannot be async. React expects it to return nothing or a cleanup function, and an async function always returns a promise.

// Wrong: the effect returns a promise
useEffect(async () => {
    const data = await fetchUser(id);
    setUser(data);
}, [id]);

// Right: define an async function inside and call it
useEffect(() => {
    async function load() {
        const data = await fetchUser(id);
        setUser(data);
    }
    load();
}, [id]);

Race conditions

When the id changes quickly, two requests are in flight at once. Nothing guarantees they answer in order. If the older one is slower, it arrives last and overwrites the data for the id the user picked most recently.

The fix is an ignore flag. Each run of the effect has its own flag, and its cleanup sets it to true. Because React runs the cleanup before the next run, a response from an outdated run sees ignore === true and is dropped. This block renders the same profile twice, without and with the flag:

Click the button and wait. User 2's fast answer arrives first, so both profiles log and keep Grace. About a second and a half after the click, user 1's slow answer arrives: the profile without the flag switches to Ada even though user 2 is selected, while the one with the flag logs dropped stale Ada and keeps Grace.

The same bug appears in search boxes, tabs and any list filtered by the server. The loading example above leaves the flag out to stay short, but every fetch in an effect should have it, or the abort below.

Cancelling with AbortController

The ignore flag drops a stale response, but the request still runs to the end. With the real fetch you can cancel it. Create an AbortController in the effect, pass its signal to fetch, and call abort() in the cleanup:

useEffect(() => {
    const controller = new AbortController();

    async function load() {
        try {
            const res = await fetch(`/api/users/${id}`, { signal: controller.signal });
            if (!res.ok) throw new Error(`HTTP ${res.status}`);
            setUser(await res.json());
        } catch (err) {
            if (err.name === 'AbortError') return; // cancelled on purpose
            setError(err);
        }
    }

    load();
    return () => controller.abort();
}, [id]);

An aborted fetch rejects with an AbortError, so the catch block skips it instead of showing it as a failure. Abort also covers unmounting: when the component is removed, its cleanup cancels the request.

Moving it into a custom hook

Loading, error and the race guard are the same in every component that fetches. Put them in a custom hook and each component asks for data in one line.

fetchPosts is defined outside the component, so its identity never changes and it is safe in the dependency array. Defined inside App, it would be a new function on every render, the effect would run after every render, and because the effect sets state, it would never stop fetching.

Change 500 to 2000 and click the button twice in a row: the console shows two requests, and only the posts for the topic you ended on appear.

Fetching in an event handler

An effect is for data the component needs because it is on the screen: a profile page loads its profile. When a request happens because the user did something, such as pressing Search or Save, make it in the event handler. You know exactly what triggered it, and nothing re-runs when unrelated state changes.

async function handleSubmit(e) {
    e.preventDefault();
    setStatus('saving');
    const res = await fetch('/api/notes', { method: 'POST', body: JSON.stringify({ text }) });
    setStatus(res.ok ? 'saved' : 'error');
}

Common mistakes

Missing dependencies. An effect that reads id but has [] fetches the first user forever. List every value the request uses.

Fetching in the component body. A fetch outside an effect runs on every render, and if it sets state, it starts a loop.

Trusting fetch to reject on errors. It only rejects when the network fails. Check res.ok.

Forgetting the loading reset. When the id changes, set loading back to true, or the old data stays on screen with no sign that new data is on its way.

When to use a library or a framework

Fetching in an effect is fine for a few requests. It does not cache: open the same profile twice and it fetches twice. It does not share data between components, retry on failure, or refetch when the tab gets focus again. Libraries do this for you:

import { useQuery } from '@tanstack/react-query';

function Profile({ id }) {
    const { data, error, isPending } = useQuery({
        queryKey: ['user', id],
        queryFn: () => fetch(`/api/users/${id}`).then((res) => res.json()),
    });

    if (isPending) return <p>Loading...</p>;
    if (error) return <p>{error.message}</p>;
    return <p>{data.name}</p>;
}

TanStack Query and SWR are the common choices for client-side data. Frameworks go further and load data on the server before the page reaches the browser: Next.js with server components, React Router with loaders. That avoids the loading flash and the chain of requests that starts when a parent's data must arrive before a child can even begin fetching.

React 19: use() with Suspense

React 19 adds use, which reads a promise during render. The component suspends until the promise resolves, and the nearest <Suspense> boundary shows a fallback in the meantime, so the component itself has no loading state:

import { use, Suspense } from 'react';

function Profile({ userPromise }) {
    const user = use(userPromise);
    return <p>{user.name}</p>;
}

<Suspense fallback={<p>Loading...</p>}>
    <Profile userPromise={userPromise} />
</Suspense>

The promise must be created outside the component (by a framework, a cache or a parent), not inside the render that reads it, or every render starts a new request. The use hook page covers this with runnable examples.

Frequently Asked Questions

How do I fetch data when a React component loads?

Start the request in a useEffect with the values it depends on in the dependency array ([] if none), and store the result in state with useState. Render a loading message until the data arrives.

Why can't the useEffect callback be async?

An async function always returns a promise, and React expects the effect to return either nothing or a cleanup function. Write an async function inside the effect and call it right away.

What is a race condition in React data fetching?

When the input changes quickly, two requests are in flight and the older one can answer last, overwriting the newer data. Set an ignore flag in the effect's cleanup and skip setState when it is set, or cancel the request with an AbortController.

Should I use useEffect or a library to fetch data?

useEffect works for small apps and is worth understanding. For caching, deduplication, retries and refetching, a library such as TanStack Query or SWR, or the data loading built into a framework like Next.js, saves you from writing that logic yourself.

How do I show a loading spinner while data loads?

Keep a loading boolean (or a status string) in state, set it before the request starts and clear it when the request finishes, in both the success and the error path. Render the spinner while it is true.

Coddy programming languages illustration

Learn to code with Coddy

GET STARTED