Episode 2 — React Frontend Architecture NextJS / 2.8 — useEffect Deep Dive

2.8.d — Data Fetching Pattern with useEffect

In one sentence: Data fetching with useEffect requires handling loading states, error states, race conditions, and request cancellation — getting it right is the gateway to understanding why libraries like TanStack Query exist.

Navigation: ← Cleanup Functions · Next → Practical Example


Table of Contents

  1. The Simplest Fetch
  2. Adding Loading and Error States
  3. The Discriminated Union Pattern
  4. useReducer for Complex State
  5. Race Conditions Explained
  6. Fixing Race Conditions with AbortController
  7. Fixing Race Conditions with Boolean Flags
  8. Building a Reusable useFetch Hook
  9. Caching Fetched Data
  10. Retry Logic
  11. Pagination and Infinite Scroll
  12. The Evolution: Why TanStack Query Exists
  13. When to Use useEffect vs TanStack Query
  14. Anti-Patterns to Avoid
  15. Key Takeaways

1. The Simplest Fetch

The absolute minimum data fetch in React:

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

What's Wrong With This?

Problems:
  1. ❌ No loading state (just shows "Loading..." until data arrives)
  2. ❌ No error handling (silently fails on network error)
  3. ❌ No race condition handling (fast navigation = stale data)
  4. ❌ No request cancellation (wasted bandwidth)
  5. ❌ No HTTP error handling (200 ≠ success always)
  6. ❌ No caching (re-fetches on every mount)
  7. ❌ setState on unmounted component possible

Let's fix each problem systematically.


2. Adding Loading and Error States

The Three State Variables Pattern

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    setLoading(true);
    setError(null);
    
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) {
          throw new Error(`HTTP error! Status: ${res.status}`);
        }
        return res.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        setError(err.message);
        setLoading(false);
      });
  }, [userId]);
  
  if (loading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error} />;
  if (!user) return null;
  
  return (
    <div>
      <h1>{user.name}</h1>
      <p>{user.email}</p>
    </div>
  );
}

The Problem with Separate State Variables

// Can you have loading=true AND error="something" at the same time?
// Can you have loading=false AND user=null AND error=null?
// These "impossible states" are bugs waiting to happen.

setLoading(true);
setError(null);   // Must remember to reset error!
setUser(null);     // Must remember to reset user!
// If you forget any reset, you show stale data alongside loading spinner

3. The Discriminated Union Pattern

Instead of separate booleans, use a single state variable that represents ALL possible states:

// State machine: one of four possible states
// { status: 'idle' }
// { status: 'loading' }
// { status: 'success', data: User }
// { status: 'error', error: string }

function UserProfile({ userId }) {
  const [state, setState] = useState({ status: 'idle' });
  
  useEffect(() => {
    setState({ status: 'loading' });
    
    fetch(`/api/users/${userId}`)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        setState({ status: 'success', data });
      })
      .catch(err => {
        setState({ status: 'error', error: err.message });
      });
  }, [userId]);
  
  switch (state.status) {
    case 'idle':
      return null;
    case 'loading':
      return <LoadingSpinner />;
    case 'error':
      return <ErrorMessage message={state.error} />;
    case 'success':
      return (
        <div>
          <h1>{state.data.name}</h1>
          <p>{state.data.email}</p>
        </div>
      );
  }
}

Why This Is Better

Separate states:                 Discriminated union:
┌─────────┬─────┬──────┐        ┌────────────────────┐
│ loading │ data│ error│        │ status = 'idle'    │
├─────────┼─────┼──────┤        │ status = 'loading' │
│ true    │ null│ null │ ✓      │ status = 'success' │ ← data guaranteed
│ false   │ {}  │ null │ ✓      │ status = 'error'   │ ← error guaranteed
│ false   │ null│ "err"│ ✓      └────────────────────┘
│ true    │ {}  │ "err"│ ✗ BUG  
│ false   │ null│ null │ ✗ ???   No impossible states!
│ true    │ {}  │ null │ ✗ BUG  
└─────────┴─────┴──────┘        
  6 combinations (3 invalid)     4 states (all valid)

4. useReducer for Complex State

For complex fetch state with more actions:

const initialState = {
  status: 'idle',  // 'idle' | 'loading' | 'success' | 'error'
  data: null,
  error: null,
};

