Loading and Error States

Whenever a React component fetches data from a network request, that data does not arrive instantly. Between the moment a component mounts and the moment the response comes back, your UI has to show something — a spinner, a skeleton, or at least a blank area — and if the request fails, it has to say so instead of leaving the user staring at nothing. Loading and error states are the mechanism React developers use to represent these in-between and failure moments as part of a component’s regular state, so the UI always reflects reality: loading, success, or error.

This lesson covers the standard three-state pattern for manual data fetching with useState and useEffect, why race conditions and memory leaks happen, and how to guard against them.

Overview / How It Works

A network request is asynchronous: the component function runs and returns JSX long before the response arrives. If you tried to fetch data directly inside the component body and use the result immediately, the JSX would render with undefined because the fetch has not resolved yet. React’s solution is to treat each phase of a request as ordinary component state, and let React’s normal render cycle handle showing the right UI at the right time.

The common pattern uses three pieces of state:

State Type Meaning
data the fetched value or null Holds the successful response once it arrives
loading boolean True while a request is in flight
error Error or null Holds the failure reason if the request rejected

The fetch itself lives inside a useEffect, because fetching is a side effect — it reaches outside of React to talk to the network, and it must not run during rendering. Here is why: React may render a component multiple times before committing anything to the screen (for example, during Strict Mode’s intentional double-invoke in development, or when React discards an in-progress render). Side effects that run during render would fire fetches you never intended. By running the fetch inside useEffect, React guarantees it only happens after the component has actually committed to the DOM.

Once the promise settles, you call setData, setError, or both, plus setLoading(false). Each set call schedules a re-render. On that next render, the component reads the new state values and returns different JSX — a spinner becomes a list, or a list becomes an error message. This is the same reconciliation process every state update goes through: React re-runs the component function, builds a new virtual DOM tree, diffs it against the previous one, and commits only the changed DOM nodes.

There’s a subtlety that trips up most beginners: what happens if the component unmounts, or its dependencies change, before the fetch resolves? The promise keeps running in the background and will eventually try to call setState on a component that either no longer exists or has moved on to a newer request. This is a race condition, and the fix — a cleanup flag or AbortController — is covered in Common Mistakes below.

Syntax

The general shape of the pattern looks like this:

const [data, setData] = useState(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState(null);

useEffect(() => {
  let ignore = false;

  async function load() {
    setLoading(true);
    setError(null);
    try {
      const response = await fetch(url);
      if (!response.ok) throw new Error("Request failed: " + response.status);
      const json = await response.json();
      if (!ignore) setData(json);
    } catch (err) {
      if (!ignore) setError(err);
    } finally {
      if (!ignore) setLoading(false);
    }
  }

  load();
  return () => { ignore = true; };
}, [url]);
  • Three useState calls — one each for the payload, the in-flight flag, and the failure. They are independent so each can update on its own schedule.
  • An async helper function inside the effect — the effect callback itself cannot be async (React expects it to return either nothing or a cleanup function, not a promise), so you define and call an inner async function instead.
  • try / catch / finally — catches both network failures and non-2xx responses (which fetch does not reject on by default, so you must check response.ok yourself), and finally guarantees loading is cleared however the request ends.
  • The ignore flag and cleanup function — prevents a stale response from overwriting newer state after the effect re-runs or the component unmounts.
  • The dependency array [url] — every reactive value read inside the effect (here, url) must be listed, so the fetch re-runs whenever it changes.

Examples

Example 1: A basic user profile fetch

import { useState, useEffect } from "react";

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false;

    async function fetchUser() {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(`https://api.example.com/users/${userId}`);
        if (!res.ok) throw new Error("User not found");
        const data = await res.json();
        if (!ignore) setUser(data);
      } catch (err) {
        if (!ignore) setError(err);
      } finally {
        if (!ignore) setLoading(false);
      }
    }

    fetchUser();
    return () => { ignore = true; };
  }, [userId]);

  if (loading) return <p>Loading user...</p>;
  if (error) return <p>Error: {error.message}</p>;

  return (
    <div>
      <h3>{user.name}</h3>
      <p>{user.email}</p>
    </div>
  );
}

export default UserProfile;

Renders: Initially shows "Loading user…". Once the fetch resolves successfully, it re-renders to show the user’s name and email inside a div. If the request fails, it re-renders to show "Error: User not found" (or whatever message the thrown Error carries) instead.

This is the canonical three-state pattern: the early returns for loading and error keep the success-path JSX clean, since by the time you reach the final return, you know user is populated.

Example 2: Avoiding a race condition with AbortController

import { useState, useEffect } from "react";

function ProductDetails({ productId }) {
  const [product, setProduct] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    async function fetchProduct() {
      setLoading(true);
      setError(null);
      try {
        const res = await fetch(`https://api.example.com/products/${productId}`, {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error("Failed to load product");
        const data = await res.json();
        setProduct(data);
      } catch (err) {
        if (err.name !== "AbortError") setError(err);
      } finally {
        setLoading(false);
      }
    }

    fetchProduct();
    return () => controller.abort();
  }, [productId]);

  if (loading) return <p>Loading product...</p>;
  if (error) return <p>Something went wrong: {error.message}</p>;

  return <h3>{product.name} — ${product.price}</h3>;
}

export default ProductDetails;

Renders: Shows "Loading product…", then the product name and price, or an error message. If productId changes quickly (for example the user clicks through several products), each earlier in-flight request is cancelled via controller.abort() before it can call setProduct with stale data.

Unlike the plain ignore flag in Example 1, AbortController also cancels the actual network request, saving bandwidth. The catch block explicitly ignores AbortError so a deliberate cancellation never gets displayed to the user as a failure.

