React - Data Fetching Patterns
Overview
Estimated time: 30–45 minutes
Implement robust client-side data fetching primitives: in-memory cache with TTL, revalidation, retry with exponential backoff, deduplicated concurrent requests, and cancellation.
Learning Objectives
- Design an in-memory cache with TTL and invalidation.
- Retry transient failures with backoff, and cancel stale requests with AbortController.
- Deduplicate concurrent requests to the same resource.
Try it: Cached Fetch with Revalidate/Retry
View source
const { useEffect, useRef, useState } = React;
const cache = new Map(); // url -> { data, ts }
const inflight = new Map(); // url -> Promise
async function fetchJSON(url, { signal, retries=3, baseDelay=300 }={}){
for (let attempt=0; attempt=500 || res.status===429) throw new Error('retryable');
const text = await res.text();
throw new Error(text || ('HTTP '+res.status));
}
return await res.json();
} catch (e){
if (e.name === 'AbortError') throw e;
if (attempt === retries-1) throw e;
await new Promise(r => setTimeout(r, baseDelay * Math.pow(2, attempt)));
}
}
}
async function fetchWithCache(url, { ttl=30000, signal }={}){
const now = Date.now();
const hit = cache.get(url);
if (hit && (now - hit.ts) < ttl){ return { data: hit.data, source: 'cache' }; }
if (inflight.has(url)){
const data = await inflight.get(url);
return { data, source:'dedup' };
}
const p = (async () => {
const data = await fetchJSON(url, { signal });
cache.set(url, { data, ts: Date.now() });
return data;
})();
inflight.set(url, p);
try {
const data = await p; return { data, source:'network' };
} finally {
inflight.delete(url);
}
}
function useResource(url, { ttl=30000 }={}){
const [state, setState] = useState({ data:null, error:'', loading:false, source:'' });
const ctlRef = useRef(null);
useEffect(() => {
let canceled = false;
async function load(){
try{
setState(s => ({ ...s, loading:true, error:'' }));
if (ctlRef.current) ctlRef.current.abort();
ctlRef.current = new AbortController();
const { data, source } = await fetchWithCache(url, { ttl, signal: ctlRef.current.signal });
if (!canceled) setState({ data, error:'', loading:false, source });
}catch(e){
if (!canceled && e.name !== 'AbortError') setState({ data:null, error:e.message||'Error', loading:false, source:'' });
}
}
if (url) load();
return () => { canceled = true; if (ctlRef.current) ctlRef.current.abort(); };
}, [url, ttl]);
function invalidate(){ cache.delete(url); }
return { ...state, invalidate };
}
function Demo(){
const [id, setId] = useState('1');
const url = id ? `https://jsonplaceholder.typicode.com/posts/${id}` : '';
const { data, loading, error, source, invalidate } = useResource(url, { ttl: 15000 });
return (
<div>
<label>Post ID: <input value={id} onChange={e => setId(e.target.value)} style={{width:80}} /></label>
<button onClick={() => invalidate()} style={{marginLeft:8}}>Invalidate</button>
{loading && <div>Loading...</div>}
{error && <div style={{color:'salmon'}}>{error}</div>}
{data && <div style={{marginTop:8}}>
<div style={{color:'var(--muted)'}}>Source: {source}</div>
<h4 style={{margin:'8px 0'}}>{data.title}</h4>
<div>{data.body}</div>
</div>}
</div>
);
}
ReactDOM.createRoot(document.getElementById('try-fetch')).render(<Demo />);
Syntax primer
- Cache:
Map<url,{ data, ts }>
stores data + timestamp for TTL-based reuse. - Dedup: store a promise in an inflight map and await it for concurrent callers.
- Retry: exponential backoff for transient server errors (5xx/429).
- Cancel: AbortController prevents updating state on stale responses.
Common pitfalls
- Updating state after unmount: guard with abort/canceled flags.
- Unbounded cache growth: add size limits or LRU if needed.
Exercises
- Add a TTL control input and a "Revalidate" button that bypasses cache for the next load.
- Share the cache across components and verify dedup works between them.
Checks for Understanding
- How does the TTL cache determine whether to return a cached value?
- How are concurrent requests to the same URL deduplicated?
- When should a fetch be retried with exponential backoff vs failed immediately?
- What prevents stale responses from updating UI state?
Answers
- It stores { data, ts } per URL and compares now - ts against ttl; if within ttl, return the cached data.
- By storing the in-flight Promise in a Map keyed by URL; subsequent callers await the same Promise.
- Transient server errors (5xx/429) warrant retries with backoff; client errors (4xx other than 429) should fail immediately.
- AbortController signals and a canceled flag prevent updating state after unmount or when a newer request supersedes the old one.