Chapter : Performance Optimization
Performance Optimization
Optimize data fetching for production applications with caching, deduplication, and intelligent loading strategies.
Overview
Performance techniques covered:
- Request deduplication
- Caching strategies
- Debouncing and throttling
- Lazy loading data
- Bundle optimization
Request Deduplication
Prevent duplicate simultaneous requests:
import { useEffect, useState, useRef } from 'react'
// Global request cache
const pendingRequests = new Map<string, Promise<any>>()
function useDedupedFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(true)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
async function fetchData() {
try {
setLoading(true)
// Check if request is already pending
if (!pendingRequests.has(url)) {
const promise = fetch(url)
.then(r => r.json())
.finally(() => {
// Clean up after request completes
pendingRequests.delete(url)
})
pendingRequests.set(url, promise)
}
const result = await pendingRequests.get(url)!
setData(result)
} catch (err) {
setError(err instanceof Error ? err : new Error('Fetch failed'))
} finally {
setLoading(false)
}
}
fetchData()
}, [url])
return { data, loading, error }
}
// Multiple components can use this without duplicate requests
function Component1() {
const { data } = useDedupedFetch('/api/shared-data')
return <div>{data?.value}</div>
}
function Component2() {
const { data } = useDedupedFetch('/api/shared-data') // Same request, no duplication
return <div>{data?.value}</div>
}
Caching Strategies
Implement smart caching:
interface CacheEntry<T> {
data: T
timestamp: number
expiresAt: number
}
class DataCache {
private cache = new Map<string, CacheEntry<any>>()
private defaultTTL = 5 * 60 * 1000 // 5 minutes
set<T>(key: string, data: T, ttl?: number): void {
const now = Date.now()
this.cache.set(key, {
data,
timestamp: now,
expiresAt: now + (ttl || this.defaultTTL)
})
}
get<T>(key: string): T | null {
const entry = this.cache.get(key)
if (!entry) return null
// Check if expired
if (Date.now() > entry.expiresAt) {
this.cache.delete(key)
return null
}
return entry.data
}
invalidate(pattern?: string): void {
if (!pattern) {
this.cache.clear()
return
}
// Invalidate matching keys
const regex = new RegExp(pattern)
for (const key of this.cache.keys()) {
if (regex.test(key)) {
this.cache.delete(key)
}
}
}
has(key: string): boolean {
return this.get(key) !== null
}
}
const cache = new DataCache()
function useCachedFetch<T>(url: string, ttl?: number) {
const [data, setData] = useState<T | null>(() => cache.get<T>(url))
const [loading, setLoading] = useState(!cache.has(url))
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
if (cache.has(url)) {
setData(cache.get<T>(url))
setLoading(false)
return
}
async function fetchData() {
try {
setLoading(true)
const response = await fetch(url)
if (!response.ok) throw new Error('Fetch failed')
const json = await response.json()
cache.set(url, json, ttl)
setData(json)
} catch (err) {
setError(err instanceof Error ? err : new Error('Fetch failed'))
} finally {
setLoading(false)
}
}
fetchData()
}, [url, ttl])
const invalidate = () => {
cache.invalidate(url)
}
return { data, loading, error, invalidate }
}
Stale-While-Revalidate
Show cached data immediately while fetching fresh data:
import { useState, useEffect } from 'react'
function useSWR<T>(url: string) {
const [data, setData] = useState<T | null>(() => cache.get<T>(url))
const [isValidating, setIsValidating] = useState(false)
const [error, setError] = useState<Error | null>(null)
useEffect(() => {
async function revalidate() {
try {
setIsValidating(true)
const response = await fetch(url)
if (!response.ok) throw new Error('Fetch failed')
const json = await response.json()
cache.set(url, json)
setData(json)
} catch (err) {
setError(err instanceof Error ? err : new Error('Fetch failed'))
} finally {
setIsValidating(false)
}
}
revalidate()
}, [url])
return {
data,
isValidating,
error,
isStale: data !== null && isValidating
}
}
// Usage
function DataDisplay() {
const { data, isValidating, isStale } = useSWR('/api/data')
return (
<div>
{isStale && <div className="stale-indicator">Refreshing...</div>}
{data && <div>{data.value}</div>}
{!data && isValidating && <div>Loading...</div>}
</div>
)
}
Debouncing
Optimize rapid successive calls:
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
}
// Search with debouncing
function SearchComponent() {
const [query, setQuery] = useState('')
const [results, setResults] = useState([])
const [searching, setSearching] = useState(false)
const debouncedQuery = useDebounce(query, 300)
useEffect(() => {
if (!debouncedQuery) {
setResults([])
return
}
const controller = new AbortController()
async function search() {
setSearching(true)
try {
const response = await fetch(
`/api/search?q=${encodeURIComponent(debouncedQuery)}`,
{ signal: controller.signal }
)
const data = await response.json()
setResults(data.results)
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
console.error('Search failed:', err)
}
} finally {
setSearching(false)
}
}
search()
return () => controller.abort()
}, [debouncedQuery])
return (
<div>
<input
type="text"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="Search..."
/>
{searching && <div>Searching...</div>}
<ul>
{results.map((result, i) => (
<li key={i}>{result}</li>
))}
</ul>
</div>
)
}
Throttling
Limit request frequency:
import { useRef, useCallback } from 'react'
function useThrottle<T extends (...args: any[]) => any>(
callback: T,
delay: number
): T {
const lastRun = useRef(Date.now())
return useCallback((...args: Parameters<T>) => {
const now = Date.now()
if (now - lastRun.current >= delay) {
callback(...args)
lastRun.current = now
}
}, [callback, delay]) as T
}
// Usage for scroll-triggered fetching
function InfiniteScroll() {
const [items, setItems] = useState([])
const loadMore = useThrottle(async () => {
const response = await fetch('/api/items?page=next')
const data = await response.json()
setItems(prev => [...prev, ...data.items])
}, 1000) // Max once per second
useEffect(() => {
const handleScroll = () => {
if (window.innerHeight + window.scrollY >= document.body.offsetHeight - 500) {
loadMore()
}
}
window.addEventListener('scroll', handleScroll)
return () => window.removeEventListener('scroll', handleScroll)
}, [loadMore])
return (
<div>
{items.map(item => (
<div key={item.id}>{item.name}</div>
))}
</div>
)
}
Lazy Data Loading
Load data only when needed:
import { useState, useEffect } from 'react'
function useLazyFetch<T>(url: string) {
const [data, setData] = useState<T | null>(null)
const [loading, setLoading] = useState(false)
const [error, setError] = useState<Error | null>(null)
const [shouldFetch, setShouldFetch] = useState(false)
useEffect(() => {
if (!shouldFetch) return
const controller = new AbortController()
async function fetchData() {
try {
setLoading(true)
const response = await fetch(url, { signal: controller.signal })
if (!response.ok) throw new Error('Fetch failed')
const json = await response.json()
setData(json)
} catch (err) {
if (err instanceof Error && err.name !== 'AbortError') {
setError(err)
}
} finally {
setLoading(false)
}
}
fetchData()
return () => controller.abort()
}, [url, shouldFetch])
const trigger = () => setShouldFetch(true)
return { data, loading, error, trigger }
}
// Usage - load data on user action
function ExpandableSection() {
const [isOpen, setIsOpen] = useState(false)
const { data, loading, trigger } = useLazyFetch('/api/details')
const handleToggle = () => {
if (!isOpen) {
trigger()
}
setIsOpen(!isOpen)
}
return (
<div>
<button onClick={handleToggle}>
{isOpen ? 'Hide' : 'Show'} Details
</button>
{isOpen && (
<div>
{loading && <div>Loading...</div>}
{data && <div>{JSON.stringify(data)}</div>}
</div>
)}
</div>
)
}
Prefetching
Load data before it’s needed:
import { useEffect } from 'react'
function usePrefetch(url: string, condition: boolean = true) {
useEffect(() => {
if (!condition) return
const link = document.createElement('link')
link.rel = 'prefetch'
link.href = url
document.head.appendChild(link)
return () => {
document.head.removeChild(link)
}
}, [url, condition])
}
// Prefetch on hover
function NavigationLink({ href, label }: { href: string; label: string }) {
const [shouldPrefetch, setShouldPrefetch] = useState(false)
usePrefetch(href, shouldPrefetch)
return (
<a
href={href}
onMouseEnter={() => setShouldPrefetch(true)}
onFocus={() => setShouldPrefetch(true)}
>
{label}
</a>
)
}
Bundle Size Optimization
Lazy load data fetching libraries:
import { lazy, Suspense } from 'react'
// Lazy load React Query only when needed
const ReactQueryProvider = lazy(() => import('./ReactQueryProvider'))
function App() {
const [showDashboard, setShowDashboard] = useState(false)
return (
<div>
<button onClick={() => setShowDashboard(true)}>
Open Dashboard
</button>
{showDashboard && (
<Suspense fallback={<div>Loading...</div>}>
<ReactQueryProvider>
<Dashboard />
</ReactQueryProvider>
</Suspense>
)}
</div>
)
}
Monitoring Performance
Track data fetching performance:
import { useEffect } from 'react'
function usePerformanceMonitoring(url: string) {
useEffect(() => {
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.name.includes(url)) {
console.log('Fetch performance:', {
url: entry.name,
duration: entry.duration,
transferSize: (entry as PerformanceResourceTiming).transferSize,
cached: (entry as PerformanceResourceTiming).transferSize === 0
})
// Send to analytics
// analytics.track('data_fetch', { ... })
}
}
})
observer.observe({ entryTypes: ['resource'] })
return () => observer.disconnect()
}, [url])
}
Best Practices Checklist
- Implement request deduplication
- Use appropriate caching strategies
- Debounce user input (300ms typical)
- Throttle scroll/resize events
- Lazy load data not immediately needed
- Prefetch anticipated data
- Monitor and optimize bundle size
- Track performance metrics
- Use stale-while-revalidate pattern
- Implement proper cache invalidation
Summary
Performance optimization is critical for production data fetching. By implementing caching, deduplication, and intelligent loading strategies, you can dramatically improve application performance.
Next Steps:
- See Real-World Examples of complete implementations
- Review earlier sections for comprehensive understanding
Key Takeaways:
- Request deduplication prevents wasteful duplicate calls
- Caching reduces server load and improves UX
- Debouncing optimizes rapid input changes
- Throttling limits request frequency
- Lazy loading reduces initial bundle size
- Prefetching improves perceived performance
- Monitoring helps identify bottlenecks