Skip to content

Commit

Permalink
feat: add useSignallingEffect for idiomatic side-effect cancellation
Browse files Browse the repository at this point in the history
  • Loading branch information
myandrienko committed Feb 29, 2024
1 parent ade8d39 commit d027338
Show file tree
Hide file tree
Showing 5 changed files with 156 additions and 0 deletions.
56 changes: 56 additions & 0 deletions docs/useSignallingEffect.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# `useSignallingEffect`

Runs an effect with an `AbortSignal`, which is automatically aborted when the effect is cleaned up. Useful as a shorthand for common operations that require clean up or cancellation and support `AbortSignal`, like adding event listeners or fetching data.

## Usage

```jsx
import { useSignallingEffect } from 'react-use';

const Demo = () => {
useSignallingEffect((signal) => {
window.addEventListener('resize', () => {}, { signal });
}, []);

return null;
};
```

## Reference

```ts
useSignallingEffect(effect: EffectCallbackWithSignal, deps?: DependencyList);
```

Has the same signature as `useEffect`, but the effect callback receives an `AbortSignal` as an argument. The signal is guaranteed to abort before the clean-up function is called.

```js
useSignallingEffect((signal) => {
console.log(signal.aborted); // false
return () => console.log(signal.aborted); // true
});
```

## Examples

```js
const UserProfile = ({ userId }) => {
const [data, setData] = useState(null);

useSignallingEffect(
(signal) => {
(async () => {
try {
const res = await fetch(`https://example.com/user/${userId}`, { signal });
setData(await res.json());
} catch (err) {
if (!signal.aborted) {
throw err;
}
}
})();
},
[userId]
);
};
```
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ export { default as useDefault } from './useDefault';
export { default as useDrop } from './useDrop';
export { default as useDropArea } from './useDropArea';
export { default as useEffectOnce } from './useEffectOnce';
export { default as useSignallingEffect } from './useSignallingEffect';
export { default as useEnsuredForwardedRef, ensuredForwardRef } from './useEnsuredForwardedRef';
export { default as useEvent } from './useEvent';
export { default as useError } from './useError';
Expand Down
18 changes: 18 additions & 0 deletions src/useSignallingEffect.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import { DependencyList, useEffect } from 'react';

export type EffectCallbackWithSignal = (signal: AbortSignal) => void | (() => void | undefined);

export default function useSignallingEffect(
effect: EffectCallbackWithSignal,
deps?: DependencyList
) {
useEffect(() => {
const controller = new AbortController();
const cleanup = effect(controller.signal);

return () => {
controller.abort();
cleanup && cleanup();
};
}, deps);
}
17 changes: 17 additions & 0 deletions stories/useSignallingEffect.story.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
import { storiesOf } from '@storybook/react';
import * as React from 'react';
import { useSignallingEffect } from '../src';
import ConsoleStory from './util/ConsoleStory';
import ShowDocs from './util/ShowDocs';

const Demo = () => {
useSignallingEffect((signal) => {
window.addEventListener('resize', () => console.log('Window resized'), { signal });
}, []);

return <ConsoleStory />;
};

storiesOf('Side effects/useSignallingEffect', module)
.add('Docs', () => <ShowDocs md={require('../docs/useSignallingEffect.md')} />)
.add('Demo', () => <Demo />);
64 changes: 64 additions & 0 deletions tests/useSignallingEffect.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import { renderHook } from '@testing-library/react-hooks';
import useSignallingEffect from '../src/useSignallingEffect';

const mockEffectCleanup = jest.fn();
const mockEffectCallback = jest.fn().mockReturnValue(mockEffectCleanup);

describe('useSignallingEffect', () => {
it('should be defined', () => {
expect(useSignallingEffect).toBeDefined();
});

it('should run provided effect', () => {
renderHook(() => useSignallingEffect(mockEffectCallback));
expect(mockEffectCallback).toHaveBeenCalledTimes(1);
});

it('should run clean-up if returned by effect', () => {
const { unmount } = renderHook(() => useSignallingEffect(mockEffectCallback));
expect(mockEffectCleanup).not.toHaveBeenCalled();

unmount();
expect(mockEffectCleanup).toHaveBeenCalledTimes(1);
});

it('should account for dependencies list', () => {
const { rerender } = renderHook(({ dep }) => useSignallingEffect(mockEffectCallback, [dep]), {
initialProps: { dep: 1, notDep: 1 },
});
expect(mockEffectCallback).toHaveBeenCalledTimes(1);
expect(mockEffectCleanup).not.toHaveBeenCalled();

rerender({ dep: 2, notDep: 1 });
expect(mockEffectCallback).toHaveBeenCalledTimes(2);
expect(mockEffectCleanup).toHaveBeenCalledTimes(1);

rerender({ dep: 2, notDep: 2 });
expect(mockEffectCallback).toHaveBeenCalledTimes(2);
expect(mockEffectCleanup).toHaveBeenCalledTimes(1);
});

it('should send about signal', () => {
const mockAbortSignalListener = jest.fn();
const { unmount } = renderHook(() =>
useSignallingEffect((signal) => {
signal.addEventListener('abort', mockAbortSignalListener);
})
);

unmount();
expect(mockAbortSignalListener).toHaveBeenCalledTimes(1);
});

it('should abort signal before clean-up', () => {
let wasAborted = false;
const { unmount } = renderHook(() =>
useSignallingEffect((signal) => () => {
wasAborted = signal.aborted;
})
);

unmount();
expect(wasAborted).toBeTruthy();
});
});

0 comments on commit d027338

Please sign in to comment.