AdSense Leaderboard

Google AdSense Placeholder

HEADER SLOT

Fetching Data in React (Mental Model): Synchronizing Server & Client

Last updated:
Fetching Data in React: Mental Model for Server-Client Sync

Master the mental model of data fetching in React. Learn useEffect, async operations, race conditions, and how to keep UI in sync with server state.

Table of Contents

# Fetching Data in React (Mental Model): Synchronizing Server & Client

Data fetching is where most React bugs hide. Not because fetching itself is hard—it's just fetch() or axios. The difficulty is in the mental model: understanding what happens when data arrives late, when the user navigates away, when requests pile up, when you need to retry. These aren't edge cases; they're constant realities of building real applications.

The problem is that React's mental model (functions that return UI) clashes with async operations (functions that eventually do something). This guide isn't about async/await syntax. It's about building a framework in your head for reasoning about data flow in React.

# Table of Contents

  1. The Core Mental Model
  2. The Effect-Fetch Loop
  3. Async Operations and Component Lifecycle
  4. The Race Condition Problem
  5. Handling Multiple Concurrent Requests
  6. Error States and Resilience
  7. The Cleanup Function: Cancellation
  8. Caching and Deduplication
  9. Dependency Arrays: The Triggering Mechanism
  10. Mental Models for Complex Scenarios
  11. FAQ

# The Core Mental Model {#core-model}

React components are functions that describe UI. Data fetching is an async operation that eventually produces data. These two worlds don't naturally fit together. Understanding the gap is the key to mastering data fetching.

# The React World: Synchronous, Deterministic

typescript
// React's world is pure and synchronous
function UserCard({ userId }: { userId: string }) {
  // Given userId, determine UI
  return <div>User: {userId}</div>;
}

// Same input → same output (always)
// No time dependency, no external state
// Renders instantly

# The Async World: Asynchronous, Dependent on Time

typescript
// Fetching is async and time-dependent
async function fetchUser(userId: string) {
  // 1. Start request (0ms)
  // 2. Wait for network (200-2000ms)
  // 3. Parse response (1-10ms)
  // 4. Return data
  
  // The data arrives LATER
  // You don't know how much later
  // It could fail
}

# The Gap

React expects all data immediately. Fetching provides data eventually. Your job is bridging this gap.

# TypeScript Version: Mental Model Visualization

typescript
import { useState, useEffect } from 'react';

interface User {
  id: string;
  name: string;
}

interface DataFetchingStates {
  idle: 'idle'; // No request in flight
  loading: 'loading'; // Request in flight
  success: 'success'; // Data received
  error: 'error'; // Request failed
}

export function UserCardWithData({ userId }: { userId: string }) {
  // State 1: Track what's happening
  const [state, setState] = useState<keyof DataFetchingStates>('idle');
  
  // State 2: Hold the actual data
  const [data, setData] = useState<User | null>(null);
  
  // State 3: Hold the error
  const [error, setError] = useState<Error | null>(null);

  // Effect 1: Sync component to data source
  useEffect(() => {
    // When userId changes, we need fresh data
    // This effect TRIGGERS the fetch
    
    let isMounted = true; // Track if component is still mounted

    const fetchData = async () => {
      try {
        setState('loading');
        
        // Now we cross the gap: from React world (sync) to async world
        const response = await fetch(`/api/users/${userId}`);
        const user: User = await response.json();

        // Back to React world: update state
        if (isMounted) {
          setData(user);
          setState('success');
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
          setState('error');
        }
      }
    };

    // Trigger the async operation
    fetchData();

    // Cleanup: if component unmounts, don't update state
    return () => {
      isMounted = false;
    };
  }, [userId]); // When userId changes, refetch

  // Render: React determines what UI to show based on current state
  if (state === 'loading') {
    return <div>Loading...</div>;
  }

  if (state === 'error') {
    return <div>Error: {error?.message}</div>;
  }

  if (state === 'success' && data) {
    return <div>User: {data.name}</div>;
  }

  return <div>No data</div>;
}

// The Mental Model:
// 1. Component mounts with userId="123"
// 2. useEffect runs: triggers fetch for userId 123
// 3. Fetch is async: returns immediately, but data isn't here yet
// 4. Component renders with state='loading'
// 5. ~200ms later: fetch completes, calls setData
// 6. setState triggers re-render
// 7. Component renders with state='success' and data

// Timeline:
// t=0ms: Component mounts
// t=0ms: useEffect runs, fetch starts
// t=0ms: Render: "Loading..."
// t=200ms: Fetch completes
// t=200ms: setState called (setData, setState('success'))
// t=200ms: Re-render: "User: John"

# JavaScript Version

javascript
import { useState, useEffect } from 'react';

