Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .changeset/useasynceffect-abort-signal.md
Original file line number Diff line number Diff line change
@@ -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)`.
81 changes: 81 additions & 0 deletions packages/core/src/hooks/useAsyncEffect/useAsyncEffect.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);

Expand Down
33 changes: 28 additions & 5 deletions packages/core/src/hooks/useAsyncEffect/useAsyncEffect.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 | (() => 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 | (() => 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 () => {
Expand All @@ -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 | (() => void)>, deps?: DependencyList) {
export function useAsyncEffect(
effect: (signal: AbortSignal) => Promise<void | (() => 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?.();
Expand All @@ -35,6 +57,7 @@ export function useAsyncEffect(effect: () => Promise<void | (() => void)>, deps?

return () => {
isCleaned = true;
abortController.abort(reason);
cleanup?.();
};

Expand Down