Fetching Data in React

Most React apps need to load data from an API. The basic pattern uses useEffect to trigger a fetch when the component mounts, and useState to track the data, loading state, and any errors.

This page covers the fundamentals of that pattern, its limitations, and when to reach for a library like TanStack Query instead.

The basic pattern

You need three pieces of state for any data-fetching component:

  • The data itself (usually starts as null or an empty array)
  • A loading flag to show a spinner or placeholder
  • An error value to show a message if the request fails
import { useState, useEffect } from 'react';

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

  useEffect(() => {
    fetch('https://jsonplaceholder.typicode.com/posts?_limit=5')
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP error: ${res.status}`);
        return res.json();
      })
      .then((data) => setPosts(data))
      .catch((err) => setError(err.message))
      .finally(() => setIsLoading(false));
  }, []);

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

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

export default PostList;

A few things to note:

  • The empty array [] dependency means the effect runs once after the first render, which is what you want for an initial data load.
  • if (!res.ok) catches HTTP errors like 404 and 500. fetch() does not throw on those automatically.
  • .finally() sets isLoading to false whether the request succeeded or failed.
  • The loading and error checks before the return keep the main render path clean.

Fetching when a value changes

If you need to re-fetch when a prop or state value changes, add it to the dependency array. The effect re-runs whenever that value changes.

import { useState, useEffect } from 'react';

function PostDetail({ postId }) {
  const [post, setPost] = useState(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    setIsLoading(true);
    setError(null);

    fetch(`https://jsonplaceholder.typicode.com/posts/${postId}`)
      .then((res) => {
        if (!res.ok) throw new Error(`Post not found`);
        return res.json();
      })
      .then((data) => setPost(data))
      .catch((err) => setError(err.message))
      .finally(() => setIsLoading(false));
  }, [postId]);

  if (isLoading) return <p>Loading post {postId}...</p>;
  if (error) return <p>Error: {error}</p>;
  if (!post) return null;

  return (
    <article>
      <h2>{post.title}</h2>
      <p>{post.body}</p>
    </article>
  );
}

export default PostDetail;

Reset isLoading and error at the start of the effect so the previous state does not flash on screen while the new request is in flight.

Avoiding race conditions with AbortController

If a fetch depends on user input (like a search box), the user might type quickly and trigger several requests in a row. The responses can arrive out of order. You might display results for an earlier query while a later one is still loading.

The fix is to cancel the previous request when the effect re-runs.

import { useState, useEffect } from 'react';

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [isLoading, setIsLoading] = useState(false);

  useEffect(() => {
    if (!query) {
      setResults([]);
      return;
    }

    const controller = new AbortController();
    setIsLoading(true);

    fetch(`https://jsonplaceholder.typicode.com/posts?q=${query}`, {
      signal: controller.signal,
    })
      .then((res) => res.json())
      .then((data) => setResults(data))
      .catch((err) => {
        if (err.name !== 'AbortError') {
          console.error('Fetch failed:', err.message);
        }
      })
      .finally(() => setIsLoading(false));

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

  if (isLoading) return <p>Searching...</p>;

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

The cleanup function returned from useEffect calls controller.abort(). React runs cleanups before re-running the effect, so the previous request is cancelled before the new one starts. The AbortError check prevents logging a noise error when a request is intentionally cancelled.

Refactoring into a custom hook

Once you repeat this pattern in a second component, extract it into a custom hook.

// hooks/useFetch.js
import { useState, useEffect } from 'react';

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

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

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

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

  return { data, isLoading, error };
}

export default useFetch;

Any component can now fetch data in one line:

import useFetch from './hooks/useFetch';

function PostList() {
  const { data: posts, isLoading, error } = useFetch(
    'https://jsonplaceholder.typicode.com/posts?_limit=5'
  );

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

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

See Custom Hooks for more on this pattern.

The limitations of fetch-in-useEffect

This approach works for learning and small projects, but it has real limitations:

  • No caching. Every time the component mounts, it fetches again. Navigate away and back and the data loads from scratch.
  • No deduplication. If two components on the page call useFetch with the same URL, two separate requests go out.
  • No background refetching. If the user leaves the tab and comes back, stale data is not refreshed automatically.
  • No retry on failure. A failed request stays failed until the user refreshes the page.
  • Race conditions still happen if you are not careful with AbortController.

For a learning project or a simple internal tool, these trade-offs are acceptable. For a production app, they add up quickly.

TanStack Query for production apps

TanStack Query handles caching, deduplication, background refetching, retries, and loading/error states automatically. The same PostList component becomes much simpler.

Install it:

npm install @tanstack/react-query

Wrap your app in a QueryClientProvider once (usually in main.jsx):

import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import App from './App';
import { createRoot } from 'react-dom/client';

const queryClient = new QueryClient();

createRoot(document.getElementById('root')).render(
  <QueryClientProvider client={queryClient}>
    <App />
  </QueryClientProvider>
);

Then use useQuery in any component:

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

function PostList() {
  const { data: posts, isLoading, error } = useQuery({
    queryKey: ['posts'],
    queryFn: () =>
      fetch('https://jsonplaceholder.typicode.com/posts?_limit=5').then(
        (res) => res.json()
      ),
  });

  if (isLoading) return <p>Loading...</p>;
  if (error) return <p>Error loading posts.</p>;

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

The queryKey identifies this query. TanStack Query caches the result under that key, so if another component uses the same key, it reuses the cached data instead of fetching again.

Use useEffect for simple cases and learning. Use TanStack Query for anything that will go into production.

Common mistakes

Forgetting the dependency array.

Without [] as the second argument, the effect runs after every render. If the fetch also updates state, that triggers another render, which triggers another fetch, creating an infinite loop.

// Wrong: runs on every render
useEffect(() => {
  fetch('/api/data').then(/* ... */);
});

// Correct: runs once on mount
useEffect(() => {
  fetch('/api/data').then(/* ... */);
}, []);

Not checking response.ok before calling response.json().

fetch() only rejects on network failure. An HTTP 404 or 500 resolves successfully with response.ok set to false. If you call response.json() on an error response without checking first, you may get unexpected data or a parse error.

Setting state after the component unmounts.

If a component unmounts while a fetch is in flight (the user navigates away), calling setState in the .then() will log a warning and may cause bugs. The AbortController pattern prevents this by cancelling the request on cleanup.

Fetching inside a loop or outside useEffect.

Every render calls any code outside a hook or event handler. If you call fetch() directly at the top level of a component, it fires on every render. Always put fetch calls inside useEffect or an event handler.