Skip to main content

async errors in useEffect

1) The basic pattern: IIFE + try/catch/finally

javascript
useEffect(() => { let cancelled = false; (async () => { try { setLoading(true); const res = await fetch("/api/items"); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); if (!cancelled) setItems(data); } catch (e) { // log here and put the error into state const err = e instanceof Error ? e : new Error(String(e)); if (!cancelled) setError(err); // send to monitoring (Sentry/GlitchTip) // reportError(err) } finally { if (!cancelled) setLoading(false); } })(); return () => { cancelled = true; }; }, [/* deps */]);

Why the cancelled flag? So setState is not called after unmount/after the request changes.


2) Canceling requests correctly: AbortController

javascript
useEffect(() => { const controller = new AbortController(); (async () => { try { setLoading(true); const res = await fetch(`/api/items`, { signal: controller.signal }); if (!res.ok) throw new Error(`HTTP ${res.status}`); const data = await res.json(); setItems(data); } catch (e) { // ignore the cancellation if (e instanceof DOMException && e.name === "AbortError") return; const err = e instanceof Error ? e : new Error(String(e)); setError(err); // reportError(err) } finally { setLoading(false); } })(); return () => controller.abort(); }, [/* deps */]);

Benefits: no race conditions when dependencies change, safe cancellation in React 18 (Strict Mode runs effects twice in dev).


3) Validate the response and explicitly throw errors

javascript
const res = await fetch(url, { signal }); if (!res.ok) throw new Error(`HTTP ${res.status} ${res.statusText}`); const json = await res.json(); if (!Array.isArray(json)) throw new Error("Unexpected payload shape");

Otherwise "silent" errors turn into unpredictable bugs.


4) Retries with exponential backoff (without infinite loops)

javascript
async function withRetry<T>(fn: () => Promise<T>, attempts = 3) { let lastErr: unknown; for (let i = 0; i < attempts; i++) { try { return await fn(); } catch (e) { lastErr = e; await new Promise(r => setTimeout(r, 2 ** i * 300)); // 300ms, 600ms, 1200ms... } } throw lastErr; } useEffect(() => { const controller = new AbortController(); (async () => { try { setLoading(true); const data = await withRetry( () => fetch(url, { signal: controller.signal }).then(r => { if (!r.ok) throw new Error(`HTTP ${r.status}`); return r.json(); }), 3 ); setData(data); } catch (e) { if (e instanceof DOMException && e.name === "AbortError") return; setError(e as Error); } finally { setLoading(false); } })(); return () => controller.abort(); }, [url]);

5) Watch the effect's dependencies

  • Everything used inside the effect that changes across renders must be in deps: URL, tokens, filters, and so on.
  • If you pass callbacks/objects, memoize them (useCallback, useMemo) so the effect does not fire needlessly.

6) Request races: "only keep the latest one current"

If AbortController cannot be used (not every client supports it), keep a "request id":

javascript
useEffect(() => { let requestId = Symbol(); const current = requestId; (async () => { try { const data = await api.load(params); if (current === requestId) setData(data); } catch (e) { if (current === requestId) setError(e as Error); } })(); return () => { requestId = Symbol(); }; }, [params]);

7) Where to show the error

  • A local error state -> show an inline alert/a Retry button.
  • Global critical ones - report to Sentry/GlitchTip.
  • An Error Boundary for render errors in the UI; not for async - those are caught right there, in the effect.

8) Practical details

  • In TypeScript, declare catch (e: unknown) and normalize it to an Error.
  • Always turn off loading in finally.
  • For reloading, keep a reload() (change a key, or increment a version in deps).
  • Reset the previous error before a new attempt (setError(null)), so the UI does not get "stuck".

9) When it is better not to write all of this by hand

TanStack Query / RTK Query are a great fit for data:

  • automatic cache, retries, cancellation, request deduplication, an isLoading/error/data status.
  • Less code in effects, fewer chances to get it wrong.

Short Answer

Interview ready
Premium

A concise answer to help you respond confidently on this topic during an interview.