Skip to content
Laviyx Learn

Try

React course menu

Fetching Data in React: Loading, Errors and Race Conditions

Load data from an API in React: fetching in useEffect with async/await, loading and error states, avoiding race conditions with an ignore flag or AbortController, refetching when inputs change, and libraries like TanStack Query and framework data loading.

Intermediate12 min readUpdated
On this page

Most apps load data from a server: products, posts, weather. In plain React, you fetch in an effect and keep three pieces of state: the data, whether it is loading, and any error.

The real code#

In your own project, fetching from an API looks like this:

JSX
import { useEffect, useState } from "react";

export default function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;
    async function load() {
      try {
        const res = await fetch("https://api.example.com/users");
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        if (!ignore) setUsers(data);
      } catch (err) {
        if (!ignore) setError(err.message);
      } finally {
        if (!ignore) setLoading(false);
      }
    }
    load();
    return () => {
      ignore = true;
    };
  }, []);

  if (loading) return <p>Loading…</p>;
  if (error) return <p>Error: {error}</p>;
  return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

Key points:

  • The effect function itself cannot be async, so define an async function inside and call it.
  • fetch only rejects on network failures; check res.ok for HTTP errors such as 404 or 500.
  • Show different UI for loading, error and success.

A live version with a simulated API#

The previews on this site run in a sandbox, so the examples below use a small simulated API: a function that returns a Promise and resolves after a delay, exactly like fetch(...).then(r => r.json()) would. In your project you would call fetch instead.

JSX
import { useEffect, useState } from "react";

const PRODUCTS = [
  { id: 1, name: "Cotton kurta", price: 899 },
  { id: 2, name: "Silk saree", price: 4599 },
  { id: 3, name: "Denim jacket", price: 2199 },
];

function fakeApi(shouldFail) {
  return new Promise((resolve, reject) =>
    setTimeout(() => (shouldFail ? reject(new Error("Server error (500)")) : resolve(PRODUCTS)), 1200)
  );
}

export default function App() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  const [attempt, setAttempt] = useState(0);
  const [fail, setFail] = useState(false);

  useEffect(() => {
    let ignore = false;
    setLoading(true);
    setError(null);
    fakeApi(fail)
      .then((data) => !ignore && setProducts(data))
      .catch((err) => !ignore && setError(err.message))
      .finally(() => !ignore && setLoading(false));
    return () => {
      ignore = true;
    };
  }, [attempt, fail]);

  return (
    <div>
      <label>
        <input type="checkbox" checked={fail} onChange={(e) => setFail(e.target.checked)} /> Make the server fail
      </label>{" "}
      <button onClick={() => setAttempt(attempt + 1)}>Reload</button>
      {loading ? (
        <p>Loading products…</p>
      ) : error ? (
        <p style={{ color: "crimson" }}>Could not load products: {error}</p>
      ) : (
        <ul>{products.map((p) => <li key={p.id}>{p.name} — ₹{p.price}</li>)}</ul>
      )}
    </div>
  );
}
Result

Race conditions#

When the request depends on input (a search term, a selected user), the user can change the input before the previous response arrives. Responses can arrive out of order, so a slow old response could overwrite a newer one. The ignore flag in the cleanup fixes this: when the input changes, React runs the old effect's cleanup, marking its result as stale.

Try it below: click the users quickly. Their simulated responses take different times (Asha's is slowest), yet the page always shows the user you clicked last.

JSX
import { useEffect, useState } from "react";

const PROFILES = {
  asha: { name: "Asha", city: "Mumbai", delay: 1500 },
  dev: { name: "Dev", city: "Jaipur", delay: 300 },
  noor: { name: "Noor", city: "Srinagar", delay: 800 },
};

function fetchProfile(id) {
  return new Promise((resolve) => setTimeout(() => resolve(PROFILES[id]), PROFILES[id].delay));
}

export default function App() {
  const [userId, setUserId] = useState("asha");
  const [profile, setProfile] = useState(null);

  useEffect(() => {
    let ignore = false;
    setProfile(null);
    fetchProfile(userId).then((data) => {
      if (!ignore) setProfile(data);
    });
    return () => {
      ignore = true;
    };
  }, [userId]);

  return (
    <div>
      {Object.keys(PROFILES).map((id) => (
        <button key={id} onClick={() => setUserId(id)} style={{ marginRight: 6, fontWeight: id === userId ? "bold" : "normal" }}>
          {PROFILES[id].name}
        </button>
      ))}
      <p>{profile ? `${profile.name} lives in ${profile.city}` : "Loading…"}</p>
    </div>
  );
}
Result

With real fetch, you can go further and cancel the request itself with an AbortController:

JSX
useEffect(() => {
  const controller = new AbortController();
  fetch(`/api/users/${userId}`, { signal: controller.signal })
    .then((res) => res.json())
    .then(setProfile)
    .catch((err) => {
      if (err.name !== "AbortError") setError(err.message);
    });
  return () => controller.abort();
}, [userId]);

In real projects: use a data library or your framework#

Hand-written fetching in effects works, but real apps also need caching, deduplicating requests, retrying, refetching when the window regains focus, and pagination. Most teams use:

  • TanStack Query (React Query) or SWR: hooks such as useQuery({ queryKey: ["users"], queryFn: fetchUsers }) that return data, isPending and error, with caching built in.
  • Framework data loading: Next.js server components and React Router loaders fetch data before or outside the component, so there is no loading flash in an effect.

Learning the effect-based pattern first makes it clear what these tools do for you.

Common mistakes#

Practice#

Practice exercise

Using a simulated API that returns a quote after 800 ms, build a "Quote of the day" card with a "New quote" button. While loading, disable the button and show "Loading…".

Show hint

Keep an index in state that the button increases; the effect depends on it, sets loading true, fetches, and sets the quote. Use disabled={loading} on the button.

Show solution
JSX
import { useEffect, useState } from "react";

const QUOTES = [
  "Practice makes progress.",
  "Small steps every day.",
  "Read the error message.",
];

const fetchQuote = (n) => new Promise((resolve) => setTimeout(() => resolve(QUOTES[n % QUOTES.length]), 800));

export default function App() {
  const [n, setN] = useState(0);
  const [quote, setQuote] = useState("");
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let ignore = false;
    setLoading(true);
    fetchQuote(n).then((q) => {
      if (!ignore) {
        setQuote(q);
        setLoading(false);
      }
    });
    return () => {
      ignore = true;
    };
  }, [n]);

  return (
    <div>
      <blockquote>{loading ? "Loading…" : `“${quote}”`}</blockquote>
      <button onClick={() => setN(n + 1)} disabled={loading}>New quote</button>
    </div>
  );
}
Result

Key takeaway#

Next: Refs with useRef.

Finished this lesson?