export function UserCardWithData({ userId }) {
  const [state, setState] = useState('idle');
  const [data, setData] = useState(null);
  const [error, setError] = useState(null);

  useEffect(() => {
    let isMounted = true;

    const fetchData = async () => {
      try {
        setState('loading');
        
        const response = await fetch(`/api/users/${userId}`);
        const user = await response.json();

        if (isMounted) {
          setData(user);
          setState('success');
        }
      } catch (err) {
        if (isMounted) {
          setError(err instanceof Error ? err : new Error('Unknown error'));
          setState('error');
        }
      }
    };

    fetchData();

    return () => {
      isMounted = false;
    };
  }, [userId]);

  if (state === 'loading') {
    return <div>Loading...</div>;
  }

  if (state === 'error') {
    return <div>Error: {error?.message}</div>;
  }

  if (state === 'success' && data) {
    return <div>User: {data.name}</div>;
  }

  return <div>No data</div>;
}

Key insight: The component renders before data arrives. You must always handle the "data not here yet" state.

# The Effect-Fetch Loop {#effect-fetch}

The typical flow is: Effect triggers → Async operation starts → Data arrives → setState → Re-render.

But there are pitfalls at each step.

# TypeScript Version: The Loop in Detail

typescript
import { useState, useEffect } from 'react';

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

export function ProductDetail({ productId }: { productId: string }) {
  const [product, setProduct] = useState<Product | null>(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    console.log('↓ Effect triggered (component mounted or dependency changed)');
    
    setIsLoading(true);
    console.log('→ Set loading = true (schedules re-render)');

    // Start the async operation
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        console.log('↓ Fetch completed, data arrived');
        
        setProduct(data);
        setIsLoading(false);
        console.log('→ Called setState: product + loading=false (schedules re-render)');
      })
      .catch(error => {
        console.log('↓ Fetch failed');
        setIsLoading(false);
        // Note: should probably set an error state here
      });

    console.log('↑ Effect finished (async operation started but not awaited)');
  }, [productId]);

  // Every render shows the CURRENT state
  console.log(`→ Render with: isLoading=${isLoading}, product=${product?.name || 'null'}`);

  return (
    <div>
      {isLoading ? (
        <p>Loading...</p>
      ) : (
        <>
          <h1>{product?.name}</h1>
          <p>${product?.price}</p>
        </>
      )}
    </div>
  );
}

// Execution Timeline:
// ─────────────────────────────────────────
// 1. Component mounts
//    ↓ Effect triggered
//    → setIsLoading(true)
//    ↑ Effect finished (fetch started in background)
//    → Render with: isLoading=true, product=null
//    [Shows: "Loading..."]
// 
// 2. ~200ms later: fetch completes
//    ↓ setProduct(data) + setIsLoading(false)
//    → Re-render with: isLoading=false, product={...}
//    [Shows: product name and price]
// 
// 3. User changes productId (prop changes)
//    ↓ Effect triggered AGAIN
//    → setIsLoading(true)
//    ↑ Effect finished (NEW fetch started)
//    → Re-render with: isLoading=true, product=old_data
//    [Shows: "Loading..." (or old product if you didn't clear it)]
// 
// 4. ~200ms later: NEW fetch completes
//    ↓ setProduct(new_data) + setIsLoading(false)
//    → Re-render with new data

# JavaScript Version

javascript
import { useState, useEffect } from 'react';

export function ProductDetail({ productId }) {
  const [product, setProduct] = useState(null);
  const [isLoading, setIsLoading] = useState(true);

  useEffect(() => {
    console.log('↓ Effect triggered');
    
    setIsLoading(true);
    console.log('→ Set loading = true');

    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        console.log('↓ Fetch completed');
        setProduct(data);
        setIsLoading(false);
      })
      .catch(error => {
        console.log('↓ Fetch failed');
        setIsLoading(false);
      });

    console.log('↑ Effect finished');
  }, [productId]);

  console.log(`→ Render with: isLoading=${isLoading}, product=${product?.name || 'null'}`);

  return (
    <div>
      {isLoading ? (
        <p>Loading...</p>
      ) : (
        <>
          <h1>{product?.name}</h1>
          <p>${product?.price}</p>
        </>
      )}
    </div>
  );
}

# Async Operations and Component Lifecycle {#async-lifecycle}

Here's the crucial distinction: the effect runs to completion immediately, then the async work happens later.

# TypeScript Version: The Timing Gap

typescript
import { useEffect, useState } from 'react';

