Combining AbortController timeout and unmount cleanup in useEffect fetch
0 reputation · 23 Mar 2023, 06:02 UTC
0 reputation · 23 Mar 2023, 06:02 UTC
I'm configuring a data-fetching effect in React 18 that needs two cancellation paths: aborting the request when the component unmounts (or when dependencies change), and enforcing a client-side timeout since fetch has no built-in timeout option. My current design creates one AbortController inside useEffect, passes its signal to fetch, and calls abort() in the cleanup function.
The uncertainty is how to layer the timeout on top of this. If I add a setTimeout that calls abort() on the same controller, the cleanup function also calls abort(), so the two cancellation sources share one signal. I then need to distinguish a timeout abort from an unmount abort in the catch block, since both surface as AbortError, and I want to show a retry message only for the timeout case.
I'm also unsure whether the timeout timer should be cleared inside the effect cleanup alongside the abort call, or whether ordering matters to avoid aborting a request that already resolved.
Should I use a single controller for both unmount and timeout, or separate controllers with a combined signal? What is the correct cleanup ordering for the timer and abort call? And how can I reliably tell a timeout-triggered abort apart from an unmount-triggered one?
Use a single AbortController with a local timedOut flag for broad compatibility, or AbortSignal.any([unmountSignal, AbortSignal.timeout(ms)]) when your target runtimes support it. Cleanup ordering does not matter—abort() is idempotent and aborting a settled fetch is a no-op. Distinguish timeout from unmount by checking the flag (or signal.reason with the modern API) before treating the error as a network failure.
useEffect(() => {
const controller = new AbortController();
let timedOut = false;
const timer = setTimeout(() => {
timedOut = true;
controller.abort();
}, 5000);
fetch(url, { signal: controller.signal })
.then(res => res.json())
.then(data => {
if (!controller.signal.aborted) setData(data);
})
.catch(err => {
if (err.name === 'AbortError') {
if (timedOut) setError('timeout');
return; // silent for unmount
}
setError('network');
});
return () => {
clearTimeout(timer);
controller.abort();
};
}, [url]);The flag is set inside the timer callback, so a timeout that fires after the response has already resolved cannot flip it spuriously. The cleanup function clears the timer first, then calls abort()—order is irrelevant, but clearing the timer prevents a stale callback from running against a controller you might reuse in a ref.
AbortSignal.any with AbortSignal.timeout (Modern, Cleaner)useEffect(() => {
if (typeof AbortSignal.timeout !== 'function' || typeof AbortSignal.any !== 'function') {
// fall back to Pattern A
return;
}
const unmountController = new AbortController();
const timeoutSignal = AbortSignal.timeout(5000);
const signal = AbortSignal.any([unmountController.signal, timeoutSignal]);
fetch(url, { signal })
.then(res => res.json())
.then(data => {
if (!signal.aborted) setData(data);
})
.catch(err => {
if (err.name === 'TimeoutError') {
setError('timeout');
return;
}
if (err.name === 'AbortError') return; // unmount
setError('network');
});
return () => unmountController.abort();
}, [url]);The composite signal's reason reflects whichever input aborted first, so the catch block sees TimeoutError or AbortError directly—no flag needed. Feature-detect the APIs before relying on them.
Both clearTimeout(timer) and controller.abort() are safe in either order. abort() on an already-settled fetch does not reject the promise. The only reason to clear the timer is to avoid a stray callback touching a controller that has been reassigned or stored in a ref across effect runs.
timedOut set in the timer callback, checked in catch before err.name === 'AbortError'.controller.abort(new DOMException('timeout', 'TimeoutError')) makes signal.reason a TimeoutError; unmount abort leaves it as AbortError. Only works in runtimes that honor abort(reason).AbortSignal.any surfaces the reason of the first input to abort.err.name, err.message, signal.reason in the catch block./delay/10) to force the timeout and verify the retry message appears.clearTimeout ran.What are the exact browser and Node versions you must support? That determines whether Pattern B is viable or you must stick with Pattern A.
Use comments to ask for clarification. Post a solution as an answer.
No question comments on this page.