API calls and error handling in React
Use TanStack Query (or RTK Query / SWR) over hand-rolled `useEffect + fetch`. Handle the four states (idle, loading, success, error) explicitly. Distinguish error classes: network, 4xx (user / validation), 5xx (server), abort. Retry transient errors with backoff; don't retry 4xx. Show inline errors per query and a global error boundary for unexpected crashes. Cancel stale requests on dependency change. Always revalidate on the server.
The naive useEffect + fetch pattern works for one query in a demo. Real apps need caching, retries, cancellation, deduplication, optimistic updates — that's why data-fetching libraries exist.
The default in 2026: TanStack Query
const { data, error, isLoading, isFetching, refetch } = useQuery({
queryKey: ["user", id],
queryFn: ({ signal }) => api.user(id, { signal }),
staleTime: 30_000,
retry: (count, err) => err.status !== 404 && count < 3,
});
if (isLoading) return <Skeleton />;
if (error) return <ErrorPanel error={error} onRetry={refetch} />;
return <Profile user={data} />;What you get for free:
- Caching by
queryKey. Same key → same data across components. - Deduplication — two components rendering the same query make one network call.
- Stale-while-revalidate via
staleTimeandrefetchOnWindowFocus. - Retry with backoff, configurable.
- AbortSignal wired in — cancel on unmount or dep change.
- Optimistic updates via
onMutate. - Devtools that show every active query.
Alternatives: SWR (smaller, simpler), RTK Query (good if you're already in Redux Toolkit).
Error classification
Treat errors by class, not "any failure":
| Class | Action |
|---|---|
| Network (fetch threw, offline) | Retry with backoff. Show "trouble connecting." |
| 4xx user error (400, 422 validation) | No retry. Show inline field/form errors. |
| 401 unauthenticated | Trigger refresh; if refresh fails, redirect to login. |
| 403 forbidden | Show "no permission" UI. Don't retry. |
| 404 | "Not found" page or row. Sometimes valid state, not an error. |
| 409 conflict | Conflict UI (optimistic locking, edited concurrently). |
| 429 rate-limited | Backoff per Retry-After. |
| 5xx server | Retry up to N times. Surface as "we're having issues." |
| AbortError | Swallow. Not a real error. |
Wrap the fetch client with this classification:
async function api<T>(path: string, init?: RequestInit): Promise<T> {
const res = await fetch(path, { credentials: "include", ...init });
if (!res.ok) {
const body = await res.json().catch(() => ({}));
throw new ApiError(res.status, body.message ?? res.statusText, body);
}
return res.json();
}
class ApiError extends Error {
constructor(public status: number, message: string, public body?: any) { super(message); }
}Retries
TanStack Query default is 3 retries with exponential backoff. Tune per query:
useQuery({
queryKey: ["heavyReport"],
queryFn: ...,
retry: (count, err) => {
if (err instanceof ApiError && err.status >= 400 && err.status < 500) return false;
return count < 3;
},
retryDelay: (attempt) => Math.min(1000 * 2 ** attempt, 30_000),
});Never retry mutations idempotently unless the server is idempotent — POST /charge twice may charge the user twice. Use idempotency keys.
Cancellation
useEffect(() => {
const ctrl = new AbortController();
fetch("/x", { signal: ctrl.signal });
return () => ctrl.abort();
}, [id]);TanStack Query passes a signal into your queryFn that aborts on unmount and on queryKey change. Wire it through your fetch wrapper.
Loading states
Three rules:
- First load → skeleton or spinner.
- Refetch / background update → don't replace the UI; show a subtle indicator.
- Mutation in flight → optimistic if safe; otherwise disable the button + show pending.
TanStack distinguishes isLoading (no data yet) from isFetching (any fetch in progress) — use both.
Error UI
- Inline: per-component error fallback with a retry button. Keep the surrounding UI alive so users don't lose context.
- Global error boundary: catches unexpected throws (renders the error from
useQueryif you opt in viathrowOnError). - Toast for mutations: non-blocking; the form stays open with fields preserved.
useMutation({
mutationFn: api.save,
onError: (err) => toast.error(getMessage(err)),
});Map error codes to user-friendly messages — never show raw stack traces.
Stale-while-revalidate UX
Cached data displays immediately, refetch happens in background. The user sees content instantly; updates arrive without UI flicker. Configure:
{
staleTime: 30_000, // up to 30s, no refetch
gcTime: 5 * 60_000, // keep in cache for 5 min after unmount
refetchOnWindowFocus: true, // refetch when user returns
refetchOnReconnect: true, // refetch when network reconnects
}Pagination, infinite scroll
TanStack's useInfiniteQuery handles getNextPageParam and fetchNextPage with backpressure-safe cache management. Pair with an IntersectionObserver on a sentinel element.
Mutations + cache invalidation
useMutation({
mutationFn: api.updateUser,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["user"] }); // refetch affected
},
});Or, for optimistic updates, see the optimistic-vs-pessimistic question.
Server-side fetching (Next.js / RSC)
For React Server Components, do the fetch on the server. No useQuery needed; errors caught by a route's error.tsx boundary, loading by loading.tsx. Hand off to TanStack only for client-driven interactivity (mutations, refetch on focus).
Senior framing
The interviewer wants:
- Library choice with rationale (TanStack Query, not raw useEffect).
- Error classification beyond "show a generic toast."
- Cancellation of stale requests.
- Retry policy that doesn't double-submit mutations.
- Cache invalidation after mutations.
- Loading-state distinction (first load vs refetch).
- Accessibility of error messages.
The "I use fetch in useEffect" answer is junior. The architecture above is senior.
Follow-up questions
- •Why is `useEffect + fetch` inadequate for real apps?
- •When should you NOT retry a request?
- •How does TanStack Query's signal threading work?
- •How would you handle 401 globally in the fetch client?
Common mistakes
- •Retrying mutations without idempotency keys — duplicate charges.
- •Treating AbortError as a real error.
- •Showing a global error toast for inline-recoverable problems.
- •Refetching every render because queryKey is a fresh object literal.
Performance considerations
- •Stale-while-revalidate cuts perceived latency to zero on repeat visits.
- •Deduplication saves bandwidth for shared queries.
- •Background refetch on focus keeps data fresh without user action.
Edge cases
- •Auth cookie expiring during long sessions — global 401 handler.
- •Multiple components needing the same data with slightly different shapes — normalize via the query function.
- •Race conditions on rapid dep changes — signal-based cancellation.
Real-world examples
- •TanStack Query is the de-facto default in React projects in 2026.
- •SWR for simpler use cases; RTK Query inside Redux Toolkit apps.