From 5faa0f684beace957985ef58dd15c6015ef699f6 Mon Sep 17 00:00:00 2001 From: haejunejung Date: Sun, 14 Jun 2026 15:59:30 +0900 Subject: [PATCH] feat(useAsyncEffect): provide an AbortSignal to the effect and abort it on cleanup --- .changeset/useasynceffect-abort-signal.md | 7 ++ .../useAsyncEffect/useAsyncEffect.spec.ts | 81 +++++++++++++++++++ .../hooks/useAsyncEffect/useAsyncEffect.ts | 33 ++++++-- 3 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 .changeset/useasynceffect-abort-signal.md diff --git a/.changeset/useasynceffect-abort-signal.md b/.changeset/useasynceffect-abort-signal.md new file mode 100644 index 00000000..8d1e51ac --- /dev/null +++ b/.changeset/useasynceffect-abort-signal.md @@ -0,0 +1,7 @@ +--- +'react-simplikit': minor +--- + +feat(useAsyncEffect): provide an AbortSignal to the effect and abort it on cleanup + +`useAsyncEffect` now creates an `AbortController` for each effect run and passes its `signal` to the effect callback. The signal is aborted when the component unmounts or dependencies change, so in-flight async operations (such as `fetch`) can be cancelled without manual boilerplate. An optional third `reason` argument is forwarded to `AbortController.abort(reason)`. diff --git a/packages/core/src/hooks/useAsyncEffect/useAsyncEffect.spec.ts b/packages/core/src/hooks/useAsyncEffect/useAsyncEffect.spec.ts index 4d81d1f0..18022e96 100644 --- a/packages/core/src/hooks/useAsyncEffect/useAsyncEffect.spec.ts +++ b/packages/core/src/hooks/useAsyncEffect/useAsyncEffect.spec.ts @@ -108,6 +108,87 @@ describe('useAsyncEffect', () => { expect(cleanup).toHaveBeenCalledTimes(1); }); + it('should pass an AbortSignal to the effect', async () => { + let receivedSignal: AbortSignal | null = null; + + await renderHookSSR(() => + useAsyncEffect(async signal => { + receivedSignal = signal; + }, []) + ); + + await flushPromises(); + + expect(receivedSignal).toBeInstanceOf(AbortSignal); + expect(receivedSignal!.aborted).toBe(false); + }); + + it('should abort the signal when the component unmounts', async () => { + let receivedSignal: AbortSignal | null = null; + const { unmount } = await renderHookSSR(() => + useAsyncEffect(async signal => { + receivedSignal = signal; + }, []) + ); + + await flushPromises(); + expect(receivedSignal!.aborted).toBe(false); + + unmount(); + expect(receivedSignal!.aborted).toBe(true); + }); + + it('should fully clean up the previous instance when dependencies change', async () => { + const signals: AbortSignal[] = []; + const cleanup = vi.fn(); + + const { rerender } = await renderHookSSR( + ({ dep }) => + useAsyncEffect( + async signal => { + signals.push(signal); + return cleanup; + }, + [dep] + ), + { + initialProps: { dep: 1 }, + } + ); + + await flushPromises(); + rerender({ dep: 2 }); + await flushPromises(); + + expect(signals).toHaveLength(2); + // The previous instance is fully torn down: its cleanup ran and its signal was aborted. + expect(cleanup).toHaveBeenCalledTimes(1); + expect(signals[0].aborted).toBe(true); + // The current instance stays active. + expect(signals[1].aborted).toBe(false); + }); + + it('should abort the signal with the provided reason', async () => { + let receivedSignal: AbortSignal | null = null; + const reason = new Error('aborted reason'); + + const { unmount } = await renderHookSSR(() => + useAsyncEffect( + async signal => { + receivedSignal = signal; + }, + [], + reason + ) + ); + + await flushPromises(); + unmount(); + + expect(receivedSignal!.aborted).toBe(true); + expect(receivedSignal!.reason).toBe(reason); + }); + it('should call effect every rerender when deps are undefined', async () => { const effect = vi.fn().mockResolvedValue(undefined); diff --git a/packages/core/src/hooks/useAsyncEffect/useAsyncEffect.ts b/packages/core/src/hooks/useAsyncEffect/useAsyncEffect.ts index 6394359a..e0a1de6e 100644 --- a/packages/core/src/hooks/useAsyncEffect/useAsyncEffect.ts +++ b/packages/core/src/hooks/useAsyncEffect/useAsyncEffect.ts @@ -5,10 +5,15 @@ import { DependencyList, useEffect } from 'react'; * `useAsyncEffect` is a React hook for handling asynchronous side effects in React components. * It follows the same cleanup pattern as `useEffect` while ensuring async operations are handled safely. * - * @param {() => Promise void)>} [effect] - An asynchronous function executed in the `useEffect` pattern. - * This function can optionally return a cleanup function. + * An `AbortController` is created for each effect run, and its `signal` is passed to the effect. + * When the component unmounts or the dependencies change, the signal is aborted, so in-flight async + * operations (such as `fetch`) can be cancelled without extra boilerplate. + * + * @param {(signal: AbortSignal) => Promise void)>} effect - An asynchronous function executed in the `useEffect` pattern. + * It receives an `AbortSignal` that is aborted on cleanup, and can optionally return a cleanup function. * @param {DependencyList} [deps] - A dependency array. - * The effect will re-run whenever any value in this array changes. If omitted, it runs only once when the component mounts. + * The effect will re-run whenever any value in this array changes. If omitted, it runs on every component re-render. + * @param {unknown} [reason] - An optional reason passed to `AbortController.abort(reason)` when the effect is cleaned up. * * @example * useAsyncEffect(async () => { @@ -19,14 +24,31 @@ import { DependencyList, useEffect } from 'react'; * console.log('Cleanup on unmount or dependencies change'); * }; * }, [dependencies]); + * + * // Cancel in-flight requests via the AbortSignal. The signal aborts on unmount or + * // dependency change, so handle the resulting AbortError yourself. + * useAsyncEffect(async signal => { + * try { + * const response = await fetch('/api/data', { signal }); + * setData(await response.json()); + * } catch (error) { + * if (error instanceof Error && error.name === 'AbortError') return; + * throw error; + * } + * }, [dependencies]); */ -export function useAsyncEffect(effect: () => Promise void)>, deps?: DependencyList) { +export function useAsyncEffect( + effect: (signal: AbortSignal) => Promise void)>, + deps?: DependencyList, + reason?: unknown +) { useEffect(() => { + const abortController = new AbortController(); let cleanup: (() => void) | void; let isCleaned = false; - effect().then(result => { + effect(abortController.signal).then(result => { cleanup = result; if (isCleaned) { cleanup?.(); @@ -35,6 +57,7 @@ export function useAsyncEffect(effect: () => Promise void)>, deps? return () => { isCleaned = true; + abortController.abort(reason); cleanup?.(); };