DNA⚛️ ReactAsync & Data Fetching
🐣HatchlingReactData FetchingAPIRace ConditionsAbortController

Async & Data Fetching

Where to make API calls, how to handle race conditions, stale data, and the patterns that keep your app reliable.

Async & Data Fetching

Almost every React app needs to talk to a server — loading user profiles, fetching product lists, submitting forms. But fetching data in React has subtle traps that catch even experienced developers. Let's learn the right patterns from the start.

Where Should API Calls Go?

Think of it like a restaurant kitchen: you don't start cooking before the customer sits down and orders. In React, the "sitting down" moment is when a component mounts — that's when it appears on screen for the first time.

The Rule: useEffect for Fetching on Mount

useEffect with an empty dependency array runs once after the component first renders. This is the standard place to fetch data:

import { useState, useEffect } from "react";
 
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [userId]);
 
  return <div>{user ? user.name : "Loading..."}</div>;
}

Event-Driven Fetching

Sometimes you fetch data in response to user actions — clicking a button, submitting a form, pressing Enter. In that case, the fetch goes inside the event handler, not in useEffect:

function SearchBar() {
  const [query, setQuery] = useState("");
  const [results, setResults] = useState([]);
 
  const handleSearch = () => {
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then((data) => setResults(data));
  };
 
  return (
    <div>
      <input value={query} onChange={(e) => setQuery(e.target.value)} />
      <button onClick={handleSearch}>Search</button>
      <ul>
        {results.map((item) => (
          <li key={item.id}>{item.name}</li>
        ))}
      </ul>
    </div>
  );
}

Common Mistake: Putting every API call inside useEffect. If the fetch is triggered by a user action (click, submit), put it in the event handler. useEffect is for fetches that should happen automatically when data changes or the component appears.


How to Fetch Data Properly (Loading, Error, Success)

A real fetch has three possible states: loading, error, and success. Always handle all three — your users will thank you.

Think of it like ordering food online: you see a spinner (loading), then either your order confirmation (success) or an error message ("restaurant is closed").

function TodoList() {
  const [todos, setTodos] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    setLoading(true);
    setError(null);
 
    fetch("/api/todos")
      .then((res) => {
        if (!res.ok) throw new Error("Failed to fetch todos");
        return res.json();
      })
      .then((data) => {
        setTodos(data);
        setLoading(false);
      })
      .catch((err) => {
        setError(err.message);
        setLoading(false);
      });
  }, []);
 
  if (loading) return <p>Loading your todos...</p>;
  if (error) return <p>Something went wrong: {error}</p>;
 
  return (
    <ul>
      {todos.map((todo) => (
        <li key={todo.id}>{todo.title}</li>
      ))}
    </ul>
  );
}

Remember: Every fetch should track three pieces of state: data, loading, and error. This is so common that libraries like React Query handle it automatically.


Race Conditions — The Silent Bug

What Is a Race Condition?

Think of it like ordering coffee at a busy café. You order a latte, then change your mind and order a cappuccino. If the barista makes the latte faster, you get the wrong drink — even though you ordered the cappuccino last.

In React, the same thing happens with API calls:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => setUser(data));
  }, [userId]);
 
  return <div>{user?.name}</div>;
}

Imagine userId changes from 1 to 2 quickly. Two requests fire:

  1. Request for user 1 (takes 3 seconds)
  2. Request for user 2 (takes 1 second)

User 2's response arrives first. Then user 1's response arrives and overwrites it. Now you're showing user 1's data even though the component is supposed to show user 2. That's a race condition.

Fix 1: The Cancelled Flag

The simplest fix — use a boolean to ignore stale responses:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
 
  useEffect(() => {
    let cancelled = false;
 
    fetch(`/api/users/${userId}`)
      .then((res) => res.json())
      .then((data) => {
        if (!cancelled) {
          setUser(data);
        }
      });
 
    return () => {
      cancelled = true;
    };
  }, [userId]);
 
  return <div>{user?.name}</div>;
}

When userId changes, React runs the cleanup function (setting cancelled = true) before starting the new effect. The old response arrives but gets ignored.

