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
- The Simplest Fetch
- Adding Loading and Error States
- The Discriminated Union Pattern
- useReducer for Complex State
- Race Conditions Explained
- Fixing Race Conditions with AbortController
- Fixing Race Conditions with Boolean Flags
- Building a Reusable useFetch Hook
- Caching Fetched Data
- Retry Logic
- Pagination and Infinite Scroll
- The Evolution: Why TanStack Query Exists
- When to Use useEffect vs TanStack Query
- Anti-Patterns to Avoid
- 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 When | Use useReducer When |
|---|---|
| 2-3 related state variables | 4+ related state variables |
| Simple transitions | Complex state transitions |
| No derived actions | Actions depend on current state |
| Prototype / quick code | Production / 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
| Feature | AbortController | Boolean 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 |
| Complexity | Medium | Low |
| Recommendation | Production code | Quick 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
-
Always handle loading, error, and empty states. The simplest fetch is the buggiest fetch.
-
Discriminated unions > separate booleans.
{ status: 'loading' }prevents impossible states. -
Race conditions are real. Fast navigation + slow API = stale data shown. Use AbortController or boolean flags.
-
AbortController is the gold standard. It actually cancels network requests and saves bandwidth.
-
Build reusable hooks. Extract useFetch early — you'll need it everywhere.
-
Caching is harder than it looks. Invalidation, TTL, stale-while-revalidate — this is why TanStack Query exists.
-
Learn useEffect fetching first, then adopt TanStack Query. Understanding the fundamentals makes you a better user of the library.
-
Avoid fetch waterfalls. Parallel requests where possible, sequential only when one depends on another.
Explain-It Challenge
-
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?
-
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?
-
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