React - Stale‑While‑Revalidate (SWR)

Overview

Estimated time: 20–30 minutes

Implement the stale‑while‑revalidate strategy: serve cached data immediately, kick off a background refresh, and update the UI when new data arrives.

Try it: SWR with background refresh

View source
const { useEffect, useRef, useState } = React;
const cache = new Map(); // url -> { data, ts }
async function fetchJSON(url){
  const res = await fetch(url);
  if (!res.ok) throw new Error('HTTP '+res.status);
  return res.json();
}
function useSWR(url, { ttl=15000 }={}){
  const [state, setState] = useState({ data:null, error:'', refreshing:false, from:'' });
  const ctlRef = useRef(null);
  useEffect(() => {
    let canceled = false;
    async function run(){
      const hit = cache.get(url);
      if (hit && Date.now()-hit.ts < ttl){
        setState({ data:hit.data, error:'', refreshing:true, from:'cache' });
        // Background refresh
        try {
          const fresh = await fetchJSON(url);
          if (!canceled){ cache.set(url, { data:fresh, ts:Date.now() }); setState({ data:fresh, error:'', refreshing:false, from:'network' }); }
        } catch (e){ if (!canceled) setState(s => ({ ...s, refreshing:false })); }
      } else {
        setState(s => ({ ...s, refreshing:true }));
        try {
          const data = await fetchJSON(url);
          if (!canceled){ cache.set(url, { data, ts:Date.now() }); setState({ data, error:'', refreshing:false, from:'network' }); }
        } catch (e){ if (!canceled) setState({ data:null, error:e.message||'Error', refreshing:false, from:'' }); }
      }
    }
    if (url) run();
    return () => { canceled = true; };
  }, [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, error, refreshing, from, invalidate } = useSWR(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>
      {error && <div style={{color:'salmon'}}>{error}</div>}
      {data && <div style={{marginTop:8}}>
        <div style={{color:'var(--muted)'}}>From: {from} {refreshing && '(refreshing...)'}</div>
        <h4 style={{margin:'8px 0'}}>{data.title}</h4>
        <div>{data.body}</div>
      </div>}
    </div>
  );
}
ReactDOM.createRoot(document.getElementById('try-swr')).render(<Demo />);

Syntax primer

  • Use an in-memory cache with timestamps and TTL.
  • When a cache hit is fresh, render it and refresh in the background.
  • Indicate refresh status to users and update when new data arrives.

Common pitfalls

  • Not indicating background refresh – users think data is stale.
  • Never invalidating – provide a manual Invalidate or time-based revalidation.

Checks for Understanding

  1. What are the core steps in a stale‑while‑revalidate flow?
  2. How do you signal to users that new data is loading in the background?
Show answers
  1. Serve cached data if fresh, start a background fetch, and update cache/UI on completion.
  2. Show a small indicator (e.g., “refreshing…”) or a spinner near the data.

Exercises

  1. Add per‑key TTL and a global invalidate‑all action.
  2. Handle fetch cancellation on rapid ID changes to avoid race conditions.
  3. Persist cache to localStorage and restore on page load.