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:
- Single state machine via
useReducerso transitions are atomic. - AbortController scoped to each effect, aborted in cleanup.
- Effect deps include the URL (and a
refreshKeyif 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;
}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
React
Medium
hot
6 min