Skip to content
elephantoo

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.