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

  1. Add a TTL control input and a "Revalidate" button that bypasses cache for the next load.
  2. Share the cache across components and verify dedup works between them.

Checks for Understanding

  1. How does the TTL cache determine whether to return a cached value?
  2. How are concurrent requests to the same URL deduplicated?
  3. When should a fetch be retried with exponential backoff vs failed immediately?
  4. What prevents stale responses from updating UI state?
Answers
  1. It stores { data, ts } per URL and compares now - ts against ttl; if within ttl, return the cached data.
  2. By storing the in-flight Promise in a Map keyed by URL; subsequent callers await the same Promise.
  3. Transient server errors (5xx/429) warrant retries with backoff; client errors (4xx other than 429) should fail immediately.
  4. AbortController signals and a canceled flag prevent updating state after unmount or when a newer request supersedes the old one.