Using the Fetch API

Most real React apps need data from a server, and the browser’s built-in fetch function is the simplest way to get it. React doesn’t ship its own data-fetching system for plain function components, so you combine fetch with useState (to store the result) and useEffect (to trigger the request at the right time). This lesson covers the standard pattern for fetching data on mount, handling loading and error states, avoiding stale-response bugs when props change, and extracting the logic into a reusable custom hook.

Overview / How it works

fetch(url) returns a Promise that resolves to a Response object once the HTTP response headers arrive. You then call response.json() (itself a promise) to read and parse the body. Because this is asynchronous work with a side effect (talking to the network), it does not belong in the body of a component function — component bodies must stay pure and synchronous so React can call them freely during rendering. Instead, you perform the fetch inside useEffect, which React runs only after the DOM has been committed for that render.

The typical shape is three pieces of state: the data itself (starts as null or an empty array), a loading flag (starts true), and an error value (starts null). The effect kicks off the request, and when the promise resolves or rejects, it calls the matching setter. Each setState call schedules a re-render, React re-runs the component function with the new state, computes a new virtual DOM tree, diffs it against the previous one (reconciliation), and commits only the changed DOM nodes.

A subtlety: if the effect depends on a prop or piece of state (say, a userId that can change), the effect must list it in the dependency array so React re-runs the fetch when it changes. But that introduces a race condition — if userId changes again before the first request finishes, the old request’s response can arrive later and overwrite the newer data. The fix is either an AbortController to cancel the stale request, or a local "ignore this result" flag set in the effect’s cleanup function. Both are shown below.

Syntax

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

  async function loadData() {
    try {
      const response = await fetch(url, { signal: controller.signal });
      if (!response.ok) throw new Error(`HTTP ${response.status}`);
      const json = await response.json();
      setData(json);
    } catch (err) {
      if (err.name !== "AbortError") setError(err);
    } finally {
      setLoading(false);
    }
  }

  loadData();
  return () => controller.abort();
}, [url]);
  • useEffect(fn, deps) — runs fn after the render commits; re-runs whenever a value in deps changes.
  • AbortController — a Web API object whose signal is passed to fetch; calling controller.abort() cancels the in-flight request.
  • async function inside the effect — the effect callback itself cannot be async (it must return a cleanup function or nothing), so an inner async function is declared and called instead.
  • response.okfetch only rejects on network failure, not on HTTP error statuses like 404 or 500, so you must check response.ok yourself.
  • cleanup return value — the function returned from the effect runs before the next effect and on unmount; used here to abort a stale request.

Examples

Example 1: Basic fetch on mount

import { useState, useEffect } from "react";

function PostList() {
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/posts?_limit=5")
      .then((response) => {
        if (!response.ok) throw new Error("Failed to fetch posts");
        return response.json();
      })
      .then((data) => setPosts(data))
      .catch((err) => setError(err.message))
      .finally(() => setLoading(false));
  }, []);

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

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

export default PostList;

Renders: "Loading posts…" briefly, then a bulleted list of five post titles once the request resolves (or an error message if it fails). The empty dependency array [] means this effect runs exactly once, right after the first mount — the classic "fetch on load" pattern.

Example 2: Fetching by a changing prop, with cleanup

import { useState, useEffect } from "react";

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

  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
    setError(null);

    fetch(`https://jsonplaceholder.typicode.com/users/${userId}`, {
      signal: controller.signal,
    })
      .then((response) => {
        if (!response.ok) throw new Error(`User ${userId} not found`);
        return response.json();
      })
      .then((data) => setUser(data))
      .catch((err) => {
        if (err.name !== "AbortError") setError(err.message);
      })
      .finally(() => setLoading(false));

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

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

  return <h3>{user.name} — {user.email}</h3>;
}

export default UserProfile;

Renders: the selected user’s name and email; switching userId quickly (e.g. from a dropdown) shows "Loading user…" then the correct, matching profile every time — never a mismatched one. Because userId is in the dependency array, every change re-runs the effect. The cleanup function aborts the previous request first, so a slow, outdated response can never overwrite a newer one.