function fetchReducer(state, action) {
  switch (action.type) {
    case 'FETCH_START':
      return { status: 'loading', data: null, error: null };
    case 'FETCH_SUCCESS':
      return { status: 'success', data: action.payload, error: null };
    case 'FETCH_ERROR':
      return { status: 'error', data: null, error: action.payload };
    case 'RESET':
      return initialState;
    default:
      throw new Error(`Unhandled action: ${action.type}`);
  }
}

function UserProfile({ userId }) {
  const [state, dispatch] = useReducer(fetchReducer, initialState);
  
  useEffect(() => {
    const controller = new AbortController();
    
    dispatch({ type: 'FETCH_START' });
    
    fetch(`/api/users/${userId}`, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => dispatch({ type: 'FETCH_SUCCESS', payload: data }))
      .catch(err => {
        if (err.name !== 'AbortError') {
          dispatch({ type: 'FETCH_ERROR', payload: err.message });
        }
      });
    
    return () => controller.abort();
  }, [userId]);
  
  // Render based on state.status...
}

When to Use useReducer vs useState

Use useState WhenUse useReducer When
2-3 related state variables4+ related state variables
Simple transitionsComplex state transitions
No derived actionsActions depend on current state
Prototype / quick codeProduction / team code

5. Race Conditions Explained

What Is a Race Condition?

A race condition occurs when two async operations compete and the wrong one "wins":

User clicks User A → fetch starts (takes 3 seconds)
User clicks User B → fetch starts (takes 1 second)

Timeline:
  0s: Request A sent
  1s: Request B sent
  2s: Response B arrives → setUser(B) ✓ Shows User B
  3s: Response A arrives → setUser(A) ✗ Shows User A (WRONG!)
  
  User sees: User A's data, but they selected User B!

Visual Diagram

Click A                           Click B
  │                                 │
  ▼                                 ▼
  ┌─── Request A (slow: 3s) ──────────────────────┐
  │                                                │
  │     ┌─── Request B (fast: 1s) ──┐             │
  │     │                           │             │
  │     │                           ▼             │
  │     │                    setUser(B) ✓         │
  │     │                    User sees B          │
  │     │                                         ▼
  │     │                                  setUser(A) ✗
  │     │                                  User sees A (BUG!)
  │     │
  └─────┘

Code That Has This Bug

// ❌ RACE CONDITION BUG
function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => setUser(data)); // No check if this is still relevant!
  }, [userId]);
}

6. Fixing Race Conditions with AbortController

The most robust solution — actually cancels the network request:

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);
  
  useEffect(() => {
    const controller = new AbortController();
    
    setLoading(true);
    setError(null);
    
    fetch(`/api/users/${userId}`, {
      signal: controller.signal, // Pass signal to fetch
    })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        setUser(data);
        setLoading(false);
      })
      .catch(err => {
        // AbortError means we intentionally cancelled — not an error
        if (err.name === 'AbortError') {
          console.log('Request aborted (expected)');
          return;
        }
        setError(err.message);
        setLoading(false);
      });
    
    // Cleanup: abort the request when userId changes or component unmounts
    return () => controller.abort();
  }, [userId]);
}

How It Fixes the Race Condition

Click A                           Click B
  │                                 │
  ▼                                 ▼
  ┌─── Request A starts ───────┐   cleanup: controller.abort()
  │                             │   Request A is CANCELLED ✓
  │    ABORTED ─────────────────┘
  │
  │     ┌─── Request B starts ──────────────────┐
  │     │                                        │
  │     │                                        ▼
  │     │                                 setUser(B) ✓
  │     │                                 User sees B ✓
  │     │
  └─────┘
  
  No stale data!

7. Fixing Race Conditions with Boolean Flags

For when you can't use AbortController (or want simpler logic):

function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    let active = true; // Flag: "is this effect still current?"
    
    fetch(`/api/users/${userId}`)
      .then(res => res.json())
      .then(data => {
        if (active) { // Only update if this effect is still active
          setUser(data);
        }
      });
    
    return () => {
      active = false; // Mark this effect as stale
    };
  }, [userId]);
}

How It Works

Click A:
  active_A = true
  fetch for User A starts

Click B:
  Cleanup runs: active_A = false  ← A's data will be ignored
  active_B = true
  fetch for User B starts

Response B arrives:
  if (active_B) → true → setUser(B) ✓

Response A arrives (late):
  if (active_A) → false → skipped ✓ (stale data ignored)

Comparison: AbortController vs Boolean Flag

