Skip to content

Fetching data

Lesson 11 of 30 16 min read

Load data with loading and error states, avoid race conditions with AbortController, and know when to reach for a library.


Nearly every app loads data from a server: courses, user profiles, search results. In this lesson you'll fetch data in a React component the right way — with loading and error states, cancellation to avoid race conditions, and a reusable pattern — and learn when to hand the job to a library instead.

We'll use the free practice API JSONPlaceholder (https://jsonplaceholder.typicode.com).

Fetching on mount#

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

export default function Users() {
  const [users, setUsers] = useState([]);
  const [status, setStatus] = useState("loading"); // loading | success | error
  const [error, setError] = useState(null);

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

    async function load() {
      try {
        const res = await fetch("https://jsonplaceholder.typicode.com/users", {
          signal: controller.signal,
        });
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        const data = await res.json();
        setUsers(data);
        setStatus("success");
      } catch (err) {
        if (err.name === "AbortError") return; // we cancelled it – ignore
        setError(err.message);
        setStatus("error");
      }
    }

    load();
    return () => controller.abort(); // cleanup: cancel if we unmount
  }, []);

  if (status === "loading") return <p>Loading users…</p>;
  if (status === "error") return <p role="alert">Couldn't load users: {error}</p>;

  return (
    <ul>
      {users.map((u) => (
        <li key={u.id}>
          {u.name} — {u.email}
        </li>
      ))}
    </ul>
  );
}

Key points:

  • The effect callback isn't async itself; it defines load and calls it. (An async callback would return a promise, but React expects a cleanup function or nothing.)
  • Check res.ok — fetch doesn't reject on 404/500.
  • Handle three states: loading, error and success. A single status value can't be "loading" and "error" at once.
  • The cleanup aborts the request. In development Strict Mode, the first request is aborted immediately and a second one runs — that's expected.

Fetching when a prop changes#

When data depends on a prop or state, add it to the dependencies:

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

