๐ Advanced Data Fetching
You've learned the basics of data fetching with useEffect and the Fetch API. But real-world applications face challenges like race conditions, slow API responses, and large datasets. How do you build a search that doesn't overwhelm the server with requests? How do you handle pagination elegantly? What about when users navigate away before data loads? In this lesson, we'll explore advanced data fetching patterns that make your React apps fast, efficient, and production-ready. Get ready to level up your data game! ๐ฏ
๐ฏ Learning Objectives
By the end of this lesson, you will be able to:
- Understand and prevent race conditions in data fetching
- Implement debouncing and throttling for user input
- Build paginated data displays with proper state management
- Create infinite scroll components
- Implement client-side caching strategies
- Use AbortController for request cancellation
- Handle stale data and revalidation
- Understand React Query fundamentals
- Type complex async operations properly
- Build production-ready data fetching solutions
Estimated Time: 75-90 minutes
Project: Build a searchable, paginated product catalog with caching
๐ In This Lesson
๐ Race Conditions: The Silent Bug
Race conditions are one of the trickiest bugs in data fetching. They occur when multiple async operations complete out of order, causing your UI to display stale or incorrect data. Let's understand and fix them!
๐ Definition
Race Condition: A bug that occurs when the outcome depends on the unpredictable timing of multiple operations. In data fetching, this happens when a later request completes before an earlier one, displaying outdated data.
The Problem: Out-of-Order Responses
Imagine a user typing in a search box: "react" โ "react typescript" โ "react query". Three requests fire off, but the network is unpredictableโthe "react" request might finish last, showing old results!
โ ๏ธ Broken Code: Race Condition
interface SearchProps {
query: string;
}
interface SearchResult {
id: number;
title: string;
}
const SearchResults: React.FC<SearchProps> = ({ query }) => {
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) return;
setLoading(true);
// Problem: No way to know if this is still the latest query!
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
setResults(data); // โ Might be stale data!
setLoading(false);
});
}, [query]);
return (
<div>
{loading && <p>Loading...</p>}
{results.map(result => (
<div key={result.id}>{result.title}</div>
))}
</div>
);
};
// Scenario:
// User types: "react" โ fires request A
// User types: "react typescript" โ fires request B
// Request B finishes first โ shows correct results
// Request A finishes second โ overwrites with old "react" results โ
Solution 1: Ignore Flag
Use a boolean flag to track whether the effect is still active:
โ Fixed with Ignore Flag
const SearchResults: React.FC<SearchProps> = ({ query }) => {
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) return;
let ignore = false; // Flag to track if this effect is still active
setLoading(true);
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(data => {
if (!ignore) { // โ
Only update if still the latest query
setResults(data);
setLoading(false);
}
});
// Cleanup: Set ignore to true when effect is cleaned up
return () => {
ignore = true;
};
}, [query]);
return (
<div>
{loading && <p>Loading...</p>}
{results.map(result => (
<div key={result.id}>{result.title}</div>
))}
</div>
);
};
// How it works:
// 1. User types "react" โ request A starts, ignore = false
// 2. User types "react typescript" โ effect cleanup runs, sets ignore = true for A
// 3. New request B starts with its own ignore = false
// 4. If request A finishes, it sees ignore = true and skips updating โ
// 5. Request B finishes, sees ignore = false, updates state โ
Solution 2: AbortController (Better!)
AbortController lets you actually cancel the in-flight request, not just ignore it:
โ Best Solution: AbortController
const SearchResults: React.FC<SearchProps> = ({ query }) => {
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
useEffect(() => {
if (!query) return;
// Create AbortController for this request
const controller = new AbortController();
setLoading(true);
fetch(`/api/search?q=${query}`, {
signal: controller.signal // Pass the signal to fetch
})
.then(res => res.json())
.then(data => {
setResults(data);
setLoading(false);
})
.catch(err => {
if (err.name === 'AbortError') {
// Request was cancelled - this is expected!
console.log('Request cancelled');
} else {
// Real error
console.error('Error:', err);
setLoading(false);
}
});
// Cleanup: Abort the request if component unmounts or query changes
return () => {
controller.abort();
};
}, [query]);
return (
<div>
{loading && <p>Loading...</p>}
{results.map(result => (
<div key={result.id}>{result.title}</div>
))}
</div>
);
};
๐ก Why AbortController is Better
- Actually cancels the request - Saves bandwidth and server resources
- Browser stops processing - No wasted CPU parsing JSON from cancelled requests
- Cleaner code - The intent is clear: "cancel this request"
- Standard Web API - Works with fetch, axios (with adapters), and other libraries
Visualizing the Race Condition
๐ฎ Interactive: Race Condition Simulator
Click the buttons to see how race conditions happen and how AbortController fixes them!
Race Conditions in Custom Hooks
Let's build a reusable hook that handles race conditions automatically:
interface UseFetchOptions {
immediate?: boolean;
}
interface UseFetchReturn<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
}
function useFetch<T>(
url: string,
options: UseFetchOptions = {}
): UseFetchReturn<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async () => {
const controller = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch(url, {
signal: controller.signal
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
return controller;
}, [url]);
useEffect(() => {
if (options.immediate ?? true) {
const controller = fetchData();
return () => {
controller.then(c => c.abort());
};
}
}, [fetchData, options.immediate]);
const refetch = useCallback(async () => {
await fetchData();
}, [fetchData]);
return { data, loading, error, refetch };
}
// Usage - race conditions handled automatically!
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, loading, error } = useFetch<User>(
`/api/users/${userId}`
);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return <div>{user.name}</div>;
};
โ Key Takeaways: Race Conditions
- Race conditions happen when async operations complete out of order
- Always use AbortController to cancel old requests
- Return cleanup functions from useEffect that abort requests
- Build reusable hooks that handle cancellation automatically
- Test with slow network conditions (Chrome DevTools throttling!)
โฑ๏ธ Debouncing and Throttling
When users type in a search box, do you really want to make an API call for every single keystroke? That's a lot of unnecessary requests! Debouncing and throttling are techniques to limit how often a function runs. Let's master them!
๐ Definitions
Debouncing: Delays executing a function until after a certain time has passed since the last call. Perfect for search inputsโwait until the user stops typing.
Throttling: Limits how often a function can execute (e.g., at most once every 500ms). Great for scroll handlers or window resize events.
The Problem: Too Many Requests
โ ๏ธ Without Debouncing
const Search: React.FC = () => {
const [query, setQuery] = useState('');
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
if (!query) return;
// โ Fires on EVERY keystroke!
fetch(`/api/search?q=${query}`)
.then(res => res.json())
.then(setResults);
}, [query]);
return (
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
);
};
// User types: "r e a c t"
// Fires 5 requests: "r", "re", "rea", "reac", "react"
// Only the last one matters! โ
Solution: Debouncing
Let's build a custom useDebounce hook:
โ useDebounce Hook
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState<T>(value);
useEffect(() => {
// Set up a timer to update the debounced value
const timer = setTimeout(() => {
setDebouncedValue(value);
}, delay);
// Clean up the timer if value changes before delay expires
return () => {
clearTimeout(timer);
};
}, [value, delay]); // Re-run effect when value or delay changes
return debouncedValue;
}
// Usage
const Search: React.FC = () => {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500); // Wait 500ms after typing stops
const [results, setResults] = useState<string[]>([]);
useEffect(() => {
if (!debouncedQuery) return;
const controller = new AbortController();
// โ
Only fires after user stops typing for 500ms!
fetch(`/api/search?q=${debouncedQuery}`, {
signal: controller.signal
})
.then(res => res.json())
.then(setResults)
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
});
return () => controller.abort();
}, [debouncedQuery]);
return (
<div>
<input
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
<div>
{results.map(result => (
<div key={result}>{result}</div>
))}
</div>
</div>
);
};
// User types: "r e a c t"
// Only fires 1 request: "react" (after 500ms pause) โ
// Much better!
How Debouncing Works
๐ฎ Interactive: Debounce vs Throttle Comparison
Type in the input below and watch how debouncing and throttling handle rapid input differently!
Throttling for Scroll Events
Throttling is differentโit guarantees a function runs at most once per time period:
function useThrottle<T>(value: T, limit: number): T {
const [throttledValue, setThrottledValue] = useState<T>(value);
const lastRan = useRef(Date.now());
useEffect(() => {
const timer = setTimeout(() => {
if (Date.now() - lastRan.current >= limit) {
setThrottledValue(value);
lastRan.current = Date.now();
}
}, limit - (Date.now() - lastRan.current));
return () => {
clearTimeout(timer);
};
}, [value, limit]);
return throttledValue;
}
// Usage: Infinite scroll
const InfiniteList: React.FC = () => {
const [scrollY, setScrollY] = useState(0);
const throttledScrollY = useThrottle(scrollY, 200); // At most every 200ms
useEffect(() => {
const handleScroll = () => {
setScrollY(window.scrollY);
};
window.addEventListener('scroll', handleScroll);
return () => window.removeEventListener('scroll', handleScroll);
}, []);
useEffect(() => {
// Check if we need to load more data
// This only runs at most once every 200ms โ
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
if (throttledScrollY + windowHeight >= documentHeight - 100) {
console.log('Load more data!');
// Fetch more data...
}
}, [throttledScrollY]);
return <div>Scroll down...</div>;
};
Debounce vs Throttle: When to Use Which?
๐ก Quick Guide
| Technique | When to Use | Examples |
|---|---|---|
| Debounce | Wait until user stops doing something | Search input, form validation, window resize |
| Throttle | Limit frequency of continuous events | Scroll handlers, mouse move, window resize (continuous feedback) |
โ Pro Tips
- Debounce delays: 300-500ms is typical for search, 1000ms+ for autosave
- Show loading indicators: Users should know their input is being processed
- Combine with AbortController: Cancel old requests when new debounced value arrives
- Consider lodash: Libraries like lodash provide battle-tested implementations
๐ Request Cancellation with AbortController
We've touched on AbortController for race conditions, but let's dive deeper. Properly cancelling requests is crucial for building responsive, efficient applications. Let's master this powerful API!
AbortController Deep Dive
The AbortController API consists of two parts:
// 1. The Controller - creates and controls the signal
const controller = new AbortController();
// 2. The Signal - passed to fetch, can be "aborted"
const signal = controller.signal;
// Check if aborted
console.log(signal.aborted); // false
// Listen for abort events
signal.addEventListener('abort', () => {
console.log('Request was aborted!');
});
// Abort the request
controller.abort();
console.log(signal.aborted); // true
Pattern 1: User-Triggered Cancellation
Let users cancel long-running requests:
โ Cancellable Request
interface Report {
id: string;
data: any;
}
const ReportGenerator: React.FC = () => {
const [loading, setLoading] = useState(false);
const [report, setReport] = useState<Report | null>(null);
const [error, setError] = useState<string | null>(null);
const controllerRef = useRef<AbortController | null>(null);
const generateReport = async () => {
// Cancel any existing request
if (controllerRef.current) {
controllerRef.current.abort();
}
// Create new controller
controllerRef.current = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch('/api/generate-report', {
method: 'POST',
signal: controllerRef.current.signal,
body: JSON.stringify({ type: 'annual' })
});
if (!response.ok) {
throw new Error('Failed to generate report');
}
const data = await response.json();
setReport(data);
} catch (err) {
if (err instanceof Error) {
if (err.name === 'AbortError') {
console.log('Report generation cancelled');
} else {
setError(err.message);
}
}
} finally {
setLoading(false);
controllerRef.current = null;
}
};
const cancelGeneration = () => {
if (controllerRef.current) {
controllerRef.current.abort();
setLoading(false);
}
};
return (
<div>
<button onClick={generateReport} disabled={loading}>
Generate Report
</button>
{loading && (
<div>
<p>Generating report...</p>
<button onClick={cancelGeneration}>Cancel</button>
</div>
)}
{error && <p style={{ color: 'red' }}>Error: {error}</p>}
{report && <pre>{JSON.stringify(report, null, 2)}</pre>}
</div>
);
};
Pattern 2: Timeout with AbortController
Implement request timeouts to prevent hanging forever:
async function fetchWithTimeout(
url: string,
options: RequestInit = {},
timeout: number = 5000 // 5 second default
): Promise<Response> {
const controller = new AbortController();
// Set up timeout
const timeoutId = setTimeout(() => {
controller.abort();
}, timeout);
try {
const response = await fetch(url, {
...options,
signal: controller.signal
});
clearTimeout(timeoutId);
return response;
} catch (err) {
clearTimeout(timeoutId);
if (err instanceof Error && err.name === 'AbortError') {
throw new Error(`Request timeout after ${timeout}ms`);
}
throw err;
}
}
// Usage
const DataFetcher: React.FC = () => {
const [data, setData] = useState(null);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
fetchWithTimeout('/api/slow-endpoint', {}, 3000)
.then(res => res.json())
.then(setData)
.catch(err => setError(err.message));
}, []);
if (error) return <div>Error: {error}</div>;
return <div>{data ? JSON.stringify(data) : 'Loading...'}</div>;
};
Pattern 3: Multiple Request Cancellation
Cancel multiple related requests at once:
interface DashboardData {
users: User[];
posts: Post[];
analytics: Analytics;
}
const Dashboard: React.FC = () => {
const [data, setData] = useState<Partial<DashboardData>>({});
const [loading, setLoading] = useState(false);
useEffect(() => {
const controller = new AbortController();
const signal = controller.signal;
setLoading(true);
// Fire multiple requests with the same signal
Promise.all([
fetch('/api/users', { signal }).then(r => r.json()),
fetch('/api/posts', { signal }).then(r => r.json()),
fetch('/api/analytics', { signal }).then(r => r.json())
])
.then(([users, posts, analytics]) => {
setData({ users, posts, analytics });
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error('Error loading dashboard:', err);
}
})
.finally(() => {
setLoading(false);
});
// Cleanup: abort ALL requests when component unmounts
return () => {
controller.abort();
};
}, []);
if (loading) return <div>Loading dashboard...</div>;
return (
<div>
<h2>Dashboard</h2>
{data.users && <UsersList users={data.users} />}
{data.posts && <PostsList posts={data.posts} />}
{data.analytics && <Analytics data={data.analytics} />}
</div>
);
};
Pattern 4: AbortController in Custom Hooks
Build a robust, reusable fetch hook with cancellation:
โ Production-Ready useFetch Hook
interface UseFetchOptions extends RequestInit {
immediate?: boolean;
timeout?: number;
}
interface UseFetchReturn<T> {
data: T | null;
loading: boolean;
error: Error | null;
refetch: () => Promise<void>;
abort: () => void;
}
function useFetch<T>(
url: string,
options: UseFetchOptions = {}
): UseFetchReturn<T> {
const [data, setData] = useState<T | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const controllerRef = useRef<AbortController | null>(null);
const abort = useCallback(() => {
if (controllerRef.current) {
controllerRef.current.abort();
controllerRef.current = null;
}
}, []);
const fetchData = useCallback(async () => {
// Abort any existing request
abort();
// Create new controller
controllerRef.current = new AbortController();
const { immediate, timeout, ...fetchOptions } = options;
setLoading(true);
setError(null);
try {
// Set up timeout if specified
let timeoutId: NodeJS.Timeout | undefined;
if (timeout) {
timeoutId = setTimeout(() => {
controllerRef.current?.abort();
}, timeout);
}
const response = await fetch(url, {
...fetchOptions,
signal: controllerRef.current.signal
});
if (timeoutId) clearTimeout(timeoutId);
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const result = await response.json();
setData(result);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err);
}
} finally {
setLoading(false);
}
}, [url, options, abort]);
useEffect(() => {
if (options.immediate ?? true) {
fetchData();
}
// Cleanup: abort on unmount or dependency change
return () => {
abort();
};
}, [fetchData, options.immediate, abort]);
return {
data,
loading,
error,
refetch: fetchData,
abort
};
}
// Usage
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, loading, error, refetch, abort } = useFetch<User>(
`/api/users/${userId}`,
{ timeout: 5000 }
);
if (loading) {
return (
<div>
Loading...
<button onClick={abort}>Cancel</button>
</div>
);
}
if (error) return <div>Error: {error.message}</div>;
if (!user) return null;
return (
<div>
<h2>{user.name}</h2>
<button onClick={refetch}>Refresh</button>
</div>
);
};
๐ก AbortController Best Practices
- Always clean up: Abort requests in useEffect cleanup functions
- Store in refs: Use useRef to keep controller references across renders
- Handle AbortError: Don't treat cancelled requests as real errors
- Provide user feedback: Show cancel buttons for long operations
- Timeout pattern: Combine with setTimeout for automatic timeouts
- One signal, many requests: Use the same signal for related requests
๐ Pagination Patterns
Large datasets can't be loaded all at onceโpagination splits data into manageable chunks. Let's explore different pagination strategies and build a complete pagination system!
๐ Definition
Pagination: A technique for dividing large datasets into discrete pages, loading only the data needed for the current page. This improves performance, reduces bandwidth, and enhances user experience.
Pagination Strategies
| Strategy | How It Works | Best For | API Pattern |
|---|---|---|---|
| Offset/Limit | Skip N items, take M items | Static data, known total count | ?page=2&limit=20 or ?offset=20&limit=20 |
| Cursor-Based | Use last item's ID as cursor | Real-time data, social feeds | ?cursor=abc123&limit=20 |
| Page Numbers | Request specific page number | Traditional pagination UI | ?page=3&per_page=20 |
Pattern 1: Offset-Based Pagination
The most common pagination pattern:
โ Complete Pagination Component
interface Product {
id: number;
name: string;
price: number;
}
interface PaginationInfo {
currentPage: number;
totalPages: number;
totalItems: number;
itemsPerPage: number;
}
interface ApiResponse {
data: Product[];
pagination: PaginationInfo;
}
const ProductList: React.FC = () => {
const [products, setProducts] = useState<Product[]>([]);
const [pagination, setPagination] = useState<PaginationInfo>({
currentPage: 1,
totalPages: 1,
totalItems: 0,
itemsPerPage: 20
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const fetchProducts = useCallback(async (page: number) => {
const controller = new AbortController();
setLoading(true);
setError(null);
try {
const response = await fetch(
`/api/products?page=${page}&limit=${pagination.itemsPerPage}`,
{ signal: controller.signal }
);
if (!response.ok) {
throw new Error('Failed to fetch products');
}
const data: ApiResponse = await response.json();
setProducts(data.data);
setPagination(data.pagination);
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err.message);
}
} finally {
setLoading(false);
}
return controller;
}, [pagination.itemsPerPage]);
useEffect(() => {
const controller = fetchProducts(pagination.currentPage);
return () => {
controller.then(c => c.abort());
};
}, [pagination.currentPage, fetchProducts]);
const goToPage = (page: number) => {
setPagination(prev => ({ ...prev, currentPage: page }));
window.scrollTo({ top: 0, behavior: 'smooth' });
};
const nextPage = () => {
if (pagination.currentPage < pagination.totalPages) {
goToPage(pagination.currentPage + 1);
}
};
const previousPage = () => {
if (pagination.currentPage > 1) {
goToPage(pagination.currentPage - 1);
}
};
if (loading && products.length === 0) {
return <div>Loading products...</div>;
}
if (error) {
return <div>Error: {error}</div>;
}
return (
<div>
<h2>Products</h2>
{/* Product Grid */}
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(200px, 1fr))',
gap: '1rem'
}}>
{products.map(product => (
<div key={product.id} style={{
border: '1px solid #ddd',
padding: '1rem',
borderRadius: '8px'
}}>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
</div>
{loading && <div style={{ textAlign: 'center', margin: '2rem' }}>Loading...</div>}
{/* Pagination Controls */}
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '1rem',
marginTop: '2rem'
}}>
<button
onClick={previousPage}
disabled={pagination.currentPage === 1 || loading}
>
Previous
</button>
<span>
Page {pagination.currentPage} of {pagination.totalPages}
<br />
({pagination.totalItems} total items)
</span>
<button
onClick={nextPage}
disabled={pagination.currentPage === pagination.totalPages || loading}
>
Next
</button>
</div>
{/* Page Numbers */}
<div style={{
display: 'flex',
justifyContent: 'center',
gap: '0.5rem',
marginTop: '1rem'
}}>
{Array.from({ length: pagination.totalPages }, (_, i) => i + 1).map(page => (
<button
key={page}
onClick={() => goToPage(page)}
disabled={loading}
style={{
padding: '0.5rem 1rem',
backgroundColor: page === pagination.currentPage ? '#667eea' : 'white',
color: page === pagination.currentPage ? 'white' : 'black',
border: '1px solid #667eea',
borderRadius: '4px',
cursor: 'pointer'
}}
>
{page}
</button>
))}
</div>
</div>
);
};
Pattern 2: Reusable Pagination Hook
Extract pagination logic into a custom hook:
interface UsePaginationOptions<T> {
fetchFunction: (page: number, limit: number) => Promise<{
data: T[];
pagination: PaginationInfo;
}>;
initialPage?: number;
itemsPerPage?: number;
}
interface UsePaginationReturn<T> {
data: T[];
pagination: PaginationInfo;
loading: boolean;
error: Error | null;
goToPage: (page: number) => void;
nextPage: () => void;
previousPage: () => void;
refresh: () => void;
}
function usePagination<T>(
options: UsePaginationOptions<T>
): UsePaginationReturn<T> {
const { fetchFunction, initialPage = 1, itemsPerPage = 20 } = options;
const [data, setData] = useState<T[]>([]);
const [pagination, setPagination] = useState<PaginationInfo>({
currentPage: initialPage,
totalPages: 1,
totalItems: 0,
itemsPerPage
});
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const fetchData = useCallback(async (page: number) => {
setLoading(true);
setError(null);
try {
const result = await fetchFunction(page, itemsPerPage);
setData(result.data);
setPagination(result.pagination);
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [fetchFunction, itemsPerPage]);
useEffect(() => {
fetchData(pagination.currentPage);
}, [pagination.currentPage, fetchData]);
const goToPage = useCallback((page: number) => {
if (page >= 1 && page <= pagination.totalPages) {
setPagination(prev => ({ ...prev, currentPage: page }));
window.scrollTo({ top: 0, behavior: 'smooth' });
}
}, [pagination.totalPages]);
const nextPage = useCallback(() => {
goToPage(pagination.currentPage + 1);
}, [pagination.currentPage, goToPage]);
const previousPage = useCallback(() => {
goToPage(pagination.currentPage - 1);
}, [pagination.currentPage, goToPage]);
const refresh = useCallback(() => {
fetchData(pagination.currentPage);
}, [pagination.currentPage, fetchData]);
return {
data,
pagination,
loading,
error,
goToPage,
nextPage,
previousPage,
refresh
};
}
// Usage - Much cleaner!
const ProductList: React.FC = () => {
const fetchProducts = async (page: number, limit: number) => {
const response = await fetch(`/api/products?page=${page}&limit=${limit}`);
return response.json();
};
const {
data: products,
pagination,
loading,
error,
nextPage,
previousPage,
goToPage
} = usePagination({ fetchFunction: fetchProducts });
if (error) return <div>Error: {error.message}</div>;
return (
<div>
{loading && <div>Loading...</div>}
<div>
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
<PaginationControls
pagination={pagination}
onNext={nextPage}
onPrevious={previousPage}
onGoToPage={goToPage}
/>
</div>
);
};
Pattern 3: URL-Based Pagination
Sync pagination state with the URL for shareable links:
import { useSearchParams } from 'react-router-dom';
const ProductList: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const [products, setProducts] = useState<Product[]>([]);
const [totalPages, setTotalPages] = useState(1);
const [loading, setLoading] = useState(false);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
fetch(`/api/products?page=${currentPage}&limit=20`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => {
setProducts(data.data);
setTotalPages(data.pagination.totalPages);
})
.catch(err => {
if (err.name !== 'AbortError') {
console.error(err);
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [currentPage]);
const goToPage = (page: number) => {
setSearchParams({ page: page.toString() });
};
return (
<div>
{loading && <div>Loading...</div>}
{products.map(product => (
<div key={product.id}>{product.name}</div>
))}
<div>
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1}
>
Previous
</button>
<span>Page {currentPage} of {totalPages}</span>
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === totalPages}
>
Next
</button>
</div>
</div>
);
};
// Now users can share URLs like:
// /products?page=5
// And the page state is preserved! โ
โ Pagination Best Practices
- Show total count: Users want to know how much data exists
- Disable during loading: Prevent double-clicks and race conditions
- Scroll to top: When changing pages, scroll the user to the top
- URL sync: Store page in URL for shareable, bookmarkable links
- Loading states: Show skeleton screens or spinners during fetch
- Smart page numbers: Don't show all 1000 pagesโuse ellipsis (...)
- Cancel old requests: Always use AbortController for pagination
โพ๏ธ Infinite Scroll
Instead of clicking "Next Page," what if content just loaded as you scroll down? That's infinite scrollโpopular on social media and modern web apps. Let's build it!
๐ Definition
Infinite Scroll: A UX pattern where new content automatically loads as the user scrolls down the page. Instead of pagination controls, scrolling triggers data fetching, creating a seamless, continuous browsing experience.
How Infinite Scroll Works
๐ฎ Interactive: Infinite Scroll Demo
Scroll down in the container below to see how Intersection Observer triggers data loading!
Detecting When to Load More
We need to detect when the user is near the bottom of the page:
// Method 1: Scroll position calculation
function isNearBottom(threshold: number = 100): boolean {
const windowHeight = window.innerHeight;
const documentHeight = document.documentElement.scrollHeight;
const scrollTop = window.scrollY;
// Distance from bottom
const distanceFromBottom = documentHeight - (scrollTop + windowHeight);
return distanceFromBottom < threshold;
}
// Method 2: Using Intersection Observer (better!)
function useInfiniteScroll(callback: () => void) {
const observerRef = useRef<IntersectionObserver | null>(null);
const loadMoreRef = useRef<HTMLDivElement | null>(null);
useEffect(() => {
observerRef.current = new IntersectionObserver(
(entries) => {
// If the sentinel element is visible, load more
if (entries[0].isIntersecting) {
callback();
}
},
{ threshold: 1.0 } // Fully visible
);
if (loadMoreRef.current) {
observerRef.current.observe(loadMoreRef.current);
}
return () => {
if (observerRef.current) {
observerRef.current.disconnect();
}
};
}, [callback]);
return loadMoreRef;
}
Pattern 1: Basic Infinite Scroll
โ Complete Infinite Scroll Component
interface Post {
id: number;
title: string;
content: string;
}
const InfinitePostFeed: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const [error, setError] = useState<string | null>(null);
const loadMorePosts = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
setError(null);
try {
const response = await fetch(`/api/posts?page=${page}&limit=20`);
if (!response.ok) {
throw new Error('Failed to fetch posts');
}
const data = await response.json();
if (data.posts.length === 0) {
setHasMore(false);
} else {
setPosts(prev => [...prev, ...data.posts]);
setPage(prev => prev + 1);
}
} catch (err) {
setError(err instanceof Error ? err.message : 'Unknown error');
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
// Load initial data
useEffect(() => {
loadMorePosts();
}, []); // Empty deps - only load once on mount
// Intersection Observer for infinite scroll
const observerTarget = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMorePosts();
}
},
{ threshold: 1.0 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => {
if (observerTarget.current) {
observer.unobserve(observerTarget.current);
}
};
}, [loadMorePosts, hasMore, loading]);
return (
<div style={{ maxWidth: '800px', margin: '0 auto', padding: '2rem' }}>
<h1>Infinite Post Feed</h1>
{/* Posts */}
<div>
{posts.map(post => (
<article
key={post.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1rem'
}}
>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
{/* Loading indicator */}
{loading && (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<p>Loading more posts...</p>
</div>
)}
{/* Error message */}
{error && (
<div style={{ color: 'red', textAlign: 'center', padding: '2rem' }}>
Error: {error}
</div>
)}
{/* End of content message */}
{!hasMore && (
<div style={{ textAlign: 'center', padding: '2rem', color: '#666' }}>
<p>๐ You've reached the end!</p>
</div>
)}
{/* Intersection Observer target (sentinel element) */}
<div
ref={observerTarget}
style={{ height: '20px' }}
/>
</div>
);
};
Pattern 2: Reusable Infinite Scroll Hook
Extract infinite scroll logic into a custom hook:
interface UseInfiniteScrollOptions<T> {
fetchFunction: (page: number) => Promise<T[]>;
initialPage?: number;
threshold?: number;
}
interface UseInfiniteScrollReturn<T> {
items: T[];
loading: boolean;
error: Error | null;
hasMore: boolean;
loadMore: () => void;
reset: () => void;
observerTarget: React.RefObject<HTMLDivElement>;
}
function useInfiniteScroll<T>(
options: UseInfiniteScrollOptions<T>
): UseInfiniteScrollReturn<T> {
const { fetchFunction, initialPage = 1, threshold = 1.0 } = options;
const [items, setItems] = useState<T[]>([]);
const [page, setPage] = useState(initialPage);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<Error | null>(null);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef<HTMLDivElement>(null);
const loadMore = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
setError(null);
try {
const newItems = await fetchFunction(page);
if (newItems.length === 0) {
setHasMore(false);
} else {
setItems(prev => [...prev, ...newItems]);
setPage(prev => prev + 1);
}
} catch (err) {
setError(err as Error);
} finally {
setLoading(false);
}
}, [fetchFunction, page, loading, hasMore]);
const reset = useCallback(() => {
setItems([]);
setPage(initialPage);
setHasMore(true);
setError(null);
}, [initialPage]);
// Intersection Observer setup
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMore();
}
},
{ threshold }
);
const currentTarget = observerTarget.current;
if (currentTarget) {
observer.observe(currentTarget);
}
return () => {
if (currentTarget) {
observer.unobserve(currentTarget);
}
};
}, [loadMore, hasMore, loading, threshold]);
// Load initial data
useEffect(() => {
if (items.length === 0 && page === initialPage) {
loadMore();
}
}, []);
return {
items,
loading,
error,
hasMore,
loadMore,
reset,
observerTarget
};
}
// Usage - Much cleaner!
const PostFeed: React.FC = () => {
const fetchPosts = async (page: number) => {
const response = await fetch(`/api/posts?page=${page}&limit=20`);
const data = await response.json();
return data.posts;
};
const {
items: posts,
loading,
error,
hasMore,
observerTarget
} = useInfiniteScroll<Post>({ fetchFunction: fetchPosts });
return (
<div>
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
{loading && <div>Loading...</div>}
{error && <div>Error: {error.message}</div>}
{!hasMore && <div>No more posts!</div>}
<div ref={observerTarget} style={{ height: '20px' }} />
</div>
);
};
Pattern 3: Infinite Scroll with Search/Filter
Combine infinite scroll with search or filters:
const SearchablePostFeed: React.FC = () => {
const [searchQuery, setSearchQuery] = useState('');
const debouncedQuery = useDebounce(searchQuery, 500);
const fetchPosts = useCallback(async (page: number) => {
const params = new URLSearchParams({
page: page.toString(),
limit: '20',
q: debouncedQuery
});
const response = await fetch(`/api/posts?${params}`);
const data = await response.json();
return data.posts;
}, [debouncedQuery]);
const {
items: posts,
loading,
error,
hasMore,
reset,
observerTarget
} = useInfiniteScroll<Post>({ fetchFunction: fetchPosts });
// Reset when search query changes
useEffect(() => {
reset();
}, [debouncedQuery, reset]);
return (
<div>
<input
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder="Search posts..."
style={{
width: '100%',
padding: '0.75rem',
fontSize: '1rem',
marginBottom: '2rem'
}}
/>
{posts.length === 0 && !loading && (
<div>No posts found.</div>
)}
{posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
{loading && <div>Loading...</div>}
{error && <div>Error: {error.message}</div>}
{!hasMore && posts.length > 0 && <div>No more results!</div>}
<div ref={observerTarget} style={{ height: '20px' }} />
</div>
);
};
๐ก Infinite Scroll Best Practices
- Use Intersection Observer: More performant than scroll event listeners
- Show loading states: Users need feedback that more content is loading
- Handle "no more data": Show a clear end message
- Provide "Back to Top" button: For long scrolls
- Reset on filter change: Clear items when search/filter changes
- Consider accessibility: Provide keyboard navigation alternatives
- Throttle loading: Prevent rapid-fire requests
- Save scroll position: When navigating back, restore position
โ ๏ธ When NOT to Use Infinite Scroll
- Footer is important: Users can never reach a footer with infinite scroll!
- Specific items needed: Finding item #237 is frustrating with infinite scroll
- Goal-oriented tasks: E-commerce checkout flows work better with pagination
- Print/export needed: Paginated data is easier to export
Solution: Provide pagination as an alternative, or use "Load More" button instead of automatic loading.
๐พ Client-Side Caching
Why fetch the same data multiple times? Caching stores fetched data so you can reuse it without making redundant API calls. Let's build a smart caching system!
๐ Definition
Client-Side Caching: Storing API responses in memory (or browser storage) to avoid redundant network requests. This improves performance, reduces server load, and provides instant data access for frequently accessed resources.
Why Caching Matters
Without Caching:
// User visits /users/1 โ API call
// User clicks "Back" โ API call
// User visits /users/1 again โ API call
// User switches tabs, comes back โ API call
//
// Result: 4 identical API calls for the same data! โ
With Caching:
// User visits /users/1 โ API call, cache result
// User clicks "Back" โ Use cached data (instant!)
// User visits /users/1 again โ Use cached data (instant!)
// User switches tabs, comes back โ Use cached data (instant!)
//
// Result: 1 API call, 3 instant cache hits! โ
Pattern 1: Simple In-Memory Cache
โ Basic Cache Implementation
// Simple cache object
const cache = new Map<string, any>();
function useCachedFetch<T>(url: string) {
const [data, setData] = useState<T | null>(() => {
// Check cache first
return cache.get(url) || null;
});
const [loading, setLoading] = useState(!cache.has(url));
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
// If data is in cache, don't fetch
if (cache.has(url)) {
return;
}
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(result => {
cache.set(url, result); // Store in cache
setData(result);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
})
.finally(() => {
setLoading(false);
});
return () => controller.abort();
}, [url]);
return { data, loading, error };
}
// Usage
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, loading } = useCachedFetch<User>(`/api/users/${userId}`);
// First visit: loading = true, fetches data
// Subsequent visits: loading = false, instant data! โ
if (loading) return <div>Loading...</div>;
return <div>{user?.name}</div>;
};
Pattern 2: Cache with Expiration
Cached data can become stale. Let's add expiration:
interface CacheEntry<T> {
data: T;
timestamp: number;
}
class Cache {
private cache = new Map<string, CacheEntry<any>>();
private defaultTTL = 5 * 60 * 1000; // 5 minutes
set<T>(key: string, data: T, ttl?: number): void {
this.cache.set(key, {
data,
timestamp: Date.now()
});
}
get<T>(key: string, ttl?: number): T | null {
const entry = this.cache.get(key);
if (!entry) return null;
const age = Date.now() - entry.timestamp;
const maxAge = ttl || this.defaultTTL;
// Check if expired
if (age > maxAge) {
this.cache.delete(key);
return null;
}
return entry.data;
}
has(key: string, ttl?: number): boolean {
return this.get(key, ttl) !== null;
}
clear(): void {
this.cache.clear();
}
delete(key: string): void {
this.cache.delete(key);
}
}
// Global cache instance
const apiCache = new Cache();
function useCachedFetch<T>(url: string, ttl?: number) {
const [data, setData] = useState<T | null>(() => apiCache.get<T>(url, ttl));
const [loading, setLoading] = useState(!apiCache.has(url, ttl));
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
// Check if valid cache exists
const cached = apiCache.get<T>(url, ttl);
if (cached) {
setData(cached);
setLoading(false);
return;
}
const controller = new AbortController();
setLoading(true);
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(result => {
apiCache.set(url, result, ttl);
setData(result);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError(err);
}
})
.finally(() => {
setLoading(false);
});
return () => controller.abort();
}, [url, ttl]);
const invalidate = useCallback(() => {
apiCache.delete(url);
setData(null);
}, [url]);
return { data, loading, error, invalidate };
}
// Usage
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const {
data: user,
loading,
invalidate
} = useCachedFetch<User>(
`/api/users/${userId}`,
2 * 60 * 1000 // 2 minute cache
);
๐ฎ Interactive: Cache Hit/Miss Simulator
Click "Fetch" buttons to see how caching works. First request is a cache miss (slow), subsequent requests are cache hits (instant)!
Cache Hits: 0
Cache Misses: 0
return (
<div>
{loading && <div>Loading...</div>}
{user && (
<div>
<h2>{user.name}</h2>
<button onClick={invalidate}>Refresh</button>
</div>
)}
</div>
);
};
Pattern 3: Persistent Cache with localStorage
Persist cache across page reloads:
class PersistentCache extends Cache {
private storageKey = 'api_cache';
constructor() {
super();
this.loadFromStorage();
}
private loadFromStorage(): void {
try {
const stored = localStorage.getItem(this.storageKey);
if (stored) {
const data = JSON.parse(stored);
// Restore cache from localStorage
Object.entries(data).forEach(([key, value]) => {
this.cache.set(key, value as CacheEntry<any>);
});
}
} catch (error) {
console.error('Failed to load cache from storage:', error);
}
}
private saveToStorage(): void {
try {
const data = Object.fromEntries(this.cache.entries());
localStorage.setItem(this.storageKey, JSON.stringify(data));
} catch (error) {
console.error('Failed to save cache to storage:', error);
}
}
set<T>(key: string, data: T, ttl?: number): void {
super.set(key, data, ttl);
this.saveToStorage();
}
delete(key: string): void {
super.delete(key);
this.saveToStorage();
}
clear(): void {
super.clear();
localStorage.removeItem(this.storageKey);
}
}
// Now cache persists across page reloads! โ
const persistentCache = new PersistentCache();
Cache Invalidation Strategies
๐ก When to Invalidate Cache
- Time-based (TTL): Expire after X minutes
- On mutation: Clear cache after POST/PUT/DELETE
- Manual refresh: User clicks "Refresh" button
- Background revalidation: Fetch in background, update when ready
- Optimistic updates: Update cache immediately, sync with server
// Invalidate on mutation
const useUpdateUser = () => {
const updateUser = async (userId: string, updates: Partial<User>) => {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
body: JSON.stringify(updates)
});
if (response.ok) {
// Invalidate cache for this user
apiCache.delete(`/api/users/${userId}`);
// Could also invalidate list caches
apiCache.delete('/api/users');
}
return response.json();
};
return { updateUser };
};
โ Caching Best Practices
- Use appropriate TTLs: Frequently changing data needs shorter TTLs
- Invalidate on mutations: Clear cache after creating/updating/deleting
- Cache key strategy: Include query params in cache keys
- Memory limits: Don't cache everythingโprioritize frequently accessed data
- Show stale data: Display cached data while revalidating in background
- Handle cache errors: Gracefully degrade if cache fails
โ ๏ธ Cache Pitfalls to Avoid
- Caching user-specific data globally: User A sees User B's data!
- No expiration: Stale data forever
- Caching errors: Don't cache failed requests
- Memory leaks: Unlimited cache growth crashes browser
๐ฎ Introduction to React Query
We've built custom hooks for caching, pagination, and infinite scroll. But what if there was a library that handled all of this (and more) out of the box? Meet React Query (TanStack Query)โthe most powerful data fetching library for React!
๐ What is React Query?
React Query (TanStack Query): A powerful data synchronization library that handles caching, background updates, stale data revalidation, pagination, and moreโall with minimal code. It treats server state as fundamentally different from client state and provides tools specifically for managing it.
Why React Query?
What We've Built vs React Query:
| Feature | Custom Hooks | React Query |
|---|---|---|
| Caching | 50+ lines of code | Built-in, automatic |
| Stale data | Manual TTL management | Automatic revalidation |
| Background refetch | Custom implementation | Automatic on window focus |
| Pagination | Custom state management | Built-in helpers |
| Infinite scroll | Complex state logic | useInfiniteQuery hook |
| Optimistic updates | Manual cache updates | Built-in mutation tools |
| Request deduplication | Not handled | Automatic |
Setup React Query
// Install
npm install @tanstack/react-query
// Setup in your app
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
import { ReactQueryDevtools } from '@tanstack/react-query-devtools';
// Create a query client
const queryClient = new QueryClient({
defaultOptions: {
queries: {
staleTime: 5 * 60 * 1000, // 5 minutes
cacheTime: 10 * 60 * 1000, // 10 minutes
retry: 3,
refetchOnWindowFocus: true,
},
},
});
// Wrap your app
function App() {
return (
<QueryClientProvider client={queryClient}>
<YourApp />
<ReactQueryDevtools initialIsOpen={false} />
</QueryClientProvider>
);
}
Basic Usage: useQuery
Replace your custom fetch hooks with useQuery:
โ Before (Custom Hook)
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<Error | null>(null);
useEffect(() => {
const controller = new AbortController();
fetch(`/api/users/${userId}`, { signal: controller.signal })
.then(res => res.json())
.then(setUser)
.catch(setError)
.finally(() => setLoading(false));
return () => controller.abort();
}, [userId]);
if (loading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
};
โ After (React Query)
import { useQuery } from '@tanstack/react-query';
const fetchUser = async (userId: string): Promise<User> => {
const response = await fetch(`/api/users/${userId}`);
if (!response.ok) {
throw new Error('Failed to fetch user');
}
return response.json();
};
const UserProfile: React.FC<{ userId: string }> = ({ userId }) => {
const { data: user, isLoading, error } = useQuery({
queryKey: ['user', userId], // Unique key for this query
queryFn: () => fetchUser(userId), // Function to fetch data
});
if (isLoading) return <div>Loading...</div>;
if (error) return <div>Error: {error.message}</div>;
return <div>{user?.name}</div>;
};
// Benefits:
// โ
Automatic caching
// โ
Automatic request cancellation
// โ
Automatic background refetch
// โ
Request deduplication
// โ
Much less code!
Mutations with useMutation
Handle POST/PUT/DELETE operations:
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface UpdateUserData {
name: string;
email: string;
}
const updateUser = async (userId: string, data: UpdateUserData): Promise<User> => {
const response = await fetch(`/api/users/${userId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error('Failed to update user');
}
return response.json();
};
const UserEditForm: React.FC<{ userId: string }> = ({ userId }) => {
const queryClient = useQueryClient();
const mutation = useMutation({
mutationFn: (data: UpdateUserData) => updateUser(userId, data),
onSuccess: (updatedUser) => {
// Invalidate and refetch user query
queryClient.invalidateQueries({ queryKey: ['user', userId] });
// Or update cache directly (optimistic update)
queryClient.setQueryData(['user', userId], updatedUser);
},
});
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const formData = new FormData(e.currentTarget);
mutation.mutate({
name: formData.get('name') as string,
email: formData.get('email') as string,
});
};
return (
<form onSubmit={handleSubmit}>
<input name="name" placeholder="Name" />
<input name="email" type="email" placeholder="Email" />
<button type="submit" disabled={mutation.isPending}>
{mutation.isPending ? 'Saving...' : 'Save'}
</button>
{mutation.isError && (
<div style={{ color: 'red' }}>
Error: {mutation.error.message}
</div>
)}
{mutation.isSuccess && (
<div style={{ color: 'green' }}>
User updated successfully!
</div>
)}
</form>
);
};
Pagination with React Query
const ProductList: React.FC = () => {
const [page, setPage] = useState(1);
const { data, isLoading, isPlaceholderData } = useQuery({
queryKey: ['products', page],
queryFn: () => fetchProducts(page),
placeholderData: (previousData) => previousData, // Keep old data while loading
});
return (
<div>
{isLoading ? (
<div>Loading...</div>
) : (
<div>
{data?.products.map(product => (
<div key={product.id}>{product.name}</div>
))}
</div>
)}
<div>
<button
onClick={() => setPage(p => p - 1)}
disabled={page === 1}
>
Previous
</button>
<span>Page {page}</span>
<button
onClick={() => setPage(p => p + 1)}
disabled={isPlaceholderData || !data?.hasMore}
>
Next
</button>
</div>
</div>
);
};
Infinite Scroll with React Query
import { useInfiniteQuery } from '@tanstack/react-query';
interface PageParam {
pageParam: number;
}
const PostFeed: React.FC = () => {
const {
data,
fetchNextPage,
hasNextPage,
isFetchingNextPage,
isLoading,
} = useInfiniteQuery({
queryKey: ['posts'],
queryFn: ({ pageParam = 1 }) => fetchPosts(pageParam),
getNextPageParam: (lastPage, allPages) => {
return lastPage.hasMore ? allPages.length + 1 : undefined;
},
initialPageParam: 1,
});
const observerTarget = useRef<HTMLDivElement>(null);
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasNextPage && !isFetchingNextPage) {
fetchNextPage();
}
},
{ threshold: 1.0 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => observer.disconnect();
}, [fetchNextPage, hasNextPage, isFetchingNextPage]);
if (isLoading) return <div>Loading...</div>;
return (
<div>
{data?.pages.map((page, i) => (
<div key={i}>
{page.posts.map(post => (
<article key={post.id}>
<h2>{post.title}</h2>
<p>{post.content}</p>
</article>
))}
</div>
))}
{isFetchingNextPage && <div>Loading more...</div>}
{!hasNextPage && <div>No more posts!</div>}
<div ref={observerTarget} style={{ height: '20px' }} />
</div>
);
};
๐ก React Query Superpowers
- Automatic caching: No manual cache management needed
- Background refetching: Keep data fresh automatically
- Window focus refetch: Refetch when user returns to tab
- Request deduplication: Multiple components requesting same data? One request.
- Retry logic: Automatic retries with exponential backoff
- Prefetching: Load data before user needs it
- DevTools: Beautiful debugging interface
- TypeScript first: Excellent type inference
โ When to Use React Query
- Any app that fetches data from APIs
- Apps with frequent data updates
- Apps with complex caching requirements
- When you need pagination or infinite scroll
- When you want optimistic updates
- When you need background data synchronization
Bottom line: If you're fetching data in React, React Query will make your life easier!
โก Optimistic Updates
Why make users wait to see their changes? Optimistic updates instantly show changes in the UI before the server responds, creating a lightning-fast user experience!
๐ Definition
Optimistic Update: A UX pattern where the UI is immediately updated to reflect a user's action, assuming the server operation will succeed. If it fails, the UI rolls back to the previous state. This creates the illusion of instant responses.
How Optimistic Updates Work
Pattern 1: Manual Optimistic Update
const LikeButton: React.FC<{ postId: string; initialLikes: number }> = ({
postId,
initialLikes
}) => {
const [likes, setLikes] = useState(initialLikes);
const [isLiked, setIsLiked] = useState(false);
const [isUpdating, setIsUpdating] = useState(false);
const handleLike = async () => {
// Save previous state for rollback
const previousLikes = likes;
const previousIsLiked = isLiked;
// Optimistic update - instant UI change!
setLikes(prev => isLiked ? prev - 1 : prev + 1);
setIsLiked(prev => !prev);
setIsUpdating(true);
try {
// Send request to server
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
body: JSON.stringify({ liked: !isLiked })
});
if (!response.ok) {
throw new Error('Failed to update like');
}
// Success! Keep the optimistic update
const data = await response.json();
setLikes(data.likes); // Sync with server's actual count
} catch (error) {
// Failure! Rollback to previous state
setLikes(previousLikes);
setIsLiked(previousIsLiked);
console.error('Failed to like post:', error);
} finally {
setIsUpdating(false);
}
};
return (
<button
onClick={handleLike}
disabled={isUpdating}
style={{
backgroundColor: isLiked ? '#667eea' : '#ccc',
color: 'white',
padding: '0.5rem 1rem',
border: 'none',
borderRadius: '4px',
cursor: isUpdating ? 'not-allowed' : 'pointer'
}}
>
๐ {likes} {isLiked ? 'Liked' : 'Like'}
</button>
);
};
Pattern 2: Optimistic Updates with React Query
React Query makes optimistic updates much easier:
โ React Query Optimistic Update
import { useMutation, useQueryClient } from '@tanstack/react-query';
interface Post {
id: string;
likes: number;
isLiked: boolean;
}
const LikeButton: React.FC<{ post: Post }> = ({ post }) => {
const queryClient = useQueryClient();
const likeMutation = useMutation({
mutationFn: async (postId: string) => {
const response = await fetch(`/api/posts/${postId}/like`, {
method: 'POST',
});
return response.json();
},
// Called before mutation
onMutate: async (postId) => {
// Cancel outgoing queries
await queryClient.cancelQueries({ queryKey: ['post', postId] });
// Snapshot previous value for rollback
const previousPost = queryClient.getQueryData<Post>(['post', postId]);
// Optimistically update cache
queryClient.setQueryData<Post>(['post', postId], (old) => {
if (!old) return old;
return {
...old,
likes: old.isLiked ? old.likes - 1 : old.likes + 1,
isLiked: !old.isLiked
};
});
// Return context with previous value for rollback
return { previousPost };
},
// Called on error
onError: (err, postId, context) => {
// Rollback to previous value
if (context?.previousPost) {
queryClient.setQueryData(['post', postId], context.previousPost);
}
},
// Always refetch after error or success
onSettled: (data, error, postId) => {
queryClient.invalidateQueries({ queryKey: ['post', postId] });
},
});
return (
<button
onClick={() => likeMutation.mutate(post.id)}
disabled={likeMutation.isPending}
style={{
backgroundColor: post.isLiked ? '#667eea' : '#ccc',
color: 'white',
padding: '0.5rem 1rem',
border: 'none',
borderRadius: '4px'
}}
>
๐ {post.likes} {post.isLiked ? 'Liked' : 'Like'}
</button>
);
};
Pattern 3: Optimistic List Updates
Adding/removing items from lists optimistically:
interface Todo {
id: string;
text: string;
completed: boolean;
}
const TodoList: React.FC = () => {
const queryClient = useQueryClient();
// Add todo optimistically
const addMutation = useMutation({
mutationFn: async (text: string) => {
const response = await fetch('/api/todos', {
method: 'POST',
body: JSON.stringify({ text })
});
return response.json();
},
onMutate: async (text) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Create temporary todo with fake ID
const optimisticTodo: Todo = {
id: `temp-${Date.now()}`,
text,
completed: false
};
// Add to cache immediately
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
[...old, optimisticTodo]
);
return { previousTodos };
},
onError: (err, text, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
onSuccess: (newTodo) => {
// Replace temporary todo with real one from server
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.map(todo =>
todo.id.startsWith('temp-') ? newTodo : todo
)
);
},
});
// Delete todo optimistically
const deleteMutation = useMutation({
mutationFn: async (id: string) => {
await fetch(`/api/todos/${id}`, { method: 'DELETE' });
},
onMutate: async (id) => {
await queryClient.cancelQueries({ queryKey: ['todos'] });
const previousTodos = queryClient.getQueryData<Todo[]>(['todos']);
// Remove from cache immediately
queryClient.setQueryData<Todo[]>(['todos'], (old = []) =>
old.filter(todo => todo.id !== id)
);
return { previousTodos };
},
onError: (err, id, context) => {
if (context?.previousTodos) {
queryClient.setQueryData(['todos'], context.previousTodos);
}
},
});
const { data: todos = [] } = useQuery({
queryKey: ['todos'],
queryFn: fetchTodos,
});
return (
<div>
{todos.map(todo => (
<div key={todo.id}>
<span>{todo.text}</span>
<button onClick={() => deleteMutation.mutate(todo.id)}>
Delete
</button>
</div>
))}
<button onClick={() => addMutation.mutate('New todo')}>
Add Todo
</button>
</div>
);
};
๐ก Optimistic Update Best Practices
- Always provide rollback: If the mutation fails, restore previous state
- Show pending states: Disable buttons or show spinners during mutations
- Handle errors gracefully: Show clear error messages when rollback occurs
- Cancel in-flight queries: Prevent race conditions with query cancellation
- Sync with server: Always refetch after mutations to ensure data consistency
- Use temporary IDs: For new items, use temp IDs until server responds
โ ๏ธ When NOT to Use Optimistic Updates
- Critical operations: Financial transactions, medical records
- Complex validations: When server might reject for non-obvious reasons
- Multi-user conflicts: When concurrent edits are likely
- Slow rollback UX: When reverting would be jarring or confusing
Rule of thumb: Use optimistic updates for simple, likely-to-succeed operations where instant feedback improves UX!
๐๏ธ Hands-on Practice
Time to apply everything you've learned! These exercises will solidify your advanced data fetching skills.
๐๏ธ Exercise 1: Debounced Search with Cache
Build a search component that debounces user input and caches results.
Requirements:
- Search input that debounces by 500ms
- Fetch search results from an API
- Cache results for each search query
- Show loading state while fetching
- Handle race conditions with AbortController
- Display search results in a list
- Type everything properly with TypeScript
๐ก Hint
Combine your useDebounce hook with a caching strategy. Use the debounced value as the cache key. Don't forget to abort old requests when the query changes!
โ Solution
interface SearchResult {
id: number;
title: string;
description: string;
}
// Simple cache
const searchCache = new Map<string, SearchResult[]>();
const SearchComponent: React.FC = () => {
const [query, setQuery] = useState('');
const debouncedQuery = useDebounce(query, 500);
const [results, setResults] = useState<SearchResult[]>([]);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
if (!debouncedQuery) {
setResults([]);
return;
}
// Check cache first
const cached = searchCache.get(debouncedQuery);
if (cached) {
setResults(cached);
return;
}
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/search?q=${debouncedQuery}`, {
signal: controller.signal
})
.then(res => res.json())
.then(data => {
searchCache.set(debouncedQuery, data.results);
setResults(data.results);
})
.catch(err => {
if (err.name !== 'AbortError') {
setError('Search failed');
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [debouncedQuery]);
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
style={{ width: '100%', padding: '0.75rem', fontSize: '1rem' }}
/>
{loading && <div>Searching...</div>}
{error && <div style={{ color: 'red' }}>{error}</div>}
<div>
{results.map(result => (
<div key={result.id} style={{ padding: '1rem', borderBottom: '1px solid #ddd' }}>
<h3>{result.title}</h3>
<p>{result.description}</p>
</div>
))}
</div>
</div>
);
};
๐๏ธ Exercise 2: Paginated Product Catalog
Build a product catalog with pagination and URL state management.
Requirements:
- Display products in a grid layout
- Implement pagination (previous/next buttons)
- Show page numbers with the current page highlighted
- Sync page number with URL query params
- Disable buttons appropriately (first/last page)
- Show loading state while fetching
- Handle errors gracefully
- Cancel old requests when page changes
๐ก Hint
Use React Router's useSearchParams to sync the page with the URL. Use AbortController in your useEffect cleanup. Keep track of total pages from the API response.
โ Solution
import { useSearchParams } from 'react-router-dom';
interface Product {
id: number;
name: string;
price: number;
image: string;
}
interface PaginationData {
products: Product[];
currentPage: number;
totalPages: number;
totalItems: number;
}
const ProductCatalog: React.FC = () => {
const [searchParams, setSearchParams] = useSearchParams();
const currentPage = Number(searchParams.get('page')) || 1;
const [data, setData] = useState<PaginationData | null>(null);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
useEffect(() => {
const controller = new AbortController();
setLoading(true);
setError(null);
fetch(`/api/products?page=${currentPage}&limit=12`, {
signal: controller.signal
})
.then(res => res.json())
.then(setData)
.catch(err => {
if (err.name !== 'AbortError') {
setError('Failed to load products');
}
})
.finally(() => setLoading(false));
return () => controller.abort();
}, [currentPage]);
const goToPage = (page: number) => {
setSearchParams({ page: page.toString() });
window.scrollTo({ top: 0, behavior: 'smooth' });
};
if (error) return <div style={{ color: 'red' }}>{error}</div>;
return (
<div style={{ maxWidth: '1200px', margin: '0 auto', padding: '2rem' }}>
<h1>Product Catalog</h1>
{loading && <div>Loading products...</div>}
{data && (
<>
<div style={{
display: 'grid',
gridTemplateColumns: 'repeat(auto-fill, minmax(250px, 1fr))',
gap: '1.5rem',
marginBottom: '2rem'
}}>
{data.products.map(product => (
<div key={product.id} style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1rem',
textAlign: 'center'
}}>
<img
src={product.image}
alt={product.name}
style={{ width: '100%', height: '200px', objectFit: 'cover' }}
/>
<h3>{product.name}</h3>
<p style={{ fontSize: '1.25rem', fontWeight: 'bold', color: '#667eea' }}>
${product.price}
</p>
</div>
))}
</div>
<div style={{
display: 'flex',
justifyContent: 'center',
alignItems: 'center',
gap: '1rem',
marginTop: '2rem'
}}>
<button
onClick={() => goToPage(currentPage - 1)}
disabled={currentPage === 1 || loading}
style={{
padding: '0.5rem 1rem',
backgroundColor: currentPage === 1 ? '#ccc' : '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: currentPage === 1 ? 'not-allowed' : 'pointer'
}}
>
Previous
</button>
<div style={{ display: 'flex', gap: '0.5rem' }}>
{Array.from({ length: data.totalPages }, (_, i) => i + 1).map(page => (
<button
key={page}
onClick={() => goToPage(page)}
disabled={loading}
style={{
padding: '0.5rem 1rem',
backgroundColor: page === currentPage ? '#667eea' : 'white',
color: page === currentPage ? 'white' : '#667eea',
border: '1px solid #667eea',
borderRadius: '4px',
cursor: 'pointer',
fontWeight: page === currentPage ? 'bold' : 'normal'
}}
>
{page}
</button>
))}
</div>
<button
onClick={() => goToPage(currentPage + 1)}
disabled={currentPage === data.totalPages || loading}
style={{
padding: '0.5rem 1rem',
backgroundColor: currentPage === data.totalPages ? '#ccc' : '#667eea',
color: 'white',
border: 'none',
borderRadius: '4px',
cursor: currentPage === data.totalPages ? 'not-allowed' : 'pointer'
}}
>
Next
</button>
</div>
<p style={{ textAlign: 'center', marginTop: '1rem', color: '#666' }}>
Page {currentPage} of {data.totalPages} ({data.totalItems} total products)
</p>
</>
)}
</div>
);
};
๐๏ธ Exercise 3: Infinite Scroll Feed
Create an infinite scroll social media feed with loading states.
Requirements:
- Load initial 20 posts on mount
- Automatically load more when user scrolls near bottom
- Use Intersection Observer for scroll detection
- Show loading indicator while fetching
- Display "No more posts" when all data is loaded
- Prevent duplicate requests
- Type all data properly
๐ก Hint
Use the useInfiniteScroll hook pattern we built earlier. Track hasMore state and check for empty responses. Place a sentinel div at the bottom to observe.
โ Solution
interface Post {
id: string;
author: string;
content: string;
timestamp: string;
}
const SocialFeed: React.FC = () => {
const [posts, setPosts] = useState<Post[]>([]);
const [page, setPage] = useState(1);
const [loading, setLoading] = useState(false);
const [hasMore, setHasMore] = useState(true);
const observerTarget = useRef<HTMLDivElement>(null);
const loadMorePosts = useCallback(async () => {
if (loading || !hasMore) return;
setLoading(true);
try {
const response = await fetch(`/api/posts?page=${page}&limit=20`);
const data = await response.json();
if (data.posts.length === 0) {
setHasMore(false);
} else {
setPosts(prev => [...prev, ...data.posts]);
setPage(prev => prev + 1);
}
} catch (error) {
console.error('Failed to load posts:', error);
} finally {
setLoading(false);
}
}, [page, loading, hasMore]);
// Load initial posts
useEffect(() => {
loadMorePosts();
}, []);
// Set up Intersection Observer
useEffect(() => {
const observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && hasMore && !loading) {
loadMorePosts();
}
},
{ threshold: 1.0 }
);
if (observerTarget.current) {
observer.observe(observerTarget.current);
}
return () => {
if (observerTarget.current) {
observer.unobserve(observerTarget.current);
}
};
}, [loadMorePosts, hasMore, loading]);
return (
<div style={{ maxWidth: '600px', margin: '0 auto', padding: '2rem' }}>
<h1>Social Feed</h1>
{posts.map(post => (
<article
key={post.id}
style={{
border: '1px solid #ddd',
borderRadius: '8px',
padding: '1.5rem',
marginBottom: '1rem'
}}
>
<div style={{ display: 'flex', alignItems: 'center', marginBottom: '0.5rem' }}>
<strong>{post.author}</strong>
<span style={{ marginLeft: 'auto', color: '#666', fontSize: '0.875rem' }}>
{new Date(post.timestamp).toLocaleDateString()}
</span>
</div>
<p>{post.content}</p>
</article>
))}
{loading && (
<div style={{ textAlign: 'center', padding: '2rem' }}>
<p>Loading more posts...</p>
</div>
)}
{!hasMore && posts.length > 0 && (
<div style={{ textAlign: 'center', padding: '2rem', color: '#666' }}>
<p>๐ You've reached the end!</p>
</div>
)}
<div ref={observerTarget} style={{ height: '20px' }} />
</div>
);
};
๐๏ธ Challenge: React Query Data Dashboard
Build a data dashboard using React Query with multiple queries, mutations, and optimistic updates.
Requirements:
- Fetch and display user statistics (useQuery)
- Fetch and display recent activity (useQuery)
- Add action button with optimistic update (useMutation)
- Implement background refetching
- Show loading and error states
- Add manual refresh button
- Display last updated timestamp
- Use React Query DevTools
๐ก Hint
Set up QueryClientProvider at the app root. Use separate useQuery calls for different data. Use useMutation with onMutate for optimistic updates. Remember to invalidate queries after mutations!
๐ Best Practices
โ Do's
- Always handle race conditions - Use AbortController or cleanup flags in every data fetching effect to prevent stale data from displaying.
- Debounce user input - For search and autocomplete, wait 300-500ms after typing stops before firing requests. Saves bandwidth and improves UX.
- Show loading states - Users need feedback. Show spinners, skeleton screens, or progress indicators during data fetching.
- Cache intelligently - Cache frequently accessed, slowly changing data. Use appropriate TTLs based on how often data changes.
- Invalidate cache on mutations - After POST/PUT/DELETE, clear related cache entries to ensure data consistency.
- Use React Query for complex needs - Don't reinvent the wheel. React Query handles caching, refetching, and state management better than custom solutions.
- Implement proper error handling - Show meaningful error messages. Provide retry buttons. Don't just console.log errors!
- Type everything - Use TypeScript interfaces for API responses, cache entries, and hook return values.
โ Don'ts
- Don't ignore cleanup - Always return cleanup functions from useEffect. Cancel requests, remove listeners, clear timers.
- Don't fetch in render - Never call fetch directly in component body. Always use useEffect or event handlers.
- Don't cache everything - User-specific data, real-time data, and large datasets often shouldn't be cached. Be selective.
- Don't use infinite scroll everywhere - It's not appropriate for all UIs. Pagination works better when users need to find specific items.
- Don't forget about mobile - Test debouncing delays and pagination on mobile devices. Network conditions vary widely.
- Don't make assumptions about network speed - Always handle slow networks and timeouts gracefully.
- Don't ignore failed optimistic updates - Always rollback and show errors when optimistic updates fail.
๐ก Pro Tips
- Use request deduplication - If 5 components request the same data simultaneously, make only one request. React Query does this automatically!
- Prefetch data - On hover or route navigation intent, prefetch data before it's needed for instant perceived performance.
- Implement background revalidation - Show cached data immediately while fetching fresh data in the background.
- Use polling for real-time updates - For data that updates frequently, implement smart polling with exponential backoff.
- Add request timeouts - Combine AbortController with setTimeout for automatic request timeouts (e.g., 10 seconds).
- Test with network throttling - Chrome DevTools lets you simulate slow 3G. Test your loading states and race conditions!
- Monitor cache size - In memory-constrained environments, implement LRU (Least Recently Used) cache eviction.
- Use skeleton screens - Instead of spinners, show content-shaped placeholders for better perceived performance.
โ Data Fetching Checklist
- โ AbortController used for request cancellation
- โ Debouncing implemented for search inputs
- โ Loading states shown to users
- โ Error handling with retry options
- โ Cache strategy defined (TTL, invalidation)
- โ Race conditions prevented
- โ TypeScript types for all API responses
- โ Pagination or infinite scroll implemented properly
- โ Optimistic updates have rollback logic
- โ Mobile experience tested
Performance Optimization Tips
Reducing Network Requests:
- Batch requests: Combine multiple small requests into one larger request
- GraphQL: For complex data needs, consider GraphQL to fetch exactly what you need
- HTTP/2: Multiple concurrent requests are cheaper with HTTP/2
- Compression: Ensure gzip/brotli compression is enabled on your API
Improving Perceived Performance:
- Show cached data first: Display stale data while fetching fresh
- Skeleton screens: Better than spinners for perceived speed
- Optimistic updates: Make UI changes feel instant
- Progressive loading: Load critical content first, lazy load the rest
๐ Summary
๐ Key Takeaways
- Race conditions are silent bugs - Always use AbortController to cancel old requests when new ones start.
- Debouncing saves bandwidth - Wait for users to stop typing before making API calls. 300-500ms is typical.
- Request cancellation is essential - Cancel requests when components unmount or dependencies change.
- Pagination comes in flavors - Offset-based, cursor-based, and page numbers each have their use cases.
- Infinite scroll needs careful implementation - Use Intersection Observer, handle "no more data," and provide accessibility alternatives.
- Client-side caching improves performance - Cache with TTL, invalidate on mutations, and use localStorage for persistence.
- React Query is a game changer - It handles caching, refetching, pagination, and more with minimal code.
- Optimistic updates feel instant - Update UI immediately, then sync with server. Always provide rollback for failures.
- TypeScript makes data fetching safer - Type your API responses, cache entries, and hook return values.
- Test with slow networks - Use Chrome DevTools throttling to test loading states and race conditions.
๐ Additional Resources
- TanStack Query (React Query) Documentation
- MDN: AbortController
- MDN: Intersection Observer API
- Web.dev: Fetch API Best Practices
- Kent C. Dodds: Better Loading States
- Patterns.dev: Client-Side Caching
๐ What's Next?
Congratulations! You've mastered advanced data fetching patterns that are used in production applications at major companies. You can now build fast, efficient, user-friendly applications that handle data like a pro.
In the next lesson, we'll work on the Module 4 Project: Weather Dashboard, where you'll apply everything you've learned:
- Data fetching with proper error handling
- Search with debouncing
- Caching weather data
- Loading and error states
- Real-world API integration
๐ฏ Quick Quiz
Question 1: What is a race condition in data fetching?
Question 2: What's the purpose of debouncing in search inputs?
Question 3: What's the main advantage of using React Query over custom hooks?
Question 4: When should you use infinite scroll instead of pagination?
Question 5: What's an optimistic update?
๐ Congratulations!
You've completed Lesson 4.4: Advanced Data Fetching! You now have professional-grade data fetching skills that will serve you throughout your React career.
You've learned techniques used by major tech companies to build fast, efficient, user-friendly applications. Keep practicing! ๐