Repository: streamich/react-use
Pull request #824 (2019-12-07 23:57:01)

feat: 🎸 add useUnmountPromise hook
Another way to check if component is still mounted.

## Type of change

<!-- Check all relevant options. -->
- [ ] Bug fix _(non-breaking change which fixes an issue)_
- [x] New feature _(non-breaking change which adds functionality)_
- [ ] **Breaking change** _(fix or feature that would cause existing functionality to not work as before)_

# Checklist
- [x] Read the [Contributing Guide](https://github.com/streamich/react-use/blob/master/CONTRIBUTING.md)
- [x] Perform a code self-review
- [x] Comment the code, particularly in hard-to-understand areas
- [x] Add documentation
- ~~[ ] Add hook's story at Storybook~~
- [x] Cover changes with tests
- [x] Ensure the test suite passes (`yarn test`)
- [ ] Provide 100% tests coverage
- [x] Make sure code lints (`yarn lint`). Fix it with `yarn lint:fix` in case of failure.
- [x] Make sure types are fine (`yarn lint:types`).



Patch:
diff --git a/README.md b/README.md
index 6f370fb0..127ccab0 100644
--- a/README.md
+++ b/README.md
@@ -111,7 +111,7 @@
   - [`useMountedState`](./docs/useMountedState.md) &mdash; track if component is mounted.
   - [`usePromise`](./docs/usePromise.md) &mdash; resolves promise only while component is mounted.
   - [`useLogger`](./docs/useLogger.md) &mdash; logs in console as component goes through life-cycles.
-  - [`useMount`](./docs/useMount.md) &mdash; calls `mount` callbacks.
+  - [`useMount`](./docs/useMount.md) and [`useUnmountPromise`](./docs/useUnmountPromise.md) &mdash; tracks if component is mounted.
   - [`useUnmount`](./docs/useUnmount.md) &mdash; calls `unmount` callbacks.
   - [`useUpdateEffect`](./docs/useUpdateEffect.md) &mdash; run an `effect` only on updates.
   - [`useIsomorphicLayoutEffect`](./docs/useIsomorphicLayoutEffect.md) &mdash; `useLayoutEffect` that does not show warning when server-side rendering.
diff --git a/docs/useUnmountPromise.md b/docs/useUnmountPromise.md
new file mode 100644
index 00000000..5a129cb9
--- /dev/null
+++ b/docs/useUnmountPromise.md
@@ -0,0 +1,30 @@
+# `useUnmountPromise`
+
+A life-cycle hook that provides a higher order promise that does not resolve if component un-mounts.
+
+
+## Usage 
+
+```ts
+import useUnmountPromise from 'react-use/lib/useUnmountPromise';
+
+const Demo = () => {
+  const mounted = useUnmountPromise();
+  useEffect(async () => {
+    await mounted(someFunction()); // Will not resolve if component un-mounts.
+  });
+};
+```
+
+
+## Reference
+
+```ts
+const mounted = useUnmountPromise();
+
+mounted(promise);
+mounted(promise, onError);
+```
+
+- `onError` &mdash; if promise rejects after the component is unmounted, `onError`
+  callback is called with the error.
diff --git a/src/index.ts b/src/index.ts
index 2559d82d..61ef03e3 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -85,6 +85,7 @@ export { default as useTitle } from './useTitle';
 export { default as useToggle } from './useToggle';
 export { default as useTween } from './useTween';
 export { default as useUnmount } from './useUnmount';
+export { default as useUnmountPromise } from './useUnmountPromise';
 export { default as useUpdate } from './useUpdate';
 export { default as useUpdateEffect } from './useUpdateEffect';
 export { default as useUpsert } from './useUpsert';
diff --git a/src/useUnmountPromise.ts b/src/useUnmountPromise.ts
new file mode 100644
index 00000000..3c3d3801
--- /dev/null
+++ b/src/useUnmountPromise.ts
@@ -0,0 +1,33 @@
+import { useMemo, useRef, useEffect } from 'react';
+
+export type Race = <P extends Promise<any>, E = any>(promise: P, onError?: (error: E) => void) => P;
+
+const useUnmountPromise = (): Race => {
+  const refUnmounted = useRef(false);
+  useEffect(() => () => {
+    refUnmounted.current = true;
+  });
+
+  const wrapper = useMemo(() => {
+    const race = <P extends Promise<any>, E>(promise: P, onError?: (error: E) => void) => {
+      const newPromise: P = new Promise((resolve, reject) => {
+        promise.then(
+          result => {
+            if (!refUnmounted.current) resolve(result);
+          },
+          error => {
+            if (!refUnmounted.current) reject(error);
+            else if (onError) onError(error);
+            else console.error('useUnmountPromise', error);
+          }
+        );
+      }) as P;
+      return newPromise;
+    };
+    return race;
+  }, []);
+
+  return wrapper;
+};
+
+export default useUnmountPromise;


Test patch:
diff --git a/tests/useUnmountPromise.test.ts b/tests/useUnmountPromise.test.ts
new file mode 100644
index 00000000..2f186e38
--- /dev/null
+++ b/tests/useUnmountPromise.test.ts
@@ -0,0 +1,79 @@
+import { renderHook } from '@testing-library/react-hooks';
+import useUnmountPromise from '../src/useUnmountPromise';
+
+describe('useUnmountPromise', () => {
+  it('should be defined', () => {
+    expect(useUnmountPromise).toBeDefined();
+  });
+
+  it('should return a function', () => {
+    const hook = renderHook(() => useUnmountPromise());
+
+    expect(typeof hook.result.current).toBe('function');
+  });
+
+  it('when component is mounted function should resolve with wrapped promises', async () => {
+    const hook = renderHook(() => useUnmountPromise());
+
+    const mounted = hook.result.current;
+    const res = await mounted(new Promise(r => setTimeout(() => r(25), 10)));
+
+    expect(res).toBe(25);
+  });
+
+  it('when component is unmounted promise never resolves', async () => {
+    const hook = renderHook(() => useUnmountPromise());
+
+    const mounted = hook.result.current;
+    const promise = mounted(new Promise(r => setTimeout(() => r(25), 10)));
+
+    hook.unmount();
+
+    const res = await Promise.race([promise, new Promise(r => setTimeout(() => r('UNMOUNTED'), 20))]);
+    expect(res).toBe('UNMOUNTED');
+  });
+
+  it('when component is mounted function should resolve with wrapped promises - 2', async () => {
+    const hook = renderHook(() => useUnmountPromise());
+
+    const mounted = hook.result.current;
+    const promise = mounted(new Promise(r => setTimeout(() => r(25), 10)));
+
+    // hook.unmount();
+
+    const res = await Promise.race([promise, new Promise(r => setTimeout(() => r('UNMOUNTED'), 20))]);
+    expect(res).toBe(25);
+  });
+
+  describe('when promise throws', () => {
+    describe('when component is mounted', () => {
+      it('onError callback is not called', async () => {
+        const hook = renderHook(() => useUnmountPromise());
+
+        const mounted = hook.result.current;
+        const onError = jest.fn();
+        try {
+          await mounted(new Promise((r, reject) => setTimeout(() => reject(r), 10)), onError);
+        } catch {}
+
+        expect(onError).toHaveBeenCalledTimes(0);
+      });
+    });
+
+    describe('when component is un-mounted', () => {
+      it('onError callback is called', async () => {
+        const hook = renderHook(() => useUnmountPromise());
+
+        const mounted = hook.result.current;
+        const onError = jest.fn();
+        const promise = mounted(new Promise((r, reject) => setTimeout(() => reject(r), 10)), onError);
+
+        hook.unmount();
+        await Promise.race([promise, new Promise(r => setTimeout(r, 20))]);
+
+        expect(onError).toHaveBeenCalledTimes(1);
+        expect(typeof onError.mock.calls[0][0]).toBe('function');
+      });
+    });
+  });
+});