Fix 2: AbortController (The Proper Way)

AbortController actually cancels the network request — the browser stops waiting for the response entirely. This is better because it saves bandwidth and resources.

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
 
  useEffect(() => {
    const controller = new AbortController();
 
    setLoading(true);
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => res.json())
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== "AbortError") {
          console.error("Fetch failed:", err);
          setLoading(false);
        }
      });
 
    return () => controller.abort();
  }, [userId]);
 
  if (loading) return <p>Loading...</p>;
  return <div>{user?.name}</div>;
}

Think of AbortController like hanging up the phone. The cancelled flag is like covering your ears — the call is still going, you're just ignoring it.

Fix 3: isFetching Ref (Preventing Overlapping Calls)

Sometimes you want to prevent a new fetch from even starting if one is already in progress:

import { useState, useEffect, useRef } from "react";
 
function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const isFetching = useRef(false);
 
  useEffect(() => {
    if (isFetching.current) return;
 
    isFetching.current = true;
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then((data) => {
        setResults(data);
        isFetching.current = false;
      })
      .catch(() => {
        isFetching.current = false;
      });
  }, [query]);
 
  return (
    <ul>
      {results.map((item) => (
        <li key={item.id}>{item.name}</li>
      ))}
    </ul>
  );
}

Remember: AbortController is the gold standard for preventing race conditions. Use the cancelled flag as a simpler alternative, and the isFetching ref when you need to block duplicate calls entirely.


The Stale Data Problem

What Is Stale Data?

Think of it like a weather app showing yesterday's forecast. The data was correct when you fetched it, but the world has moved on. Stale data is data that no longer reflects reality.

In React, this happens when:

  • A user leaves a tab open for hours, then comes back
  • Data was fetched on mount but the server has newer information
  • Multiple users edit the same resource simultaneously

How to Handle Stale Data

Strategy 1: Refetch on window focus

When a user switches back to your tab, re-fetch the data:

function useRefetchOnFocus(fetchFn) {
  useEffect(() => {
    const handleFocus = () => fetchFn();
 
    window.addEventListener("focus", handleFocus);
    return () => window.removeEventListener("focus", handleFocus);
  }, [fetchFn]);
}
 
function UserDashboard() {
  const [data, setData] = useState(null);
 
  const fetchData = () => {
    fetch("/api/dashboard")
      .then((res) => res.json())
      .then(setData);
  };
 
  useEffect(() => {
    fetchData();
  }, []);
 
  useRefetchOnFocus(fetchData);
 
  return <div>{data ? `Welcome, ${data.name}` : "Loading..."}</div>;
}

Strategy 2: Polling at intervals

For data that changes frequently (stock prices, notifications):

function NotificationBell() {
  const [count, setCount] = useState(0);
 
  useEffect(() => {
    const fetchCount = () => {
      fetch("/api/notifications/unread-count")
        .then((res) => res.json())
        .then((data) => setCount(data.count));
    };
 
    fetchCount();
    const interval = setInterval(fetchCount, 30000);
 
    return () => clearInterval(interval);
  }, []);
 
  return <span>Notifications: {count}</span>;
}

Strategy 3: Stale-while-revalidate

Show the old data immediately, then update when fresh data arrives:

function ProductList() {
  const [products, setProducts] = useState(() => {
    const cached = localStorage.getItem("products");
    return cached ? JSON.parse(cached) : [];
  });
 
  useEffect(() => {
    fetch("/api/products")
      .then((res) => res.json())
      .then((freshData) => {
        setProducts(freshData);
        localStorage.setItem("products", JSON.stringify(freshData));
      });
  }, []);
 
  return (
    <ul>
      {products.map((p) => (
        <li key={p.id}>{p.name}</li>
      ))}
    </ul>
  );
}

Remember: Stale data is inevitable. The question is how you handle it. Refetch on focus, poll for critical data, and use stale-while-revalidate to keep the UI responsive.


Deduplication of API Calls

The Problem