Example 3: A reusable useFetch custom hook

import { useState, useEffect } from "react";

function useFetch(url) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    setLoading(true);
    setError(null);
    setData(null);

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`Request failed with status ${res.status}`);
        return res.json();
      })
      .then((json) => setData(json))
      .catch((err) => {
        if (err.name !== "AbortError") setError(err);
      })
      .finally(() => setLoading(false));

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

  return { data, loading, error };
}

function PostList() {
  const { data: posts, loading, error } = useFetch("https://api.example.com/posts");

  if (loading) return <p>Loading posts...</p>;
  if (error) return <p>Could not load posts: {error.message}</p>;

  return (
    <ul>
      {posts.map((post) => (
        <li key={post.id}>{post.title}</li>
      ))}
    </ul>
  );
}

export default PostList;

Renders: "Loading posts…", then a bulleted list of post titles, or an error line. Extracting the pattern into useFetch means any component can get the same { data, loading, error } shape from a single line, and the reset of data/error at the start of the effect stops a previous URL’s stale result or error from flashing on screen while a new URL loads.

How It Works Step by Step

  • On mount: the component renders once with loading at its initial value (typically true) and data/error at null, showing the loading UI. After the DOM commits, React runs the effect, which kicks off the fetch.
  • While the request is in flight: no further renders happen from this effect — the component simply displays the loading branch until a promise settles.
  • On success: setData and setLoading(false) are called, React schedules a re-render, and the component now returns the success branch of the JSX.
  • On failure: setError and setLoading(false) are called instead, and the component renders the error branch.
  • On dependency change (e.g. userId changes): the effect’s cleanup function runs first (aborting or flagging the old request as stale), then the effect runs again with the new value, restarting the whole cycle.
  • On unmount: the cleanup function runs one final time, aborting any pending request so it can never call setState on a component that no longer exists.

Common Mistakes

Mistake 1: No cleanup, so a stale response overwrites newer state

// Wrong: no cleanup — a slow first request can resolve
// AFTER a faster second request and overwrite its data
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    setLoading(true);
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then((data) => {
        setResults(data);
        setLoading(false);
      });
  }, [query]);

  // ...
}

If the user types quickly, each keystroke fires a new request. Requests don’t necessarily resolve in the order they were sent — an earlier request for "re" might resolve after a later request for "react", silently replacing the correct results with outdated ones. There is also no protection against calling setState after the component has unmounted.

// Correct: cleanup cancels stale work
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [loading, setLoading] = useState(true);

  useEffect(() => {
    let ignore = false;
    setLoading(true);

    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then((data) => {
        if (!ignore) {
          setResults(data);
          setLoading(false);
        }
      });

    return () => { ignore = true; };
  }, [query]);

  // ...
}

Mistake 2: Forgetting to reset the error state before refetching

// Wrong: error from a previous failed request never clears,
// so a later successful fetch still shows the old error message
useEffect(() => {
  setLoading(true);
  fetch(url)
    .then((res) => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, [url]);

Once error is set, nothing ever sets it back to null. If the first request for a given url fails but a later one succeeds, the component keeps rendering the error branch (assuming an if (error) return ... guard) forever, even though fresh data has arrived.

// Correct: clear error (and stale data) at the start of every attempt
useEffect(() => {
  setLoading(true);
  setError(null);
  fetch(url)
    .then((res) => res.json())
    .then(setData)
    .catch(setError)
    .finally(() => setLoading(false));
}, [url]);

Best Practices

  • Always guard against setting state after a request is no longer relevant, using either a cleanup flag or AbortController.
  • Reset error (and usually data) to their empty values at the start of every new fetch attempt, so old results or errors don’t linger on screen.
  • Check response.ok explicitly — fetch only rejects on network failure, not on HTTP error statuses like 404 or 500.
  • List every value the effect reads (URLs, IDs, query params) in the dependency array so refetches happen exactly when they should.
  • Prefer a small custom hook like useFetch once you find yourself repeating the same loading/error/data trio across multiple components.
  • For anything beyond a simple lesson-sized example, reach for a data-fetching library (React Query, SWR) — they handle caching, retries, and race conditions for you, but understanding this manual pattern is what lets you use them well.
  • Distinguish an empty successful result (e.g. an empty array) from an error — don’t conflate "no data yet" with "something went wrong."

Practice Exercises

  • Build a WeatherWidget component that fetches weather for a city name typed into a text input. It should show a loading indicator while fetching, an error message if the city is invalid, and the temperature once loaded. Make sure typing a new city cancels any in-flight request for the previous one.
  • Take the useFetch hook from Example 3 and add a refetch function to its returned object, so a component can manually re-run the same request (for example from a "Retry" button shown only when error is truthy).
  • Given the "Mistake 2" wrong code snippet above, rewrite it from scratch without looking at the fix, then compare your version against the corrected one — check specifically whether you reset both error and any previous data.

Summary

  • Loading and error states turn an asynchronous fetch into three explicit, renderable states: loading, error, and success.
  • Fetching belongs inside useEffect, not in the component body, because it’s a side effect that must run after commit, not during render.
  • An inner async function is needed because the effect callback itself cannot be async.
  • Race conditions happen when an older request resolves after a newer one; guard with a cleanup flag or, better, AbortController.
  • Reset error and stale data at the start of every new fetch attempt so old state doesn’t linger.
  • Always check response.ok, since fetch does not reject on HTTP error status codes.
  • Extracting the pattern into a custom hook like useFetch keeps components focused on rendering rather than fetching mechanics.