export function MisunderstandingAsync() {
  const [data, setData] = useState<string | null>(null);

  useEffect(() => {
    // ❌ COMMON MISUNDERSTANDING
    // Developers think: "I'll await the fetch and then update state"
    // But useEffect callback cannot be async!

    // const fetchData = async () => { ... }
    // This is fine, but you call it below

    // This DOESN'T work:
    // useEffect(async () => { ... }, [])
    // Because the cleanup function would be the Promise, not a function

    // ✅ CORRECT: Create an async function and call it
    const fetchData = async () => {
      const response = await fetch('/api/data');
      const result = await response.json();
      setData(result); // Happens LATER
    };

    console.log('1. Effect runs');
    fetchData(); // Start the async operation
    console.log('2. Effect finishes (but fetch is still pending!)');

    // At this point:
    // - The effect has completed
    // - But the fetch request is still in flight
    // - And setData hasn't been called yet
  }, []);

  console.log(`3. Render with data=${data}`);
  // First render: data=null (because fetch hasn't completed yet)
  // After fetch: data=result (because setData was called)

  return <div>{data || 'Loading...'}</div>;
}

// Timeline:
// t=0ms: useEffect hook starts
// t=0ms: "1. Effect runs"
// t=0ms: fetchData() called (returns immediately, fetch starts in background)
// t=0ms: "2. Effect finishes"
// t=0ms: Component renders with data=null
// t=200ms: fetch completes in the background
// t=200ms: setData(result) called
// t=200ms: Re-render with data=result

// The KEY: effect finishes BEFORE data arrives

# JavaScript Version

javascript
import { useEffect, useState } from 'react';

export function MisunderstandingAsync() {
  const [data, setData] = useState(null);

  useEffect(() => {
    const fetchData = async () => {
      const response = await fetch('/api/data');
      const result = await response.json();
      setData(result);
    };

    console.log('1. Effect runs');
    fetchData();
    console.log('2. Effect finishes (but fetch is still pending!)');
  }, []);

  console.log(`3. Render with data=${data}`);

  return <div>{data || 'Loading...'}</div>;
}

// Timeline same as above

# The Race Condition Problem {#race-conditions}

This is the most insidious bug in data fetching: when you start multiple requests, but only the first one should count.

# The Scenario

typescript
User at /product/123
clicks link
User at /product/456
useEffect triggers
fetch /api/products/456 starts (request A)
User quickly navigates back to /product/123
useEffect triggers AGAIN
fetch /api/products/123 starts (request B)
request A (for 123) arrives LATE (maybe network was slow)
setState is called with data for product 123
UI shows product 123
↓ ~1 second later: request B (for 456) arrives
setState is called with data for product 456
UI WRONGLY shows product 456 (even though user is on page for 123!)

This is a race condition: the outcome depends on the order of async events, not the logic.

# TypeScript Version: Race Condition and Solution

typescript
import { useState, useEffect } from 'react';

interface Product {
  id: string;
  name: string;
}

// ❌ Buggy version: vulnerable to race conditions
export function ProductBuggy({ productId }: { productId: string }) {
  const [product, setProduct] = useState<Product | null>(null);

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        // BUG: This always updates state, even if we navigated away!
        // If requests complete out of order, we show wrong data
        setProduct(data);
      });
  }, [productId]);

  return <div>{product?.name}</div>;
}

// ✅ Fixed version: abort cancelled requests
export function ProductFixed({ productId }: { productId: string }) {
  const [product, setProduct] = useState<Product | null>(null);

  useEffect(() => {
    // Create an AbortController for this effect
    const abortController = new AbortController();

    fetch(`/api/products/${productId}`, {
      signal: abortController.signal, // Attach abort signal
    })
      .then(res => res.json())
      .then(data => {
        // Only update if this fetch wasn't cancelled
        setProduct(data);
      })
      .catch(error => {
        if (error.name === 'AbortError') {
          // This fetch was cancelled (expected)
          console.log('Fetch cancelled: dependency changed');
        } else {
          // Real error
          console.error('Fetch failed:', error);
        }
      });

    // Cleanup: cancel the fetch if effect re-runs before it completes
    return () => {
      abortController.abort();
    };
  }, [productId]);

  return <div>{product?.name}</div>;
}

// Timeline with fix:
// t=0ms: User on /product/123 → fetch 123 starts (AbortController A)
// t=100ms: User navigates to /product/456
// t=100ms: useEffect runs again
// t=100ms: Cleanup called → abortController A.abort() (cancels 123 request)
// t=100ms: fetch 456 starts (AbortController B)
// t=200ms: fetch 123 response arrives, but already aborted (ignored)
// t=300ms: fetch 456 response arrives, setProduct called with 456 (✓ correct!)

# JavaScript Version

javascript
import { useState, useEffect } from 'react';