FeatureAbortControllerBoolean Flag
Cancels network request✅ Yes❌ No (request still completes)
Prevents stale state update✅ Yes✅ Yes
Saves bandwidth✅ Yes❌ No
Works with all Promises⚠️ Only fetch/XMLHttpRequest✅ Yes
ComplexityMediumLow
RecommendationProduction codeQuick prototypes

8. Building a Reusable useFetch Hook

Basic Version

function useFetch(url) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null,
  });
  
  useEffect(() => {
    const controller = new AbortController();
    
    setState({ data: null, loading: true, error: null });
    
    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => setState({ data, loading: false, error: null }))
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState({ data: null, loading: false, error: err.message });
        }
      });
    
    return () => controller.abort();
  }, [url]);
  
  return state;
}

// Usage
function UserProfile({ userId }) {
  const { data: user, loading, error } = useFetch(`/api/users/${userId}`);
  
  if (loading) return <Spinner />;
  if (error) return <Error message={error} />;
  return <div>{user.name}</div>;
}

Advanced Version with Options

function useFetch(url, options = {}) {
  const {
    enabled = true,          // Control when to fetch
    initialData = null,      // Default data before fetch
    transform = (d) => d,    // Transform response data
    onSuccess,               // Callback on success
    onError,                 // Callback on error
  } = options;
  
  const [state, dispatch] = useReducer(fetchReducer, {
    status: enabled ? 'loading' : 'idle',
    data: initialData,
    error: null,
  });
  
  const refetch = useCallback(() => {
    dispatch({ type: 'FETCH_START' });
    
    return fetch(url)
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        const transformed = transform(data);
        dispatch({ type: 'FETCH_SUCCESS', payload: transformed });
        onSuccess?.(transformed);
        return transformed;
      })
      .catch(err => {
        dispatch({ type: 'FETCH_ERROR', payload: err.message });
        onError?.(err);
        throw err;
      });
  }, [url, transform, onSuccess, onError]);
  
  useEffect(() => {
    if (!enabled || !url) return;
    
    const controller = new AbortController();
    
    dispatch({ type: 'FETCH_START' });
    
    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        const transformed = transform(data);
        dispatch({ type: 'FETCH_SUCCESS', payload: transformed });
        onSuccess?.(transformed);
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          dispatch({ type: 'FETCH_ERROR', payload: err.message });
          onError?.(err);
        }
      });
    
    return () => controller.abort();
  }, [url, enabled]);
  
  return {
    ...state,
    isIdle: state.status === 'idle',
    isLoading: state.status === 'loading',
    isSuccess: state.status === 'success',
    isError: state.status === 'error',
    refetch,
  };
}

// Usage
function SearchResults({ query }) {
  const { data, isLoading, isError, error, refetch } = useFetch(
    query ? `/api/search?q=${encodeURIComponent(query)}` : null,
    {
      enabled: query.length >= 2,
      transform: (data) => data.results,
      onSuccess: (results) => console.log(`Found ${results.length} results`),
    }
  );
}

9. Caching Fetched Data

Simple In-Memory Cache

const cache = new Map();

function useFetchWithCache(url, ttl = 60000) {
  const [state, setState] = useState(() => {
    const cached = cache.get(url);
    if (cached && Date.now() - cached.timestamp < ttl) {
      return { data: cached.data, loading: false, error: null };
    }
    return { data: null, loading: true, error: null };
  });
  
  useEffect(() => {
    // Check cache first
    const cached = cache.get(url);
    if (cached && Date.now() - cached.timestamp < ttl) {
      setState({ data: cached.data, loading: false, error: null });
      return; // Cache hit — no fetch needed
    }
    
    const controller = new AbortController();
    setState(prev => ({ ...prev, loading: true, error: null }));
    
    fetch(url, { signal: controller.signal })
      .then(res => {
        if (!res.ok) throw new Error(`HTTP ${res.status}`);
        return res.json();
      })
      .then(data => {
        cache.set(url, { data, timestamp: Date.now() });
        setState({ data, loading: false, error: null });
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState({ data: null, loading: false, error: err.message });
        }
      });
    
    return () => controller.abort();
  }, [url, ttl]);
  
  return state;
}

Stale-While-Revalidate Pattern

