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
errorstate -> 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 anError. - Always turn off
loadinginfinally. - For reloading, keep a
reload()(change a key, or increment aversionin 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/datastatus. - Less code in effects, fewer chances to get it wrong.
Short Answer
Interview readyPremium
A concise answer to help you respond confidently on this topic during an interview.