How to Fetch API Data in React

Fetch API data in a React component with the browser's fetch function and useEffect. Store JSON in state and avoid stale responses with cleanup.

6 min read

Fetching API data in React means sending a request from a component and putting the response into state. You can fetch data in React with the browser's fetch function and a useEffect call, with no extra package. This guide builds that pattern step by step, with a loading state and cleanup that stops stale responses from winning.

Run the request in an Effect

A fetch starts because the component appeared, so it belongs in an Effect rather than during render. The Effect below runs once, reads a JSON endpoint, and stores the result in the posts state.

App.jsxApp.jsx
import { useEffect, useState } from "react";
 
export default function Posts() {
  const [posts, setPosts] = useState([]);
  useEffect(() => {
    fetch("/api/posts")
      .then((res) => res.json())
      .then((data) => setPosts(data));
  }, []);
}

After the first render, React runs the Effect. The fetch promise resolves with a Response, the next call parses it as JSON, and the final callback saves the array into posts.

The empty dependency array means the request runs once instead of on every render. A changing endpoint would belong in that array, but a static URL keeps it empty.

Guard against stale responses

The plain version has a bug. If the component unmounts before the response arrives, the callback still calls setPosts on a component that is gone.

If the component later requests different data, two responses can race and the older one can win. An ignore flag fixes both problems.

App.jsxApp.jsx
useEffect(() => {
  let ignore = false;
  fetch("/api/posts")
    .then((res) => res.json())
    .then((data) => {
      if (!ignore) setPosts(data);
    });
  return () => {
    ignore = true;
  };
}, []);

The cleanup runs before the Effect re-runs and when the component unmounts. Setting ignore to true tells an old response to drop its data, so only the newest request can update state.

This is cheap insurance and costs nothing in production. The same stale response problem is covered in how to fetch data with useEffect without creating race conditions.

Check response.ok before reading the body

fetch rejects only when the network fails, not when the server returns an error status. A 404 or 500 still resolves into a Response, so check the status yourself before calling json.

App.jsxApp.jsx
fetch("/api/posts").then((response) => {
  if (!response.ok) {
    throw new Error(`Request failed with ${response.status}`);
  }
  return response.json();
});

response.ok is true for status codes in the 200 range. Throwing here pushes the failure into the promise chain, where an error state can catch it instead of silently rendering an empty list.

Treating a non-2xx response as an error keeps a broken request from looking like missing data. If the server sends a JSON error body, read it in the catch block and show that message instead of a generic failure.

Show a loading state

A component that fetches data should tell the user what is happening. Silence makes a failure look like a missing feature. Add a status state next to posts, set it to "loading", "success", or "error" at each step of the fetch, and render a message for each state instead of leaving the screen blank.

App.jsxApp.jsx
const [status, setStatus] = useState("loading");
 
if (status === "loading") return <p role="status">Loading posts...</p>;
if (status === "error") return <p role="alert">Could not load posts.</p>;
return (
  <ul>
    {posts.map((post) => (
      <li key={post.id}>{post.title}</li>
    ))}
  </ul>
);

Set status to loading before the request, success after the data arrives, and error when the request fails. Keep status as a single string rather than separate boolean flags, so the states can never contradict each other.

The role attributes announce these changes to screen readers. A full breakdown is in how to handle loading, error, empty, and success states.

When to use a data library instead

Fetching inside an Effect is fine for a small client-only app, but it has limits. Effects do not run on the server, so the initial HTML has no data. There is no caching, so navigating back re-fetches.

There is no deduplication, so two components requesting the same URL each run their own request. The React documentation recommends a framework's built-in data fetching or a client-side cache such as TanStack Query, SWR, or React Router for anything beyond simple cases.

You do not have to rewrite your component to benefit from this later. Moving the fetch into a custom Hook or setting up TanStack Query in React keeps the same UI while adding caching and request deduplication underneath. The visible screen stays the same, while repeated visits and shared requests become faster.

Rune AI

Rune AI

Key Insights

  • Run the request in useEffect, not during render.
  • Check response.ok before reading JSON.
  • Store the parsed data in state.
  • Use an ignore flag in cleanup to drop stale responses.
  • Show a loading state while the request runs.
RunePowered by Rune AI

Frequently Asked Questions

Should I fetch data in an Effect?

Yes for a client-only React app. The request runs because the component appeared, so an Effect is the right place. Frameworks and data libraries like TanStack Query offer more efficient built-in fetching for larger apps.

Why does fetch not throw on a 404?

fetch rejects only on network failures, not on HTTP error statuses. Check response.ok or the status code yourself and treat non-2xx responses as errors.

Do I need to cancel the request?

You should at least ignore stale responses with a flag in the Effect cleanup. AbortController also cancels the actual network request, which is covered in its own guide.

Conclusion

Fetch API data with fetch inside useEffect, store the parsed JSON in state, and guard against stale responses with an ignore flag in cleanup. For larger apps, move to a data library that adds caching and deduplication.