// ❌ Buggy version
export function ProductBuggy({ productId }) {
  const [product, setProduct] = useState(null);

  useEffect(() => {
    fetch(`/api/products/${productId}`)
      .then(res => res.json())
      .then(data => {
        setProduct(data); // BUG: race condition
      });
  }, [productId]);

  return <div>{product?.name}</div>;
}

// ✅ Fixed version
export function ProductFixed({ productId }) {
  const [product, setProduct] = useState(null);

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

    fetch(`/api/products/${productId}`, {
      signal: abortController.signal,
    })
      .then(res => res.json())
      .then(data => {
        setProduct(data);
      })
      .catch(error => {
        if (error.name === 'AbortError') {
          console.log('Fetch cancelled');
        } else {
          console.error('Fetch failed:', error);
        }
      });

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

  return <div>{product?.name}</div>;
}

This is the single most important pattern for data fetching. Always abort or ignore outdated requests.

# Handling Multiple Concurrent Requests {#concurrent-requests}

Sometimes you need multiple pieces of data. Managing them together is tricky.

# TypeScript Version: Coordinating Multiple Requests

typescript
import { useState, useEffect } from 'react';

interface User {
  id: string;
  name: string;
}

interface Post {
  id: string;
  title: string;
  userId: string;
}

export function UserProfile({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [posts, setPosts] = useState<Post[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

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

    // Strategy 1: Fetch both, then update together
    // Advantage: shows data only when BOTH are ready
    // Disadvantage: slower (waits for both)
    const fetchBoth = async () => {
      try {
        setLoading(true);

        // Fetch in parallel (not sequential)
        const [userRes, postsRes] = await Promise.all([
          fetch(`/api/users/${userId}`, { signal: abortController.signal }),
          fetch(`/api/users/${userId}/posts`, { signal: abortController.signal }),
        ]);

        const userData = await userRes.json();
        const postsData = await postsRes.json();

        setUser(userData);
        setPosts(postsData);
        setError(null);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchBoth();

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

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>{user?.name}</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

// Strategy 2: Fetch independently, show data as it arrives
export function UserProfileStreaming({ userId }: { userId: string }) {
  const [user, setUser] = useState<User | null>(null);
  const [posts, setPosts] = useState<Post[]>([]);

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

    // Fetch user
    fetch(`/api/users/${userId}`, { signal: abortController.signal })
      .then(res => res.json())
      .then(setUser)
      .catch(err => err.name !== 'AbortError' && console.error(err));

    // Fetch posts independently
    fetch(`/api/users/${userId}/posts`, { signal: abortController.signal })
      .then(res => res.json())
      .then(setPosts)
      .catch(err => err.name !== 'AbortError' && console.error(err));

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

  return (
    <div>
      {user && <h1>{user.name}</h1>}
      {posts.length > 0 && (
        <ul>
          {posts.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
      {!user && <p>Loading user...</p>}
      {user && posts.length === 0 && <p>Loading posts...</p>}
    </div>
  );
}

# JavaScript Version

javascript
import { useState, useEffect } from 'react';

// Strategy 1: Fetch both, update together
export function UserProfile({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

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

    const fetchBoth = async () => {
      try {
        setLoading(true);

        const [userRes, postsRes] = await Promise.all([
          fetch(`/api/users/${userId}`, { signal: abortController.signal }),
          fetch(`/api/users/${userId}/posts`, { signal: abortController.signal }),
        ]);

        const userData = await userRes.json();
        const postsData = await postsRes.json();

        setUser(userData);
        setPosts(postsData);
        setError(null);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setLoading(false);
      }
    };

    fetchBoth();

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

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error.message}</div>;

  return (
    <div>
      <h1>{user?.name}</h1>
      <ul>
        {posts.map(post => (
          <li key={post.id}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
}

// Strategy 2: Fetch independently
export function UserProfileStreaming({ userId }) {
  const [user, setUser] = useState(null);
  const [posts, setPosts] = useState([]);

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

    fetch(`/api/users/${userId}`, { signal: abortController.signal })
      .then(res => res.json())
      .then(setUser)
      .catch(err => err.name !== 'AbortError' && console.error(err));

    fetch(`/api/users/${userId}/posts`, { signal: abortController.signal })
      .then(res => res.json())
      .then(setPosts)
      .catch(err => err.name !== 'AbortError' && console.error(err));

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

  return (
    <div>
      {user && <h1>{user.name}</h1>}
      {posts.length > 0 && (
        <ul>
          {posts.map(post => (
            <li key={post.id}>{post.title}</li>
          ))}
        </ul>
      )}
      {!user && <p>Loading user...</p>}
      {user && posts.length === 0 && <p>Loading posts...</p>}
    </div>
  );
}

# Error States and Resilience {#error-handling}

Most tutorials skip error handling. Real apps need it.

# TypeScript Version: Robust Error Handling

typescript
import { useState, useEffect } from 'react';

interface FetchState<T> {
  data: T | null;
  error: Error | null;
  isLoading: boolean;
  isRetrying: boolean;
  retryCount: number;
}

interface UseFetchOptions {
  maxRetries?: number;
  retryDelay?: number;
}

// Custom hook for robust data fetching
export function useFetch<T>(
  url: string,
  options: UseFetchOptions = {}
): FetchState<T> {
  const maxRetries = options.maxRetries ?? 3;
  const retryDelay = options.retryDelay ?? 1000;

  const [state, setState] = useState<FetchState<T>>({
    data: null,
    error: null,
    isLoading: true,
    isRetrying: false,
    retryCount: 0,
  });

  useEffect(() => {
    const abortController = new AbortController();
    let retryCount = 0;

    const fetchWithRetry = async () => {
      try {
        const response = await fetch(url, {
          signal: abortController.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const data = await response.json() as T;

        setState({
          data,
          error: null,
          isLoading: false,
          isRetrying: false,
          retryCount,
        });
      } catch (err) {
        // Don't update state if aborted
        if (err instanceof Error && err.name === 'AbortError') {
          return;
        }

        // Check if we should retry
        if (retryCount < maxRetries) {
          retryCount++;
          setState(prev => ({
            ...prev,
            isRetrying: true,
            retryCount,
          }));

          // Wait before retrying
          await new Promise(resolve => setTimeout(resolve, retryDelay));
          
          // Recursive retry
          fetchWithRetry();
        } else {
          // Give up after max retries
          setState({
            data: null,
            error: err instanceof Error ? err : new Error('Unknown error'),
            isLoading: false,
            isRetrying: false,
            retryCount,
          });
        }
      }
    };

    fetchWithRetry();

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

  return state;
}

// Usage
export function DataDisplay() {
  const { data, error, isLoading, isRetrying, retryCount } = useFetch<{
    name: string;
  }>('/api/data', {
    maxRetries: 3,
    retryDelay: 1000,
  });

  if (isLoading && !isRetrying) {
    return <div>Loading...</div>;
  }

  if (isRetrying) {
    return (
      <div>
        Connection failed. Retrying... (attempt {retryCount})
      </div>
    );
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return <div>{data?.name}</div>;
}

# JavaScript Version

javascript
import { useState, useEffect } from 'react';

export function useFetch(url, options = {}) {
  const maxRetries = options.maxRetries ?? 3;
  const retryDelay = options.retryDelay ?? 1000;

  const [state, setState] = useState({
    data: null,
    error: null,
    isLoading: true,
    isRetrying: false,
    retryCount: 0,
  });

  useEffect(() => {
    const abortController = new AbortController();
    let retryCount = 0;

    const fetchWithRetry = async () => {
      try {
        const response = await fetch(url, {
          signal: abortController.signal,
        });

        if (!response.ok) {
          throw new Error(`HTTP ${response.status}: ${response.statusText}`);
        }

        const data = await response.json();

        setState({
          data,
          error: null,
          isLoading: false,
          isRetrying: false,
          retryCount,
        });
      } catch (err) {
        if (err instanceof Error && err.name === 'AbortError') {
          return;
        }

        if (retryCount < maxRetries) {
          retryCount++;
          setState(prev => ({
            ...prev,
            isRetrying: true,
            retryCount,
          }));

          await new Promise(resolve => setTimeout(resolve, retryDelay));
          fetchWithRetry();
        } else {
          setState({
            data: null,
            error: err instanceof Error ? err : new Error('Unknown error'),
            isLoading: false,
            isRetrying: false,
            retryCount,
          });
        }
      }
    };

    fetchWithRetry();

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

  return state;
}

export function DataDisplay() {
  const { data, error, isLoading, isRetrying, retryCount } = useFetch(
    '/api/data',
    {
      maxRetries: 3,
      retryDelay: 1000,
    }
  );

  if (isLoading && !isRetrying) {
    return <div>Loading...</div>;
  }

  if (isRetrying) {
    return <div>Connection failed. Retrying... (attempt {retryCount})</div>;
  }

  if (error) {
    return <div>Error: {error.message}</div>;
  }

  return <div>{data?.name}</div>;
}

# The Cleanup Function: Cancellation {#cleanup-function}

The cleanup function in useEffect is your primary defense against stale data.

# TypeScript Version: Why Cleanup Matters

typescript
import { useEffect, useState } from 'react';

export function CleanupExample() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    // Scenario: fetch takes 5 seconds
    const abortController = new AbortController();

    console.log('Fetch started');

    // Imagine this takes 5 seconds
    const timer = setTimeout(() => {
      console.log('Fetch completed (would call setState)');
      // In real code: setData(result)
    }, 5000);

    // WITHOUT cleanup, what happens?
    // 1. Effect runs: setTimeout starts 5-second timer
    // 2. Component re-renders (count changes)
    // 3. Effect runs AGAIN: another setTimeout starts
    // 4. After 5 seconds: first setTimeout calls setState
    // 5. After another 5 seconds: second setTimeout calls setState
    // 6. Even after component unmounts: timers keep running!

    // WITH cleanup:
    return () => {
      console.log('Cleaning up (clearing timer)');
      clearTimeout(timer); // Stop the timer if effect re-runs
      abortController.abort(); // Cancel fetch if it was running
    };
  }, []);

  return (
    <div>
      <p>Cleanup Example</p>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
    </div>
  );
}

// Timeline:
// t=0s: Component mounts
// t=0s: "Fetch started"
// t=0s: Cleanup function registered
// User clicks button
// t=0s: Effect cleanup runs: "Cleaning up"
// t=0s: Effect runs AGAIN: "Fetch started" (new timer)
// t=5s: Original timer would have fired, but was cleaned up
// t=5s: New timer fires: "Fetch completed"
// Component unmounts
// t=5s: Effect cleanup runs: clears timer
// (No more setState calls after unmount!)

# JavaScript Version

javascript
import { useEffect, useState } from 'react';

export function CleanupExample() {
  const [count, setCount] = useState(0);

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

    console.log('Fetch started');

    const timer = setTimeout(() => {
      console.log('Fetch completed');
    }, 5000);

    return () => {
      console.log('Cleaning up');
      clearTimeout(timer);
      abortController.abort();
    };
  }, []);

  return (
    <div>
      <p>Cleanup Example</p>
      <button onClick={() => setCount(count + 1)}>Count: {count}</button>
    </div>
  );
}

# Caching and Deduplication {#caching}

The naive approach makes a new request every time. Real apps need caching.

# TypeScript Version: Simple Cache

typescript
import { useEffect, useState, useRef } from 'react';

interface CacheEntry<T> {
  data: T;
  timestamp: number;
}

interface UseQueryOptions {
  cacheTime?: number; // How long to consider data fresh
}

// Simple caching hook
export function useQuery<T>(
  url: string,
  options: UseQueryOptions = {}
): {
  data: T | null;
  isLoading: boolean;
  error: Error | null;
} {
  const cacheTime = options.cacheTime ?? 5 * 60 * 1000; // 5 minutes
  const cacheRef = useRef<Map<string, CacheEntry<T>>>(new Map());

  const [data, setData] = useState<T | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

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

    // Check cache first
    const cached = cacheRef.current.get(url);
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      // Cache is still fresh
      console.log('Using cached data for:', url);
      setData(cached.data);
      setIsLoading(false);
      return; // Don't fetch
    }

    // Not in cache or stale, fetch fresh
    console.log('Fetching:', url);
    const fetchData = async () => {
      try {
        setIsLoading(true);
        const response = await fetch(url, { signal: abortController.signal });
        const result = await response.json() as T;

        // Store in cache
        cacheRef.current.set(url, {
          data: result,
          timestamp: Date.now(),
        });

        setData(result);
        setError(null);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setIsLoading(false);
      }
    };

    fetchData();

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

  return { data, isLoading, error };
}

// Usage
export function UserCard({ userId }: { userId: string }) {
  // Fetch same user multiple times: only makes ONE request
  const { data: user, isLoading } = useQuery(`/api/users/${userId}`, {
    cacheTime: 5 * 60 * 1000, // Cache for 5 minutes
  });

  if (isLoading) return <div>Loading...</div>;
  return <div>{user?.name}</div>;
}

export function App() {
  const [showCard1, setShowCard1] = useState(true);
  const [showCard2, setShowCard2] = useState(false);

  return (
    <div>
      <button onClick={() => setShowCard1(!showCard1)}>Toggle Card 1</button>
      <button onClick={() => setShowCard2(!showCard2)}>Toggle Card 2</button>

      {showCard1 && <UserCard userId="123" />}
      {showCard2 && <UserCard userId="123" />}
      {/* Both render UserCard for user 123, but only ONE fetch! */}
    </div>
  );
}

# JavaScript Version

javascript
import { useEffect, useState, useRef } from 'react';

export function useQuery(url, options = {}) {
  const cacheTime = options.cacheTime ?? 5 * 60 * 1000;
  const cacheRef = useRef(new Map());

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

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

    const cached = cacheRef.current.get(url);
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      console.log('Using cached data for:', url);
      setData(cached.data);
      setIsLoading(false);
      return;
    }

    console.log('Fetching:', url);
    const fetchData = async () => {
      try {
        setIsLoading(true);
        const response = await fetch(url, { signal: abortController.signal });
        const result = await response.json();

        cacheRef.current.set(url, {
          data: result,
          timestamp: Date.now(),
        });

        setData(result);
        setError(null);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setIsLoading(false);
      }
    };

    fetchData();

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

  return { data, isLoading, error };
}

export function UserCard({ userId }) {
  const { data: user, isLoading } = useQuery(`/api/users/${userId}`, {
    cacheTime: 5 * 60 * 1000,
  });

  if (isLoading) return <div>Loading...</div>;
  return <div>{user?.name}</div>;
}

export function App() {
  const [showCard1, setShowCard1] = useState(true);
  const [showCard2, setShowCard2] = useState(false);

  return (
    <div>
      <button onClick={() => setShowCard1(!showCard1)}>Toggle Card 1</button>
      <button onClick={() => setShowCard2(!showCard2)}>Toggle Card 2</button>

      {showCard1 && <UserCard userId="123" />}
      {showCard2 && <UserCard userId="123" />}
    </div>
  );
}

# Dependency Arrays: The Triggering Mechanism {#dependencies}

The dependency array controls WHEN the effect runs. It's the bridge between React world (props/state) and async world (network requests).

# TypeScript Version: Dependency Array Patterns

typescript
import { useEffect, useState } from 'react';

export function DependencyPatterns({ userId, filters }: { userId: string; filters: string[] }) {
  const [data, setData] = useState(null);

  // Pattern 1: No dependencies
  // Effect runs on EVERY render (bad for fetching!)
  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(setData);
    // This would make infinite requests!
  }); // ⚠️ Don't do this for fetching

  // Pattern 2: Empty array
  // Effect runs ONCE on mount
  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(setData);
  }, []); // ✅ Fetch once on mount

  // Pattern 3: With dependencies
  // Effect runs when userId or filters change
  useEffect(() => {
    const filterStr = filters.join(',');
    fetch(`/api/users/${userId}/posts?filters=${filterStr}`)
      .then(r => r.json())
      .then(setData);
  }, [userId, filters]); // ✅ Refetch when dependencies change

  // Pattern 4: Derived dependency
  // Calculate dependency to avoid re-running unnecessarily
  const filterStr = filters.join(',');
  useEffect(() => {
    fetch(`/api/users/${userId}/posts?filters=${filterStr}`)
      .then(r => r.json())
      .then(setData);
  }, [userId, filterStr]); // More explicit

  return <div>Data: {JSON.stringify(data)}</div>;
}

// The Mental Model:
// Dependencies = "When should this side effect run again?"
// 
// No dependencies → "Every render (almost never what you want for fetching)"
// [] → "Only once on mount"
// [userId] → "When userId changes"
// [userId, filters] → "When either userId or filters changes"
//
// If you fetch in effect but don't list dependencies that affect the URL,
// you'll have stale data!

# JavaScript Version

javascript
import { useEffect, useState } from 'react';

export function DependencyPatterns({ userId, filters }) {
  const [data, setData] = useState(null);

  // Pattern 1: No dependencies (bad)
  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(setData);
  }); // ⚠️ Don't do this

  // Pattern 2: Empty array (good for one-time setup)
  useEffect(() => {
    fetch('/api/data').then(r => r.json()).then(setData);
  }, []);

  // Pattern 3: With dependencies (most common)
  useEffect(() => {
    const filterStr = filters.join(',');
    fetch(`/api/users/${userId}/posts?filters=${filterStr}`)
      .then(r => r.json())
      .then(setData);
  }, [userId, filters]);

  return <div>Data: {JSON.stringify(data)}</div>;
}

# Mental Models for Complex Scenarios {#complex-scenarios}

Let's combine everything for real-world situations.

# TypeScript Version: Complete Real-World Example

typescript
import { useState, useEffect, useCallback } from 'react';

interface Product {
  id: string;
  name: string;
  price: number;
  reviews: string[];
}

interface SearchFilters {
  category: string;
  minPrice: number;
  maxPrice: number;
}

export function ProductSearch({ initialFilters }: { initialFilters: SearchFilters }) {
  // 1. Filters can change frequently
  const [filters, setFilters] = useState(initialFilters);

  // 2. Result data
  const [products, setProducts] = useState<Product[]>([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState<Error | null>(null);

  // 3. Debounce to avoid fetching on every keystroke
  const [debouncedFilters, setDebouncedFilters] = useState(filters);

  // Debounce effect: wait 500ms after filters stop changing
  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedFilters(filters);
    }, 500);

    return () => clearTimeout(timer);
  }, [filters]);

  // Fetch effect: triggered when debounced filters change
  useEffect(() => {
    const abortController = new AbortController();

    const fetchProducts = async () => {
      try {
        setIsLoading(true);

        const params = new URLSearchParams({
          category: debouncedFilters.category,
          minPrice: String(debouncedFilters.minPrice),
          maxPrice: String(debouncedFilters.maxPrice),
        });

        const response = await fetch(`/api/products?${params}`, {
          signal: abortController.signal,
        });

        if (!response.ok) throw new Error('Failed to fetch');

        const data = await response.json();
        setProducts(data);
        setError(null);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setIsLoading(false);
      }
    };

    fetchProducts();

    return () => abortController.abort();
  }, [debouncedFilters]); // Fetch when debounced filters change

  const handleCategoryChange = (category: string) => {
    // Update filters (this triggers debounce timer)
    setFilters(prev => ({ ...prev, category }));
  };

  return (
    <div>
      <input
        type="text"
        value={filters.category}
        onChange={e => handleCategoryChange(e.target.value)}
        placeholder="Search category..."
      />

      {isLoading && <p>Searching...</p>}
      {error && <p>Error: {error.message}</p>}

      <ul>
        {products.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

// Mental Model:
// 1. User types "electronics"
// 2. filters updated (but debouncedFilters not yet)
// 3. Debounce timer starts (500ms)
// 4. User continues typing "electronics > phones"
// 5. Debounce timer resets (new 500ms)
// 6. User stops typing
// 7. After 500ms: debouncedFilters updates
// 8. Fetch effect triggers with new filters
// 9. Loading shows while fetching
// 10. Products show when data arrives
//
// This prevents making 10 requests while user is still typing!

# JavaScript Version

javascript
import { useState, useEffect } from 'react';

export function ProductSearch({ initialFilters }) {
  const [filters, setFilters] = useState(initialFilters);
  const [products, setProducts] = useState([]);
  const [isLoading, setIsLoading] = useState(false);
  const [error, setError] = useState(null);

  const [debouncedFilters, setDebouncedFilters] = useState(filters);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedFilters(filters);
    }, 500);

    return () => clearTimeout(timer);
  }, [filters]);

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

    const fetchProducts = async () => {
      try {
        setIsLoading(true);

        const params = new URLSearchParams({
          category: debouncedFilters.category,
          minPrice: String(debouncedFilters.minPrice),
          maxPrice: String(debouncedFilters.maxPrice),
        });

        const response = await fetch(`/api/products?${params}`, {
          signal: abortController.signal,
        });

        if (!response.ok) throw new Error('Failed to fetch');

        const data = await response.json();
        setProducts(data);
        setError(null);
      } catch (err) {
        if (err instanceof Error && err.name !== 'AbortError') {
          setError(err);
        }
      } finally {
        setIsLoading(false);
      }
    };

    fetchProducts();

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

  const handleCategoryChange = (category) => {
    setFilters(prev => ({ ...prev, category }));
  };

  return (
    <div>
      <input
        type="text"
        value={filters.category}
        onChange={e => handleCategoryChange(e.target.value)}
        placeholder="Search category..."
      />

      {isLoading && <p>Searching...</p>}
      {error && <p>Error: {error.message}</p>}

      <ul>
        {products.map(product => (
          <li key={product.id}>
            {product.name} - ${product.price}
          </li>
        ))}
      </ul>
    </div>
  );
}

# FAQ

# Q: Why do people say "don't fetch in useEffect"?

A: They don't mean literally don't—they mean use a library that handles it better (React Query, SWR, tRPC). Fetching in useEffect works fine if you handle race conditions, aborts, caching, deduplication, and error retries. Libraries automate this.

# Q: Should I use async/await or .then()?

A: Either works. async/await is more readable. The key is not making useEffect itself async—create an async function inside.

# Q: Can I avoid the AbortController pattern?

A: You can use a flag (isMounted), but AbortController is better—it actually cancels the fetch at the network level. Use it.

# Q: Why doesn't my data appear on first render?

A: Because the fetch hasn't completed yet. You must show a loading state or default data while waiting.

# Q: How do I handle network errors gracefully?

A: Show error UI, allow retry, potentially use cached data. Test your app with slow networks (DevTools → Network → Throttling).

# Q: Is there a simpler way to do this?

A: Yes, use React Query, SWR, or tRPC. They handle caching, deduplication, retry, error handling, and more. For simple cases, these libraries save a lot of code.


What's your mental model for data fetching? Share your approach in the comments. Have you hit race conditions, stale data, or other fetching issues? Let's discuss the patterns that worked for you!

Sponsored Content

Google AdSense Placeholder

CONTENT SLOT

Sponsored

Google AdSense Placeholder

FOOTER SLOT