ReactMedium

Common data fetching pitfalls in React

01

The Short Answer

Data fetching in React is deceptively tricky. The most common pitfalls include race conditions (stale responses overwriting fresh ones), memory leaks (setting state after unmount), waterfall requests (sequential fetches that should be parallel), missing error handling, and not considering loading/empty states. These bugs are subtle — the app works most of the time but breaks under real-world conditions like slow networks, fast navigation, or component remounting.

02

Race Conditions

The most insidious pitfall: when a user triggers multiple fetches quickly (typing in search, navigating between pages), responses can arrive out of order. Without cancellation, the last response to arrive wins — which might be from an older, stale request. The UI shows data for a previous query while the user expects the latest.

race-condition.tsxtypescript
// ❌ Race condition — no cancellation
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<Item[]>([]);

  useEffect(() => {
    // If query changes rapidly, multiple fetches are in-flight
    // Response for "jav" might arrive AFTER response for "javascript"
    fetch(`/api/search?q=${query}`)
      .then((res) => res.json())
      .then((data) => setResults(data)); // Stale data overwrites fresh!
  }, [query]);
}

// ✅ Fixed — AbortController cancels stale requests
function SearchResults({ query }: { query: string }) {
  const [results, setResults] = useState<Item[]>([]);

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

    fetch(`/api/search?q=${query}`, { signal: controller.signal })
      .then((res) => res.json())
      .then((data) => setResults(data))
      .catch((err) => {
        if (err.name !== 'AbortError') console.error(err);
      });

    return () => controller.abort(); // Cancel previous request
  }, [query]);
}

The cleanup function runs before the next effect, aborting the previous request. Only the latest request's response will be applied to state. Libraries like React Query handle this automatically.

03

Setting State After Unmount

If a component unmounts while a fetch is in-flight (user navigates away), the response arrives and tries to call setState on a component that no longer exists. This causes memory leaks and the classic React warning. The fix is the same as race conditions — abort the request on cleanup.

unmount-leak.tsxtypescript
// ❌ Memory leak — setState called after unmount
useEffect(() => {
  fetch('/api/data')
    .then((res) => res.json())
    .then((data) => setData(data)); // Component might be gone!
}, []);

// ✅ Fixed — abort on unmount
useEffect(() => {
  const controller = new AbortController();

  fetch('/api/data', { signal: controller.signal })
    .then((res) => res.json())
    .then((data) => setData(data))
    .catch((err) => {
      if (err.name !== 'AbortError') setError(err);
    });

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

Waterfall Requests

Waterfalls happen when components fetch data sequentially — a child can't start fetching until its parent renders. Each level of nesting adds latency. If parent takes 200ms and child takes 300ms, the user waits 500ms total instead of 300ms (if fetched in parallel).

waterfall.tsxtypescript
// ❌ Waterfall — child can't fetch until parent renders
function UserPage({ userId }: { userId: string }) {
  const { data: user } = useFetch(`/api/users/${userId}`);
  if (!user) return <Spinner />;

  // UserPosts can't even START fetching until user loads
  return <UserPosts userId={user.id} />;
}

function UserPosts({ userId }: { userId: string }) {
  const { data: posts } = useFetch(`/api/users/${userId}/posts`);
  if (!posts) return <Spinner />;
  return <PostList posts={posts} />;
}

// ✅ Fixed — fetch in parallel at the top level
function UserPage({ userId }: { userId: string }) {
  const { data: user } = useFetch(`/api/users/${userId}`);
  const { data: posts } = useFetch(`/api/users/${userId}/posts`);

  if (!user || !posts) return <Spinner />;

  return (
    <div>
      <UserProfile user={user} />
      <PostList posts={posts} />
    </div>
  );
}

The fix is to hoist data fetching to the highest component that knows what data is needed, or use a framework that supports parallel data loading (Next.js Server Components, Remix loaders). React Query's useQueries also fetches multiple queries in parallel.

05

Missing Loading and Error States

A common shortcut is handling only the success case — no loading indicator, no error message, no empty state. This creates a poor user experience: the page appears broken during loading, errors are silently swallowed, and empty results show a blank screen with no explanation.

complete-states.tsxtypescript
// ❌ Only handles success
function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  useEffect(() => {
    fetch('/api/users').then((r) => r.json()).then(setUsers);
  }, []);
  return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}

// ✅ Handles all states
function UserList() {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

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

    fetch('/api/users', { signal: controller.signal })
      .then((res) => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(setUsers)
      .catch((err) => {
        if (err.name !== 'AbortError') setError(err);
      })
      .finally(() => setLoading(false));

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

  if (loading) return <UserListSkeleton />;
  if (error) return <ErrorCard message={error.message} onRetry={() => {}} />;
  if (users.length === 0) return <EmptyState message="No users found" />;

  return <ul>{users.map((u) => <li key={u.id}>{u.name}</li>)}</ul>;
}
06

Fetching in Event Handlers vs useEffect

Not all fetches belong in useEffect. Data that loads on mount or when dependencies change belongs in effects. Data triggered by user actions (form submit, button click) belongs in event handlers. Putting user-triggered fetches in effects leads to unnecessary complexity and bugs.

ScenarioWhere to fetchWhy
Page data on mountuseEffectNeeds to run on render, cleanup on unmount
Data when prop changesuseEffect with dependencyReact to prop/state changes
Form submissionEvent handlerUser-triggered, not tied to render cycle
Button click actionEvent handlerOne-time action, not a synchronization
Search as you typeuseEffect (debounced)Reacts to input state changes
07

No Caching or Deduplication

Without a caching layer, every component mount triggers a fresh network request — even for data that was just fetched seconds ago. If multiple components need the same data, each makes its own request. This wastes bandwidth, increases server load, and makes the app feel slow with unnecessary loading spinners.

Signs you need a caching library

  • Users see loading spinners when navigating back to a previously visited page
  • Multiple components fetch the same endpoint independently
  • You're manually managing stale data and refetch logic
  • You need optimistic updates for mutations
  • You want background refetching without showing loading states
08

Why Interviewers Ask This

This question reveals production experience. Anyone can write a basic fetch in useEffect — but handling race conditions, waterfalls, error states, and caching requires real-world debugging experience. Interviewers want to see that you've encountered these bugs, understand why they happen, and know the solutions (AbortController, parallel fetching, caching libraries, proper state machines). It's a practical question that separates tutorial-level knowledge from battle-tested expertise.

Quick Revision Cheat Sheet

Race conditions: AbortController in useEffect cleanup — cancel stale requests

Memory leaks: Same fix — abort on unmount prevents setState after unmount

Waterfalls: Hoist fetches to parent, use Promise.all, or parallel loaders

Error handling: Always handle loading, error, empty, and success states

Caching: Use React Query/SWR for deduplication and stale-while-revalidate

Event handlers vs effects: User actions → handler; sync with state → effect