function useSWR(url) {
  const [state, setState] = useState(() => {
    const cached = cache.get(url);
    return {
      data: cached?.data ?? null,
      loading: !cached,
      error: null,
      isValidating: true,
    };
  });
  
  useEffect(() => {
    const controller = new AbortController();
    
    // Show cached data immediately (stale)
    const cached = cache.get(url);
    if (cached) {
      setState(prev => ({
        ...prev,
        data: cached.data,
        loading: false,
        isValidating: true,
      }));
    }
    
    // Revalidate in background
    fetch(url, { signal: controller.signal })
      .then(res => res.json())
      .then(data => {
        cache.set(url, { data, timestamp: Date.now() });
        setState({ data, loading: false, error: null, isValidating: false });
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState(prev => ({
            ...prev,
            error: err.message,
            isValidating: false,
          }));
        }
      });
    
    return () => controller.abort();
  }, [url]);
  
  return state;
}

10. Retry Logic

function useFetchWithRetry(url, { maxRetries = 3, retryDelay = 1000 } = {}) {
  const [state, setState] = useState({
    data: null,
    loading: true,
    error: null,
    retryCount: 0,
  });
  
  useEffect(() => {
    let cancelled = false;
    let currentRetry = 0;
    
    async function fetchWithRetry() {
      setState(prev => ({ ...prev, loading: true, error: null }));
      
      while (currentRetry <= maxRetries) {
        try {
          const res = await fetch(url);
          if (!res.ok) throw new Error(`HTTP ${res.status}`);
          const data = await res.json();
          
          if (!cancelled) {
            setState({ data, loading: false, error: null, retryCount: currentRetry });
          }
          return;
        } catch (err) {
          currentRetry++;
          
          if (currentRetry > maxRetries) {
            if (!cancelled) {
              setState({
                data: null,
                loading: false,
                error: `Failed after ${maxRetries} retries: ${err.message}`,
                retryCount: currentRetry,
              });
            }
            return;
          }
          
          // Exponential backoff
          const delay = retryDelay * Math.pow(2, currentRetry - 1);
          await new Promise(resolve => setTimeout(resolve, delay));
          
          if (cancelled) return;
          
          setState(prev => ({ ...prev, retryCount: currentRetry }));
        }
      }
    }
    
    fetchWithRetry();
    
    return () => { cancelled = true; };
  }, [url, maxRetries, retryDelay]);
  
  return state;
}

11. Pagination and Infinite Scroll

Page-Based Pagination

function usePagination(baseUrl, pageSize = 20) {
  const [page, setPage] = useState(1);
  const [state, setState] = useState({
    items: [],
    loading: true,
    error: null,
    hasMore: true,
    totalPages: 0,
  });
  
  useEffect(() => {
    const controller = new AbortController();
    setState(prev => ({ ...prev, loading: true }));
    
    fetch(`${baseUrl}?page=${page}&limit=${pageSize}`, {
      signal: controller.signal,
    })
      .then(res => res.json())
      .then(data => {
        setState({
          items: data.items,
          loading: false,
          error: null,
          hasMore: page < data.totalPages,
          totalPages: data.totalPages,
        });
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState(prev => ({ ...prev, loading: false, error: err.message }));
        }
      });
    
    return () => controller.abort();
  }, [baseUrl, page, pageSize]);
  
  return {
    ...state,
    page,
    nextPage: () => setPage(p => Math.min(p + 1, state.totalPages)),
    prevPage: () => setPage(p => Math.max(p - 1, 1)),
    goToPage: setPage,
  };
}

Infinite Scroll (Append)

function useInfiniteScroll(baseUrl, pageSize = 20) {
  const [page, setPage] = useState(1);
  const [state, setState] = useState({
    items: [],
    loading: false,
    error: null,
    hasMore: true,
  });
  
  const loadMore = useCallback(() => {
    if (state.loading || !state.hasMore) return;
    setPage(p => p + 1);
  }, [state.loading, state.hasMore]);
  
  useEffect(() => {
    const controller = new AbortController();
    setState(prev => ({ ...prev, loading: true }));
    
    fetch(`${baseUrl}?page=${page}&limit=${pageSize}`, {
      signal: controller.signal,
    })
      .then(res => res.json())
      .then(data => {
        setState(prev => ({
          items: page === 1
            ? data.items
            : [...prev.items, ...data.items], // APPEND for infinite scroll
          loading: false,
          error: null,
          hasMore: data.items.length === pageSize,
        }));
      })
      .catch(err => {
        if (err.name !== 'AbortError') {
          setState(prev => ({ ...prev, loading: false, error: err.message }));
        }
      });
    
    return () => controller.abort();
  }, [baseUrl, page, pageSize]);
  
  return { ...state, loadMore };
}

