Back to React
React
medium
mid

How do you handle API calls and errors in a React application?

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.

9 min read·~15 min to think through

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

tsx
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 staleTime and refetchOnWindowFocus.
  • 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":

ClassAction
Network (fetch threw, offline)Retry with backoff. Show "trouble connecting."
4xx user error (400, 422 validation)No retry. Show inline field/form errors.
401 unauthenticatedTrigger refresh; if refresh fails, redirect to login.
403 forbiddenShow "no permission" UI. Don't retry.
404"Not found" page or row. Sometimes valid state, not an error.
409 conflictConflict UI (optimistic locking, edited concurrently).
429 rate-limitedBackoff per Retry-After.
5xx serverRetry up to N times. Surface as "we're having issues."
AbortErrorSwallow. Not a real error.

Wrap the fetch client with this classification:

ts
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:

ts
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

tsx
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:

  1. First load → skeleton or spinner.
  2. Refetch / background update → don't replace the UI; show a subtle indicator.
  3. 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 useQuery if you opt in via throwOnError).
  • Toast for mutations: non-blocking; the form stays open with fields preserved.
tsx
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:

ts
{
  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

ts
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:

  1. Library choice with rationale (TanStack Query, not raw useEffect).
  2. Error classification beyond "show a generic toast."
  3. Cancellation of stale requests.
  4. Retry policy that doesn't double-submit mutations.
  5. Cache invalidation after mutations.
  6. Loading-state distinction (first load vs refetch).
  7. 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.

Related questions