Fetching Data in React - The Right Way

10 min read
React Data Fetching Tutorial

Data fetching is one of the first real challenges you face in React. It seems simple—just fetch some data and display it. But handling loading states, errors, and race conditions correctly? That's where most developers struggle.

Let me show you how to do it right.

The Naive Approach (Don't Do This)

When I first started with React, I wrote code like this. It looks clean, but it's broken in subtle ways.

// ❌ DON'T DO THIS
function ProductList() {
  const [products, setProducts] = useState([]);

  useEffect(() => {
    fetch('/api/products')
      .then(res => res.json())
      .then(data => setProducts(data));
  }, []);

  return (
    
{products.map(product => (
{product.name}
))}
); }

What's wrong? No loading state. No error handling. No cleanup. If the component unmounts before the fetch completes, you'll get a "Can't perform a React state update on an unmounted component" warning. And if you're fetching based on a prop that changes, you might have a race condition.

Aha moment: I once spent hours debugging why my product list showed the wrong data after quickly switching between categories. Turns out, the second request finished before the first one, so I displayed stale data. Race conditions are real.

Step 1: Add Loading and Error States

First, let's handle the basics—loading and error states. Users need feedback when data is loading or when something goes wrong.

interface Product {
  id: number;
  name: string;
  price: number;
  description: string;
}

function ProductList() {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    fetch('/api/products')
      .then(res => {
        if (!res.ok) {
          throw new Error(`HTTP error! status: ${res.status}`);
        }
        return res.json();
      })
      .then(data => {
        setProducts(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, []);

  if (loading) return 
Loading products...
; if (error) return
Error: {error}
; return (
{products.map(product => ( ))}
); }

Better. Now users see "Loading products..." while data fetches, and an error message if something fails. But we're still missing cleanup and race condition handling.

Step 2: Handle Race Conditions

Race conditions happen when you make multiple requests and they complete in a different order than you sent them. Imagine fetching products by category—if the user switches categories quickly, older requests might finish after newer ones.

function ProductList({ category }: { category: string }) {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    let ignore = false; // Cleanup flag

    setLoading(true);
    setError(null);

    fetch(`/api/products?category=${category}`)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
        return res.json();
      })
      .then(data => {
        if (!ignore) { // Only update if not cancelled
          setProducts(data);
          setLoading(false);
        }
      })
      .catch(err => {
        if (!ignore) {
          setError(err.message);
          setLoading(false);
        }
      });

    return () => {
      ignore = true; // Mark as cancelled on cleanup
    };
  }, [category]);

  if (loading) return 
Loading products...
; if (error) return
Error: {error}
; return (

Products in {category}

{products.map(product => ( ))}
); }

The ignore flag is crucial. When the effect re-runs (because category changed), the cleanup function runs first and sets ignore = true. This tells the old fetch to ignore its result.

Key insight: You can't actually cancel a fetch request, but you can ignore its result. The ignore flag prevents stale data from being displayed.

Step 3: Use AbortController for Real Cancellation

While the ignore flag prevents updating state with stale data, the request still completes in the background. For better performance, cancel the actual request using AbortController.

function ProductList({ category }: { category: string }) {
  const [products, setProducts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    setLoading(true);
    setError(null);

    fetch(`/api/products?category=${category}`, {
      signal: abortController.signal
    })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
        return res.json();
      })
      .then(data => {
        setProducts(data);
        setLoading(false);
      })
      .catch(err => {
        // Ignore abort errors
        if (err.name === 'AbortError') return;
        
        setError(err.message);
        setLoading(false);
      });

    return () => {
      abortController.abort(); // Cancel the request
    };
  }, [category]);

  if (loading) return 
Loading products...
; if (error) return
Error: {error}
; return (

Products in {category}

{products.map(product => ( ))}
); }

Now when the component unmounts or category changes, we actually abort the HTTP request. This saves bandwidth and prevents unnecessary work.

Step 4: Extract Into a Custom Hook

You'll fetch data in many components. Don't repeat this logic everywhere—extract it into a reusable hook.

function useFetch(url: string) {
  const [data, setData] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    setLoading(true);
    setError(null);

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

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

  return { data, loading, error };
}

// Usage
function ProductList({ category }: { category: string }) {
  const { data: products, loading, error } = useFetch(
    `/api/products?category=${category}`
  );

  if (loading) return 
Loading products...
; if (error) return
Error: {error}
; if (!products) return null; return (

Products in {category}

{products.map(product => ( ))}
); }

Much cleaner. The hook encapsulates all the complexity—loading, error handling, cleanup, and cancellation. Your component just uses the data.

Step 5: Better Loading States

A simple "Loading..." text is boring. Let's add skeleton screens for better UX.

function ProductSkeleton() {
  return (
    
); } function ProductList({ category }: { category: string }) { const { data: products, loading, error } = useFetch( `/api/products?category=${category}` ); if (error) { return (

Failed to load products: {error}

); } if (loading) { return (
{Array.from({ length: 6 }).map((_, i) => ( ))}
); } if (!products || products.length === 0) { return
No products found in {category}
; } return (
{products.map(product => ( ))}
); }

Skeleton screens give users a preview of the layout while data loads. It feels faster than a spinner and prevents layout shift.

Common Mistakes to Avoid

Forgetting the dependency array: If you omit the dependency array in useEffect, your fetch runs on every render. If you pass an empty array but reference props/state, you'll have stale closures.

Not handling errors: Networks fail. APIs return errors. Always handle both and show users something meaningful.

Ignoring cleanup: Not cleaning up effects causes memory leaks and "Can't perform state update on unmounted component" warnings.

Not checking response.ok: Fetch doesn't reject on HTTP errors (404, 500, etc.). You must check response.ok manually.

Aha moment: I once forgot to check response.ok and my app tried to parse an HTML error page as JSON. The error message was completely unhelpful until I realized fetch considers 404 a "successful" request.

Why You Should Use TanStack Query Instead

Everything I've shown you works, but it's a lot of boilerplate. And we haven't even covered caching, background refetching, optimistic updates, or request deduplication.

This is where TanStack Query (formerly React Query) shines. It handles all of this automatically:

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

function ProductList({ category }: { category: string }) {
  const { data: products, isLoading, error } = useQuery({
    queryKey: ['products', category],
    queryFn: async () => {
      const res = await fetch(`/api/products?category=${category}`);
      if (!res.ok) throw new Error(`HTTP error! status: ${res.status}`);
      return res.json();
    },
  });

  if (isLoading) return 
Loading...
; if (error) return
Error: {error.message}
; return (
{products.map(product => ( ))}
); }

What TanStack Query gives you for free:

Automatic caching: Fetches once, reuses data across components. No duplicate requests.

Background refetching: Keeps data fresh by refetching when the window refocuses or network reconnects.

Request deduplication: If multiple components request the same data simultaneously, only one request is made.

Stale-while-revalidate: Shows cached data immediately while fetching fresh data in the background.

Built-in retry logic: Automatically retries failed requests with exponential backoff.

Proper cleanup: Handles all the AbortController and cleanup logic automatically.

My recommendation: Learn how to fetch data with useEffect first—you need to understand the fundamentals. But for production apps, use TanStack Query. It's worth the learning curve.

Final Thoughts

Data fetching in React is straightforward once you understand the patterns. Handle loading and error states properly. Prevent race conditions with cleanup. Use AbortController to cancel requests. Extract logic into custom hooks.

But don't reinvent the wheel. Libraries like TanStack Query solve these problems better than you or I ever will. Use them.

The goal isn't to write clever fetching code—it's to build reliable applications that handle the messy reality of network requests gracefully.