Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add useSignallingEffect for idiomatic side-effect cancellation #2546

Open
wants to merge 1 commit into
base: master
Choose a base branch
from
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.
Jump to
Jump to file
Failed to load files.
Diff view
Diff view
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,7 @@
- [`useLockBodyScroll`](./docs/useLockBodyScroll.md) — lock scrolling of the body element.
- [`useRafLoop`](./docs/useRafLoop.md) — calls given function inside the RAF loop.
- [`useSessionStorage`](./docs/useSessionStorage.md) — manages a value in `sessionStorage`.
- [`useSignallingEffect`](./docs/useSignallingEffect.md) — idiomatic side-effect cancellation with `AbortSignal`.
- [`useThrottle` and `useThrottleFn`](./docs/useThrottle.md) — throttles a function. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/side-effects-usethrottle--demo)
- [`useTitle`](./docs/useTitle.md) — sets title of the page.
- [`usePermission`](./docs/usePermission.md) — query permission status for browser APIs.
Expand Down
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(`/api/users/${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();
});
});