// Usage with Intersection Observer
function ProductList() {
  const { items, loading, loadMore, hasMore } = useInfiniteScroll('/api/products');
  const sentinelRef = useRef(null);
  
  useEffect(() => {
    const sentinel = sentinelRef.current;
    if (!sentinel) return;
    
    const observer = new IntersectionObserver(
      ([entry]) => {
        if (entry.isIntersecting) loadMore();
      },
      { threshold: 0.1 }
    );
    
    observer.observe(sentinel);
    return () => observer.disconnect();
  }, [loadMore]);
  
  return (
    <div>
      {items.map(item => <ProductCard key={item.id} {...item} />)}
      {loading && <LoadingSpinner />}
      {hasMore && <div ref={sentinelRef} style={{ height: 1 }} />}
    </div>
  );
}

12. The Evolution: Why TanStack Query Exists

After building all the above patterns, you realise you're reinventing the wheel. Let's see what problems TanStack Query solves:

What You'd Need to Build Yourself

Feature                     | useEffect DIY | TanStack Query
─────────────────────────────────────────────────────────────
Loading/error states        | ✅ Manual      | ✅ Built-in
AbortController             | ✅ Manual      | ✅ Automatic
Race condition prevention   | ✅ Manual      | ✅ Automatic
Caching                     | ⚠️ Basic       | ✅ Advanced (stale-while-revalidate)
Background refetching       | ❌ Complex     | ✅ Automatic
Window focus refetching     | ❌ Complex     | ✅ One config flag
Retry with backoff          | ⚠️ Manual      | ✅ Built-in
Pagination                  | ⚠️ Manual      | ✅ Built-in
Infinite scroll             | ⚠️ Complex     | ✅ useInfiniteQuery
Optimistic updates          | ❌ Very hard   | ✅ Built-in
Cache invalidation          | ❌ Manual      | ✅ queryClient.invalidateQueries
Prefetching                 | ❌ Manual      | ✅ queryClient.prefetchQuery
Request deduplication       | ❌ Very hard   | ✅ Automatic
DevTools                    | ❌ None        | ✅ React Query DevTools
SSR support                 | ❌ Complex     | ✅ Hydration support
Garbage collection          | ❌ Manual      | ✅ Automatic
Parallel queries            | ⚠️ Manual      | ✅ useQueries
Dependent queries           | ⚠️ Manual      | ✅ enabled flag

TanStack Query Equivalent

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

// Compare: ~50 lines of useEffect code → 5 lines
function UserProfile({ userId }) {
  const { data: user, isLoading, error } = useQuery({
    queryKey: ['user', userId],
    queryFn: () => fetch(`/api/users/${userId}`).then(r => r.json()),
    staleTime: 5 * 60 * 1000, // Cache for 5 minutes
    retry: 3,
  });
  
  if (isLoading) return <Spinner />;
  if (error) return <Error message={error.message} />;
  return <div>{user.name}</div>;
}

13. When to Use useEffect vs TanStack Query

Use useEffect When

✅ Learning React (understand the fundamentals first)
✅ Simple, one-off fetch in a small app
✅ Non-HTTP external system (WebSocket, localStorage, DOM)
✅ You're building a library/framework yourself
✅ Bundle size is a critical concern
✅ Server-side fetching (Server Components)

Use TanStack Query When

✅ Production application
✅ Multiple components fetch the same data
✅ Need caching, refetching, or optimistic updates
✅ Complex data dependencies between fetches
✅ Real-time data (polling, background refetch)
✅ Team project (standardised patterns)

Decision Flowchart

Is this a learning exercise?
  └─ YES → useEffect (learn the fundamentals)

Is the data fetched in multiple places?
  └─ YES → TanStack Query (dedup + cache)

Do you need caching/refetching?
  └─ YES → TanStack Query

Is it a simple, one-off fetch?
  └─ YES → useEffect is fine

Are you using Next.js App Router?
  └─ YES → Server Components first, TanStack Query for client

14. Anti-Patterns to Avoid

Anti-Pattern 1: Fetching in Event Handlers for Display Data

