Skip to content
matthewcm.dev

    ↑ ↓ move · ↵ open · tab scopeopen graph →

    React Data Fetching Patterns

    Use a server-state library by default; otherwise keep fetches close to the feature, cancel stale requests with AbortController, debounce user-driven queries, and know your Promise combinators.

    Seedtended planted 1 min read

    🌱 A rough idea, planted recently. Expect rough edges — it will grow.

    On this page

    Use a server-state library (TanStack Query, formerly React Query) by default. Without one, keep fetch close to the feature that uses it, cancel stale requests with an AbortController, and debounce anything the user types.

    Where to fetch

    • TanStack Query handles caching, de-duplication, retries and cancellation. It’s my default.
    • Without it, fetching inside the feature component (or a feature hook) is fine. Keep the data close to what uses it, with an abort in the effect cleanup.

    Cancelling with AbortController

    Cancelling stale requests prevents race conditions, where an older, slower response overwrites a newer one. It also saves you from manual “ignore this” flags.

    useEffect(() => {
      const ctrl = new AbortController();
      fetch(`/api/users?q=${q}`, { signal: ctrl.signal })
        .then((r) => r.json())
        .then(setUsers)
        .catch((e) => { if (e.name !== "AbortError") setError(e); });
      return () => ctrl.abort(); // a new q, or unmounting, cancels the old request
    }, [q]);
    
    // A timeout on anything that accepts a signal:
    fetch(url, { signal: AbortSignal.timeout(3000) });
    // Both at once:
    AbortSignal.any([ctrl.signal, AbortSignal.timeout(3000)]);

    TanStack Query passes a signal into your queryFn (({ signal }) => fetch(url, { signal })) and aborts it when the query goes stale or unmounts.

    A debounce hook

    Around 300 ms is a sensible delay for search-as-you-type.

    function useDebounce<T>(value: T, delay = 300) {
      const [debounced, setDebounced] = useState(value);
      useEffect(() => {
        const id = setTimeout(() => setDebounced(value), delay);
        return () => clearTimeout(id); // each keystroke restarts the timer
      }, [value, delay]);
      return debounced;
    }

    Debounce waits for a pause (search). Throttle caps the rate (scroll, streaming updates); see Finding React Re-render and Memory Leaks.

    Promise combinators

    CombinatorResolves whenRejects when
    Promise.allall fulfil (results in order)any rejects (fail fast)
    Promise.allSettledall settle; you get {status, value/reason} for eachnever
    Promise.anythe first fulfilsall reject (AggregateError)
    Promise.racethe first settles, success or failurethe first to settle rejects

    Racing against a timeout promise used to be the way to time out a request. AbortSignal.timeout is better because it actually cancels the request.

    What to do when a request fails is in React Error Handling; testing fetch code without mocking fetch itself is in Stop Mocking Fetch; the n+1 request problem is in Browser Fundamentals for Front-end Engineers.

    • #react
    • #data-fetching
    • #react-query
    • #abortcontroller
    • #promises

    ↪ Links to (4)