useEffect returns a cleanup function, and you need it

Anything an effect starts — a subscription, a timer, a fetch, an event listener — outlives the component unless the effect returns something that stops it.

useEffect(() => {
  const controller = new AbortController();

  fetch(`/api/orders/${id}`, { signal: controller.signal })
    .then(r => r.json())
    .then(setOrder)
    .catch(e => { if (e.name !== 'AbortError') report(e); });

  return () => controller.abort();
}, [id]);

The cleanup runs before every re-run of the effect as well as on unmount, which is the part people miss — an effect with a changing dependency cleans up and re-runs on every change. Without the abort, a component that unmounts mid-request sets state on something that no longer exists and React warns about it. Distinguishing the abort error from a real failure is required, or every cancellation is reported as a bug.