// ❌ Fetch triggered by event but data needed for display
function UserPage() {
  const [user, setUser] = useState(null);
  
  // Don't fetch in event handler if data is needed immediately on render
  function loadUser() {
    fetch('/api/me').then(r => r.json()).then(setUser);
  }
  
  return (
    <div>
      <button onClick={loadUser}>Load Profile</button>
      {/* User has to click a button to see their own profile? */}
    </div>
  );
}

// ✅ Fetch in useEffect — data loads automatically
function UserPage() {
  const [user, setUser] = useState(null);
  
  useEffect(() => {
    fetch('/api/me').then(r => r.json()).then(setUser);
  }, []);
  
  if (!user) return <LoadingSpinner />;
  return <div>{user.name}</div>;
}

Anti-Pattern 2: Not Handling All States

// ❌ Missing states
function Posts() {
  const [posts, setPosts] = useState([]);
  
  useEffect(() => {
    fetch('/api/posts').then(r => r.json()).then(setPosts);
  }, []);
  
  // What if it's loading? What if there's an error?
  // What if the response is an empty array?
  return posts.map(p => <Post key={p.id} {...p} />);
}

// ✅ Handle every state
function Posts() {
  const { data, loading, error } = useFetch('/api/posts');
  
  if (loading) return <Skeleton count={5} />;
  if (error) return <ErrorBanner message={error} />;
  if (data.length === 0) return <EmptyState message="No posts yet" />;
  return data.map(p => <Post key={p.id} {...p} />);
}

Anti-Pattern 3: Waterfall Fetches

// ❌ Sequential fetches (slow!)
function Dashboard() {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState(null);
  const [notifications, setNotifications] = useState(null);
  
  useEffect(() => {
    fetch('/api/user').then(r => r.json()).then(setUser);
  }, []);
  
  useEffect(() => {
    if (user) fetch(`/api/posts?userId=${user.id}`).then(r => r.json()).then(setPosts);
  }, [user]);
  
  useEffect(() => {
    if (user) fetch(`/api/notifications?userId=${user.id}`).then(r => r.json()).then(setNotifications);
  }, [user]);
  // User → wait → Posts → wait → (sequential, 3 network round trips)
}

// ✅ Parallel where possible
function Dashboard() {
  const [state, setState] = useState({ user: null, posts: null, notifications: null });
  
  useEffect(() => {
    const controller = new AbortController();
    
    // Fetch user first (needed for other requests)
    fetch('/api/user', { signal: controller.signal })
      .then(r => r.json())
      .then(user => {
        setState(prev => ({ ...prev, user }));
        
        // Then fetch posts and notifications IN PARALLEL
        return Promise.all([
          fetch(`/api/posts?userId=${user.id}`, { signal: controller.signal }).then(r => r.json()),
          fetch(`/api/notifications?userId=${user.id}`, { signal: controller.signal }).then(r => r.json()),
        ]);
      })
      .then(([posts, notifications]) => {
        setState(prev => ({ ...prev, posts, notifications }));
      })
      .catch(err => {
        if (err.name !== 'AbortError') console.error(err);
      });
    
    return () => controller.abort();
  }, []);
}

15. Key Takeaways

  1. Always handle loading, error, and empty states. The simplest fetch is the buggiest fetch.

  2. Discriminated unions > separate booleans. { status: 'loading' } prevents impossible states.

  3. Race conditions are real. Fast navigation + slow API = stale data shown. Use AbortController or boolean flags.

  4. AbortController is the gold standard. It actually cancels network requests and saves bandwidth.

  5. Build reusable hooks. Extract useFetch early — you'll need it everywhere.

  6. Caching is harder than it looks. Invalidation, TTL, stale-while-revalidate — this is why TanStack Query exists.

  7. Learn useEffect fetching first, then adopt TanStack Query. Understanding the fundamentals makes you a better user of the library.

  8. Avoid fetch waterfalls. Parallel requests where possible, sequential only when one depends on another.


Explain-It Challenge

  1. The Post Office Analogy: Explain race conditions using letters in the mail. You send two letters to the same address, but the second one arrives first. How does AbortController change this analogy?

  2. Build vs Buy: Your team lead asks: "Why can't we just use useEffect for all our data fetching?" List the features you'd have to build from scratch and estimate the effort. At what point does TanStack Query become the better choice?

  3. State Machine Drawing: Draw the state machine for a fetch operation with all possible states and transitions. Include: idle, loading, success, error, refetching, and stale. What transitions are valid? What transitions are impossible?


Navigation: ← Cleanup Functions · Next → Practical Example