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.