function UserPosts({ userId }) {
  const [posts, setPosts] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    const controller = new AbortController();
    setPosts(null); // show loading for the new user
    setError(null);

    fetch(`https://jsonplaceholder.typicode.com/posts?userId=${userId}`, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(setPosts)
      .catch((err) => {
        if (err.name !== "AbortError") setError(err.message);
      });

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

  if (error) return <p role="alert">{error}</p>;
  if (!posts) return <p>Loading posts…</p>;
  return (
    <ol>
      {posts.map((p) => (
        <li key={p.id}>{p.title}</li>
      ))}
    </ol>
  );
}

export default function PostsBrowser() {
  const [userId, setUserId] = useState(1);
  return (
    <>
      <label>
        User{" "}
        <select value={userId} onChange={(e) => setUserId(Number(e.target.value))}>
          {[1, 2, 3, 4, 5].map((id) => (
            <option key={id} value={id}>
              {id}
            </option>
          ))}
        </select>
      </label>
      <UserPosts userId={userId} />
    </>
  );
}

Why the cleanup matters: race conditions

Imagine the user switches from user 1 to user 2 quickly, and user 1's response is slower. Without cleanup:

Output
request user 1 ───────────────────────────▶ response 1 arrives last ❌
request user 2 ──────────▶ response 2 arrives first

Response 1 would overwrite user 2's posts, showing the wrong data. Aborting the previous request (or setting an ignore flag in cleanup) guarantees only the latest request can update state:

JSX
useEffect(() => {
  let ignore = false;
  fetchPosts(userId).then((data) => {
    if (!ignore) setPosts(data);
  });
  return () => {
    ignore = true;
  };
}, [userId]);

Extracting a useFetch hook#

The pattern repeats, so extract it into a custom hook (you'll learn these properly soon):

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

export function useFetch(url) {
  const [state, setState] = useState({ data: null, error: null, loading: true });

  useEffect(() => {
    if (!url) return;
    const controller = new AbortController();
    setState({ data: null, error: null, loading: true });

    fetch(url, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then((data) => setState({ data, error: null, loading: false }))
      .catch((error) => {
        if (error.name !== "AbortError") setState({ data: null, error, loading: false });
      });

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

  return state;
}

function Todo({ id }) {
  const { data, error, loading } = useFetch(`https://jsonplaceholder.typicode.com/todos/${id}`);
  if (loading) return <p>Loading…</p>;
  if (error) return <p role="alert">{error.message}</p>;
  return (
    <p>
      {data.completed ? "✅" : "⬜"} {data.title}
    </p>
  );
}

Now any component can fetch with one line.

Sending data (mutations)#

Creating, updating or deleting data happens in response to user actions, so it belongs in event handlers, not effects:

JSX
import { useState } from "react";

export default function NewPost({ onCreated }) {
  const [title, setTitle] = useState("");
  const [saving, setSaving] = useState(false);
  const [error, setError] = useState(null);

  async function handleSubmit(e) {
    e.preventDefault();
    setSaving(true);
    setError(null);
    try {
      const res = await fetch("https://jsonplaceholder.typicode.com/posts", {
        method: "POST",
        headers: { "Content-Type": "application/json" },
        body: JSON.stringify({ title, body: "", userId: 1 }),
      });
      if (!res.ok) throw new Error(`HTTP ${res.status}`);
      const created = await res.json();
      onCreated?.(created);
      setTitle("");
    } catch (err) {
      setError(err.message);
    } finally {
      setSaving(false);
    }
  }

  return (
    <form onSubmit={handleSubmit}>
      <input value={title} onChange={(e) => setTitle(e.target.value)} required />
      <button disabled={saving}>{saving ? "Saving…" : "Create post"}</button>
      {error && <p role="alert">{error}</p>}
    </form>
  );
}

Organising API code#

Keep fetch details out of components with a small API module:

JavaScript
// src/lib/api.js
const BASE = import.meta.env.VITE_API_URL ?? "https://jsonplaceholder.typicode.com";

export async function api(path, { method = "GET", body, signal } = {}) {
  const res = await fetch(`${BASE}${path}`, {
    method,
    signal,
    headers: body ? { "Content-Type": "application/json" } : undefined,
    body: body ? JSON.stringify(body) : undefined,
  });
  if (!res.ok) throw new Error(`${method} ${path} failed: HTTP ${res.status}`);
  return res.status === 204 ? null : res.json();
}

export const getUsers = (signal) => api("/users", { signal });
export const createPost = (post) => api("/posts", { method: "POST", body: post });

Components then call getUsers(controller.signal) — and when the backend changes, you edit one file.

Avoiding waterfalls#

If a parent fetches, renders a child, and the child then fetches, the requests run one after another — a waterfall. When requests are independent, start them together:

JSX
useEffect(() => {
  const controller = new AbortController();
  Promise.all([
    fetch("/api/user", { signal: controller.signal }).then((r) => r.json()),
    fetch("/api/courses", { signal: controller.signal }).then((r) => r.json()),
  ])
    .then(([user, courses]) => {
      setUser(user);
      setCourses(courses);
    })
    .catch((err) => {
      if (err.name !== "AbortError") setError(err);
    });
  return () => controller.abort();
}, []);

Why most apps use a data library#

Hand-written effects work, but production apps also need:

  • Caching — don't refetch the same data every time a component mounts.
  • De-duplication — ten components asking for the same user should make one request.
  • Background refetching — refresh stale data when the window regains focus.
  • Retries, pagination, optimistic updates and invalidation after mutations.

That's what TanStack Query does (it has its own lesson in the Advanced module):

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

function Users() {
  const { data, isPending, error } = useQuery({
    queryKey: ["users"],
    queryFn: ({ signal }) => fetch("https://jsonplaceholder.typicode.com/users", { signal }).then((r) => r.json()),
  });
  if (isPending) return <p>Loading…</p>;
  if (error) return <p role="alert">{error.message}</p>;
  return <ul>{data.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

Frameworks offer alternatives too: React Router loaders fetch before a page renders, and Next.js Server Components fetch on the server. React 19's use() API can also read a promise during render together with Suspense.

Common mistakes#

  • Making the effect callback async.
  • Not checking res.ok.
  • No loading or error state — users see a blank screen or a crash.
  • Forgetting cleanup, causing race conditions and "state update on unmounted component" style bugs.
  • Fetching in response to clicks via an effect instead of directly in the handler.
  • Putting an object or array literal in the dependency array, causing refetch loops.

What's next#

Next: useRef — reaching into the DOM to focus and measure elements, and storing values that shouldn't trigger re-renders.

Check your understanding

Quick quiz

0/3 answered
  1. 1.Why might a slow response for an old userId overwrite the data for the current one?

  2. 2.Why can't you write useEffect(async () => { ... }, [])?

  3. 3.What problems does a data library like TanStack Query solve that a hand-written useEffect + fetch doesn't?

Finished reading?

Mark this lesson complete to track your progress.