Think of it like a classroom where 30 students all ask the teacher the same question at the same time. It would be much better if one student asked and shared the answer with everyone.

When multiple components request the same data simultaneously, you get duplicate API calls:

function Header() {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch("/api/me").then((r) => r.json()).then(setUser);
  }, []);
  return <div>Hello, {user?.name}</div>;
}
 
function Sidebar() {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetch("/api/me").then((r) => r.json()).then(setUser);
  }, []);
  return <div>Role: {user?.role}</div>;
}

Both Header and Sidebar fetch /api/me — that's two identical requests.

Fix: A Simple Request Cache

const cache = new Map();
 
function fetchWithCache(url) {
  if (cache.has(url)) {
    return cache.get(url);
  }
 
  const promise = fetch(url)
    .then((res) => res.json())
    .then((data) => {
      setTimeout(() => cache.delete(url), 30000);
      return data;
    });
 
  cache.set(url, promise);
  return promise;
}
 
function Header() {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetchWithCache("/api/me").then(setUser);
  }, []);
  return <div>Hello, {user?.name}</div>;
}
 
function Sidebar() {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetchWithCache("/api/me").then(setUser);
  }, []);
  return <div>Role: {user?.role}</div>;
}

Both components share the same in-flight request. Only one network call is made.

Common Mistake: Building complex caching and deduplication from scratch. Libraries like React Query (TanStack Query) handle deduplication, caching, stale data, retries, and more — out of the box.


Best Practices for Data Fetching

1. Always Handle Loading and Error States

Never assume a fetch will succeed or complete quickly.

2. Cancel Requests on Cleanup

Use AbortController in every useEffect that fetches data. Prevents race conditions and memory leaks.

3. Don't Fetch in the Render Body

function Bad() {
  fetch("/api/data"); // fires on EVERY render
  return <div>oops</div>;
}
 
function Good() {
  useEffect(() => {
    fetch("/api/data");
  }, []);
  return <div>correct</div>;
}

4. Keep Fetch Logic Separate

Extract data fetching into custom hooks so components stay focused on rendering:

function useUser(userId) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
 
  useEffect(() => {
    const controller = new AbortController();
    setLoading(true);
 
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error("Failed to load user");
        return res.json();
      })
      .then((data) => {
        setUser(data);
        setLoading(false);
      })
      .catch((err) => {
        if (err.name !== "AbortError") {
          setError(err.message);
          setLoading(false);
        }
      });
 
    return () => controller.abort();
  }, [userId]);
 
  return { user, loading, error };
}
 
function UserProfile({ userId }) {
  const { user, loading, error } = useUser(userId);
 
  if (loading) return <p>Loading...</p>;
  if (error) return <p>Error: {error}</p>;
  return <h1>{user.name}</h1>;
}

5. Consider a Data Fetching Library

For production apps, consider using a library instead of raw fetch + useEffect:

LibraryBest For
React Query (TanStack Query)Caching, deduplication, background refetching, pagination
SWRLightweight stale-while-revalidate pattern
RTK QueryApps already using Redux Toolkit

These libraries give you loading/error states, caching, deduplication, retries, and stale data handling — all the problems we discussed above — without writing boilerplate.

Remember: Start with useEffect + fetch to understand the fundamentals. Then graduate to a library like React Query when you see the pain points in real projects. Understanding the "why" behind libraries makes you a better developer.


Interview Corner

Where should you make API calls in React? In useEffect for data that should load automatically. In event handlers for user-triggered actions. Never in the render body.

What is a race condition in React? When multiple async operations compete and the wrong one "wins" — like two API responses arriving out of order, causing stale data to overwrite fresh data.

How do you prevent race conditions? Use AbortController to cancel outdated requests, a cancelled flag to ignore stale responses, or an isFetching ref to prevent overlapping calls.

What is the stale-while-revalidate pattern? Show cached (possibly stale) data immediately for a fast UI, then refetch in the background and update when fresh data arrives.

Why extract fetch logic into custom hooks? It separates data concerns from rendering concerns, makes the logic reusable across components, and makes testing easier.