Example 3: Extracting the logic into a 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);

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

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

  return { data, loading, error };
}

function TodoList() {
  const { data, loading, error } = useFetch(
    "https://jsonplaceholder.typicode.com/todos?_limit=5"
  );

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

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

export default TodoList;

Renders: a loading message followed by a list of five todo titles. useFetch is a custom hook — a plain function whose name starts with use and that calls other hooks internally. Any component can call useFetch(someUrl) to get the same loading/error/data behavior without duplicating the effect logic.

How it works step by step

On mount: React renders the component with the initial state (loading: true, data: null), commits the DOM, then runs the effect, which starts the fetch. The UI shows the loading branch while the request is in flight.

On response: when the promise resolves, the .then/.catch/.finally handlers call setData, setError, or setLoading. Each call schedules a re-render; React batches these state updates, re-renders the component once with the new values, diffs the resulting tree, and commits the differences (swapping the loading text for the real content, for example).

On a dependency change: if url or userId changes, React first runs the previous effect’s cleanup function (aborting the old request), then runs the effect again with the new value, starting a fresh fetch.

On unmount: React runs the cleanup function one last time. Aborting here prevents a "Can’t perform a React state update on an unmounted component" situation where a late-arriving response tries to call setState on a component that no longer exists.

Common Mistakes

Mistake 1: Missing or wrong dependency array causes an infinite loop

function PostList() {
  const [posts, setPosts] = useState([]);

  useEffect(() => {
    fetch("https://jsonplaceholder.typicode.com/posts")
      .then((res) => res.json())
      .then((data) => setPosts(data));
  });

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

With no dependency array at all, the effect runs after every render. Since setPosts triggers a re-render, and every re-render triggers the effect again, this fetches in an endless loop. The fix is to add [] so it only runs once, after the initial mount:

useEffect(() => {
  fetch("https://jsonplaceholder.typicode.com/posts")
    .then((res) => res.json())
    .then((data) => setPosts(data));
}, []);

Mistake 2: Ignoring race conditions between overlapping requests

useEffect(() => {
  fetch(`/api/users/${userId}`)
    .then((res) => res.json())
    .then((data) => setUser(data));
}, [userId]);

If userId changes from 1 to 2 quickly, both requests are in flight. If the request for 1 happens to resolve after the request for 2, setUser is called last with the wrong user’s data, and the UI silently shows stale content. Use an AbortController (or an "ignore" flag set in cleanup) as shown in Example 2 so a superseded request can never win.

Best Practices

  • Always track loading and error state separately from the data itself, and render distinct UI for each.
  • Check response.ok manually — fetch does not reject on 4xx/5xx responses.
  • List every reactive value the effect reads (like url or userId) in the dependency array so the request re-runs exactly when it should.
  • Cancel in-flight requests in the effect’s cleanup function with AbortController to avoid race conditions and updates after unmount.
  • Extract repeated fetch logic into a custom hook (like useFetch) instead of copy-pasting the same effect into every component.
  • Never call fetch directly in the component body — it must run inside useEffect (or an event handler), not during render.
  • For larger apps, consider a dedicated data-fetching library (such as React Query or SWR) that adds caching, retries, and deduplication on top of this same pattern.

Practice Exercises

  • Build a UserList component that fetches https://jsonplaceholder.typicode.com/users on mount and renders each user’s name in a list, with proper loading and error states.
  • Take the UserProfile component from Example 2, add a <select> element that lets the reader pick a userId from 1 to 5, and confirm that rapidly switching options never shows a mismatched profile.
  • Write a usePosts(limit) custom hook based on useFetch that fetches https://jsonplaceholder.typicode.com/posts?_limit=${limit}, and use it in two different components with different limit values.

Summary

  • fetch returns a promise; always check response.ok before trusting the body.
  • Perform fetches inside useEffect, never directly in the component body.
  • Track data, loading, and error as separate state values.
  • Include every value the effect depends on in its dependency array.
  • Use AbortController in the cleanup function to cancel stale requests and prevent race conditions.
  • Extract shared fetch logic into a custom hook to avoid duplicating the same effect across components.