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
- The Core Mental Model
- The Effect-Fetch Loop
- Async Operations and Component Lifecycle
- The Race Condition Problem
- Handling Multiple Concurrent Requests
- Error States and Resilience
- The Cleanup Function: Cancellation
- Caching and Deduplication
- Dependency Arrays: The Triggering Mechanism
- Mental Models for Complex Scenarios
- 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
// 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
// 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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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
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!
Google AdSense Placeholder
CONTENT SLOT