Back to React
React
medium
very high
mid

How would you build a custom React hook that handles loading, error, and success states?

A correct fetch hook tracks {status, data, error}, cancels in-flight requests on unmount or arg change with AbortController, and avoids stale-state-after-unmount warnings. The reducer pattern keeps transitions safe.

7 min read·~25 min to think through

The "naïve useFetch" interview answer ships three classic bugs: it sets state after unmount, it doesn't cancel the previous request when the URL changes, and it spreads three booleans (loading, error, data) that can disagree (e.g. loading=true while data is also set).

A solid hook fixes all three:

  1. Single state machine via useReducer so transitions are atomic.
  2. AbortController scoped to each effect, aborted in cleanup.
  3. Effect deps include the URL (and a refreshKey if you want manual refresh).

Senior nuance: in production you'd use React Query / SWR instead — they add caching, deduping, retries, focus revalidation, and SSR hydration. Build this hook to demonstrate the mechanics; recommend the library for actual code.

Code

tsx
type State<T> =
  | { status: "idle" }
  | { status: "loading" }
  | { status: "success"; data: T }
  | { status: "error"; error: Error };

type Action<T> =
  | { type: "start" }
  | { type: "ok"; data: T }
  | { type: "err"; error: Error };

function reducer<T>(_: State<T>, a: Action<T>): State<T> {
  switch (a.type) {
    case "start": return { status: "loading" };
    case "ok":    return { status: "success", data: a.data };
    case "err":   return { status: "error",   error: a.error };
  }
}

export function useFetch<T>(url: string) {
  const [state, dispatch] = useReducer(reducer<T>, { status: "idle" });

  useEffect(() => {
    const ctrl = new AbortController();
    dispatch({ type: "start" });
    fetch(url, { signal: ctrl.signal })
      .then(async (r) => {
        if (!r.ok) throw new Error(`HTTP ${r.status}`);
        return (await r.json()) as T;
      })
      .then((data) => dispatch({ type: "ok", data }))
      .catch((e) => {
        if (e.name === "AbortError") return; // expected on cleanup
        dispatch({ type: "err", error: e });
      });

    return () => ctrl.abort();
  }, [url]);

  return state;
}
useFetch — cancellation-aware, reducer-based

Follow-up questions

  • How does this compare to React Query? What does it not handle?
  • How do you add retry-with-exponential-backoff?
  • How would you extend this to support optimistic updates?

Common mistakes

  • Three booleans (loading/error/data) instead of a tagged union — they disagree.
  • Forgetting AbortController — the previous request can resolve after the new one and overwrite fresh data.
  • Setting state after unmount and chasing the React warning instead of cleaning up.

Performance considerations

  • Without dedupe, two components fetching the same URL fire two requests — React Query / SWR de-dupe by key.

Edge cases

  • AbortError is an expected error — must be filtered, not surfaced to the UI.
  • Strict Mode double-invokes the effect in dev; ensure cleanup truly aborts.

Real-world examples

  • TanStack Query implements this same state machine plus cache, dedupe, refocus revalidation, and SSR hydration.

Senior engineer discussion

Senior signal: discuss why a custom hook is rarely the right answer in production, what 'data fetching primitives' libraries solve (cache key, structural sharing, retry, focus, mutate), and the new use() + Suspense data-fetching model.

Related questions