Chapter : Advanced Data Fetching Patterns
Advanced Data Fetching Patterns
Advanced patterns handle complex scenarios like parallel fetching, infinite scrolling, and real-time updates. These techniques are essential for production applications.
Overview
Master these advanced patterns:
- Parallel and dependent fetching
- Infinite scroll and pagination
- Real-time data with WebSockets
- Optimistic updates
- Data prefetching
Parallel Data Fetching
Fetch multiple resources simultaneously:
import { useState, useEffect } from 'react'
interface User {
id: number
name: string
}
interface Post {
id: number
title: string
}
function UserDashboard({ userId }: { userId: number }) {
const [data, setData] = useState<{
user: User | null
posts: Post[] | null
comments: any[] | null
}>({
user: null,
posts: null,
comments: null
})
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
const controller = new AbortController()
async function fetchAll() {
try {
setLoading(true)
// Fetch all resources in parallel
const [userRes, postsRes, commentsRes] = await Promise.all([
fetch(`/api/users/${userId}`, { signal: controller.signal }),
fetch(`/api/users/${userId}/posts`, { signal: controller.signal }),
fetch(`/api/users/${userId}/comments`, { signal: controller.signal })
])
// Parse all responses
const [user, posts, comments] = await Promise.all([
userRes.json(),
postsRes.json(),
commentsRes.json()
])
setData({ user, posts, comments })
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err)
}
} finally {
setLoading(false)
}
}
fetchAll()
return () => controller.abort()
}, [userId])
if (loading) return <div>Loading dashboard...</div>
if (error) return <div>Error: {error.message}</div>
return (
<div>
<h1>{data.user?.name}'s Dashboard</h1>
<section>
<h2>Posts ({data.posts?.length})</h2>
{/* Render posts */}
</section>
<section>
<h2>Comments ({data.comments?.length})</h2>
{/* Render comments */}
</section>
</div>
)
}
Dependent Data Fetching
Fetch data that depends on previous results:
import { useState, useEffect } from 'react'
function PostWithComments({ postId }: { postId: number }) {
const [post, setPost] = useState(null)
const [author, setAuthor] = useState(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
const controller = new AbortController()
async function fetchDependent() {
try {
// First fetch the post
const postRes = await fetch(`/api/posts/${postId}`, {
signal: controller.signal
})
const postData = await postRes.json()
setPost(postData)
// Then fetch the author based on post data
const authorRes = await fetch(`/api/users/${postData.authorId}`, {
signal: controller.signal
})
const authorData = await authorRes.json()
setAuthor(authorData)
} catch (err) {
console.error('Fetch failed:', err)
} finally {
setLoading(false)
}
}
fetchDependent()
return () => controller.abort()
}, [postId])
if (loading) return <div>Loading...</div>
return (
<article>
<h2>{post?.title}</h2>
<p>By {author?.name}</p>
<div>{post?.content}</div>
</article>
)
}
Infinite Scroll
Implement infinite scrolling with intersection observer:
import { useState, useEffect, useRef, useCallback } from 'react'
interface UseInfiniteScrollReturn<T> {
items: T[]
loading: boolean
error: Error | null
hasMore: boolean
observerTarget: (node: HTMLDivElement | null) => void
}
function useInfiniteScroll<T>(
baseUrl: string,
pageSize: number = 20
): UseInfiniteScrollReturn<T> {
const [items, setItems] = useState<T[]>([])
const [page, setPage] = useState(1)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [hasMore, setHasMore] = useState(true)
const observer = useRef<IntersectionObserver>()
const observerTarget = useCallback((node: HTMLDivElement | null) => {
if (loading) return
if (observer.current) observer.current.disconnect()
observer.current = new IntersectionObserver(entries => {
if (entries[0].isIntersecting && hasMore) {
setPage(prevPage => prevPage + 1)
}
})
if (node) observer.current.observe(node)
}, [loading, hasMore])
useEffect(() => {
const controller = new AbortController()
async function fetchPage() {
try {
setLoading(true)
const response = await fetch(
`${baseUrl}?page=${page}&limit=${pageSize}`,
{ signal: controller.signal }
)
const data = await response.json()
setItems(prev => [...prev, ...data.items])
setHasMore(data.hasMore)
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err)
}
} finally {
setLoading(false)
}
}
fetchPage()
return () => controller.abort()
}, [baseUrl, page, pageSize])
return { items, loading, error, hasMore, observerTarget }
}
// Usage
function InfiniteProductList() {
const { items, loading, hasMore, observerTarget } =
useInfiniteScroll<Product>('/api/products', 20)
return (
<div>
{items.map((product, index) => (
<div key={`${product.id}-${index}`}>
<h3>{product.name}</h3>
<p>${product.price}</p>
</div>
))}
{hasMore && (
<div ref={observerTarget} style={{ height: '20px' }}>
{loading && <div>Loading more...</div>}
</div>
)}
{!hasMore && <div>No more items</div>}
</div>
)
}
WebSocket Real-Time Updates
Implement real-time data synchronization:
import { useState, useEffect, useRef } from 'react'
interface UseWebSocketOptions {
onMessage?: (data: any) => void
onError?: (error: Event) => void
reconnect?: boolean
reconnectDelay?: number
}
function useWebSocket<T>(
url: string,
options: UseWebSocketOptions = {}
) {
const [data, setData] = useState<T | null>(null)
const [isConnected, setIsConnected] = useState(false)
const ws = useRef<WebSocket | null>(null)
const reconnectTimeout = useRef<NodeJS.Timeout>()
useEffect(() => {
const connect = () => {
ws.current = new WebSocket(url)
ws.current.onopen = () => {
setIsConnected(true)
console.log('WebSocket connected')
}
ws.current.onmessage = (event) => {
const parsed = JSON.parse(event.data)
setData(parsed)
options.onMessage?.(parsed)
}
ws.current.onerror = (error) => {
console.error('WebSocket error:', error)
options.onError?.(error)
}
ws.current.onclose = () => {
setIsConnected(false)
console.log('WebSocket disconnected')
// Reconnect if enabled
if (options.reconnect !== false) {
reconnectTimeout.current = setTimeout(() => {
console.log('Reconnecting...')
connect()
}, options.reconnectDelay || 3000)
}
}
}
connect()
return () => {
if (reconnectTimeout.current) {
clearTimeout(reconnectTimeout.current)
}
ws.current?.close()
}
}, [url, options])
const send = useCallback((message: any) => {
if (ws.current?.readyState === WebSocket.OPEN) {
ws.current.send(JSON.stringify(message))
}
}, [])
return { data, isConnected, send }
}
// Usage
interface StockPrice {
symbol: string
price: number
timestamp: number
}
function LiveStockPrice({ symbol }: { symbol: string }) {
const { data, isConnected } = useWebSocket<StockPrice>(
`wss://api.example.com/stocks/${symbol}`,
{ reconnect: true }
)
return (
<div>
<div>Status: {isConnected ? '🟢 Connected' : '🔴 Disconnected'}</div>
{data && (
<div>
<h2>{data.symbol}</h2>
<p>${data.price.toFixed(2)}</p>
<small>{new Date(data.timestamp).toLocaleTimeString()}</small>
</div>
)}
</div>
)
}
Optimistic Updates
Update UI immediately before server confirmation:
import { useState } from 'react'
interface Todo {
id: number
text: string
completed: boolean
}
function useTodos() {
const [todos, setTodos] = useState<Todo[]>([])
const toggleTodo = async (id: number) => {
// Optimistic update
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
)
try {
const response = await fetch(`/api/todos/${id}/toggle`, {
method: 'POST'
})
if (!response.ok) {
throw new Error('Failed to toggle todo')
}
// Server confirmed - no need to update again
} catch (err) {
// Revert on error
setTodos(prev =>
prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
)
)
console.error('Failed to toggle todo:', err)
}
}
const addTodo = async (text: string) => {
// Create optimistic todo with temporary ID
const optimisticTodo: Todo = {
id: Date.now(),
text,
completed: false
}
setTodos(prev => [...prev, optimisticTodo])
try {
const response = await fetch('/api/todos', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ text })
})
const serverTodo = await response.json()
// Replace optimistic todo with server response
setTodos(prev =>
prev.map(todo =>
todo.id === optimisticTodo.id ? serverTodo : todo
)
)
} catch (err) {
// Remove optimistic todo on error
setTodos(prev => prev.filter(todo => todo.id !== optimisticTodo.id))
console.error('Failed to add todo:', err)
}
}
return { todos, toggleTodo, addTodo }
}
Data Prefetching
Prefetch data for better perceived performance:
import { useState, useEffect } from 'react'
// Global prefetch cache
const prefetchCache = new Map<string, Promise<any>>()
function prefetch(url: string): Promise<any> {
if (!prefetchCache.has(url)) {
prefetchCache.set(
url,
fetch(url).then(r => r.json())
)
}
return prefetchCache.get(url)!
}
function usePrefetchedData<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
useEffect(() => {
prefetch(url).then(data => {
setData(data)
setLoading(false)
})
}, [url])
return { data, loading }
}
// Prefetch on hover
function NavigationLink({ href, children }: { href: string; children: React.ReactNode }) {
const handleMouseEnter = () => {
prefetch(href)
}
return (
<a href={href} onMouseEnter={handleMouseEnter}>
{children}
</a>
)
}
Debouncing Search
Optimize search with debouncing:
import { useState, useEffect, useRef } from 'react'
function useDebounce<T>(value: T, delay: number): T {
const [debouncedValue, setDebouncedValue] = useState(value)
useEffect(() => {
const handler = setTimeout(() => {
setDebouncedValue(value)
}, delay)
return () => clearTimeout(handler)
}, [value, delay])
return debouncedValue
}
function SearchResults() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [loading, setLoading] = useState(false)
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (!debouncedQuery) {
setResults([])
return
}
const controller = new AbortController()
async function search() {
setLoading(true)
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`,
{ signal: controller.signal }
)
const data = await response.json()
setResults(data.results)
} catch (err) {
console.error('Search failed:', err)
} finally {
setLoading(false)
}
}
search()
return () => controller.abort()
}, [debouncedQuery])
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{loading && <div>Searching...</div>}
<ul>
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
)
}
Best Practices
- Use Promise.all for independent parallel fetches
- Implement intersection observer for infinite scroll
- Add optimistic updates for better UX
- Prefetch data on user intent (hover, etc.)
- Debounce search inputs to reduce API calls
- Handle WebSocket reconnection gracefully
- Validate data from real-time sources
- Test edge cases thoroughly
Summary
Advanced data fetching patterns enable sophisticated user experiences in production applications. Master these techniques to build responsive, real-time applications.
Next Steps:
- Explore Modern Data Fetching Libraries for built-in solutions
- Learn about Performance Optimization techniques
Key Takeaways:
- Parallel fetching improves load times
- Infinite scroll enhances mobile experiences
- WebSockets enable real-time features
- Optimistic updates improve perceived performance
- Prefetching reduces wait times
- Debouncing optimizes search and input handling