Repository: streamich/react-use
Pull request #540 (2019-08-20 07:06:56)

feat: useTimeoutFn
Hook giving ability to run function after timeout without re-rendering parent component.
Giving handles to check the call state and cancel the timeout.

Timeout restarts itself on deps change and unmout.


Patch:
diff --git a/README.md b/README.md
index d24873e0..a812c062 100644
--- a/README.md
+++ b/README.md
@@ -82,6 +82,7 @@
   - [`useInterval`](./docs/useInterval.md) &mdash; re-renders component on a set interval using `setInterval`.
   - [`useSpring`](./docs/useSpring.md) &mdash; interpolates number over time according to spring dynamics.
   - [`useTimeout`](./docs/useTimeout.md) &mdash; returns true after a timeout.
+  - [`useTimeoutFn`](./docs/useTimeoutFn.md) &mdash; calls given function after a timeout. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/animation-usetimeoutfn--demo)
   - [`useTween`](./docs/useTween.md) &mdash; re-renders component, while tweening a number from 0 to 1. [![][img-demo]](https://codesandbox.io/s/52990wwzyl)
   - [`useUpdate`](./docs/useUpdate.md) &mdash; returns a callback, which re-renders component when called.
     <br/>
diff --git a/docs/useTimeoutFn.md b/docs/useTimeoutFn.md
new file mode 100644
index 00000000..52d259be
--- /dev/null
+++ b/docs/useTimeoutFn.md
@@ -0,0 +1,65 @@
+# `useTimeoutFn`
+
+Calls given function after specified amount of milliseconds.  
+**Note:** this hook does not re-render component by itself.
+
+Automatically cancels timeout on component unmount.
+Automatically resets timeout on delay change.
+
+## Usage
+
+```jsx
+import * as React from 'react';
+import { useTimeoutFn } from 'react-use';
+
+const Demo = () => {
+  const [state, setState] = React.useState('Not called yet');
+
+  function fn() {
+    setState(`called at ${Date.now()}`);
+  }
+
+  const [isReady, cancel, reset] = useTimeoutFn(fn, 5000);
+  const cancelButtonClick = useCallback(() => {
+    if (isReady() === false) {
+      cancel();
+      setState(`cancelled`);
+    } else {
+      reset();
+      setState('Not called yet');
+    }
+  }, []);
+
+  const readyState = isReady();
+
+  return (
+    <div>
+      <div>{readyState !== null ? 'Function will be called in 5 seconds' : 'Timer cancelled'}</div>
+      <button onClick={cancelButtonClick}> {readyState === false ? 'cancel' : 'restart'} timeout</button>
+      <br />
+      <div>Function state: {readyState === false ? 'Pending' : readyState ? 'Called' : 'Cancelled'}</div>
+      <div>{state}</div>
+    </div>
+  );
+};
+```
+
+## Reference
+
+```ts 
+const [
+    isReady: () => boolean | null,
+    cancel: () => void,
+    reset: () => void,
+] = useTimeoutFn(fn: Function, ms: number = 0);
+```
+
+- **`fn`**_`: Function`_ - function that will be called;
+- **`ms`**_`: number`_ - delay in milliseconds;
+- **`isReady`**_`: ()=>boolean|null`_ - function returning current timeout state:
+    - `false` - pending
+    - `true` - called
+    - `null` - cancelled
+- **`cancel`**_`: ()=>void`_ - cancel the timeout
+- **`reset`**_`: ()=>void`_ - reset the timeout
+
diff --git a/src/__stories__/useTimeoutFn.story.tsx b/src/__stories__/useTimeoutFn.story.tsx
new file mode 100644
index 00000000..9dcb45f3
--- /dev/null
+++ b/src/__stories__/useTimeoutFn.story.tsx
@@ -0,0 +1,40 @@
+import { storiesOf } from '@storybook/react';
+import * as React from 'react';
+import { useCallback } from 'react';
+import { useTimeoutFn } from '../index';
+import ShowDocs from './util/ShowDocs';
+
+const Demo = () => {
+  const [state, setState] = React.useState('Not called yet');
+
+  function fn() {
+    setState(`called at ${Date.now()}`);
+  }
+
+  const [isReady, cancel, reset] = useTimeoutFn(fn, 5000);
+  const cancelButtonClick = useCallback(() => {
+    if (isReady() === false) {
+      cancel();
+      setState(`cancelled`);
+    } else {
+      reset();
+      setState('Not called yet');
+    }
+  }, []);
+
+  const readyState = isReady();
+
+  return (
+    <div>
+      <div>{readyState !== null ? 'Function will be called in 5 seconds' : 'Timer cancelled'}</div>
+      <button onClick={cancelButtonClick}> {readyState === false ? 'cancel' : 'restart'} timeout</button>
+      <br />
+      <div>Function state: {readyState === false ? 'Pending' : readyState ? 'Called' : 'Cancelled'}</div>
+      <div>{state}</div>
+    </div>
+  );
+};
+
+storiesOf('Animation|useTimeoutFn', module)
+  .add('Docs', () => <ShowDocs md={require('../../docs/useTimeoutFn.md')} />)
+  .add('Demo', () => <Demo />);
diff --git a/src/index.ts b/src/index.ts
index b4361a8a..644f587e 100644
--- a/src/index.ts
+++ b/src/index.ts
@@ -73,6 +73,7 @@ export { default as useStartTyping } from './useStartTyping';
 export { default as useThrottle } from './useThrottle';
 export { default as useThrottleFn } from './useThrottleFn';
 export { default as useTimeout } from './useTimeout';
+export { default as useTimeoutFn } from './useTimeoutFn';
 export { default as useTitle } from './useTitle';
 export { default as useToggle } from './useToggle';
 export { default as useTween } from './useTween';
diff --git a/src/useTimeoutFn.ts b/src/useTimeoutFn.ts
new file mode 100644
index 00000000..16276343
--- /dev/null
+++ b/src/useTimeoutFn.ts
@@ -0,0 +1,29 @@
+import { useCallback, useEffect, useRef } from 'react';
+
+export type UseTimeoutFnReturn = [() => boolean | null, () => void, () => void];
+
+export default function useTimeoutFn(fn: Function, ms: number = 0): UseTimeoutFnReturn {
+  const ready = useRef<boolean | null>(false);
+  const timeout = useRef(0);
+
+  const isReady = useCallback(() => ready.current, []);
+  const set = useCallback(() => {
+    ready.current = false;
+    timeout.current = window.setTimeout(() => {
+      ready.current = true;
+      fn();
+    }, ms);
+  }, [ms, fn]);
+  const clear = useCallback(() => {
+    ready.current = null;
+    timeout.current && clearTimeout(timeout.current);
+  }, []);
+
+  useEffect(() => {
+    set();
+
+    return clear;
+  }, [ms]);
+
+  return [isReady, clear, set];
+}


Test patch:
diff --git a/src/__tests__/useTimeoutFn.test.ts b/src/__tests__/useTimeoutFn.test.ts
new file mode 100644
index 00000000..8164bcdc
--- /dev/null
+++ b/src/__tests__/useTimeoutFn.test.ts
@@ -0,0 +1,116 @@
+import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks';
+import { useTimeoutFn } from '../index';
+import { UseTimeoutFnReturn } from '../useTimeoutFn';
+
+describe('useTimeoutFn', () => {
+  beforeAll(() => {
+    jest.useFakeTimers();
+  });
+
+  afterEach(() => {
+    jest.clearAllTimers();
+  });
+
+  afterAll(() => {
+    jest.useRealTimers();
+  });
+
+  it('should be defined', () => {
+    expect(useTimeoutFn).toBeDefined();
+  });
+
+  it('should return three functions', () => {
+    const hook = renderHook(() => useTimeoutFn(() => {}, 5));
+
+    expect(hook.result.current.length).toBe(3);
+    expect(typeof hook.result.current[0]).toBe('function');
+    expect(typeof hook.result.current[1]).toBe('function');
+    expect(typeof hook.result.current[2]).toBe('function');
+  });
+
+  function getHook(ms: number = 5): [jest.Mock, RenderHookResult<{ delay: number }, UseTimeoutFnReturn>] {
+    const spy = jest.fn();
+    return [spy, renderHook(({ delay = 5 }) => useTimeoutFn(spy, delay), { initialProps: { delay: ms } })];
+  }
+
+  it('should call passed function after given amount of time', () => {
+    const [spy] = getHook();
+
+    expect(spy).not.toHaveBeenCalled();
+    jest.advanceTimersByTime(5);
+    expect(spy).toHaveBeenCalledTimes(1);
+  });
+
+  it('should cancel function call on unmount', () => {
+    const [spy, hook] = getHook();
+
+    expect(spy).not.toHaveBeenCalled();
+    hook.unmount();
+    jest.advanceTimersByTime(5);
+    expect(spy).not.toHaveBeenCalled();
+  });
+
+  it('first function should return actual state of timeout', () => {
+    let [, hook] = getHook();
+    let [isReady] = hook.result.current;
+
+    expect(isReady()).toBe(false);
+    hook.unmount();
+    expect(isReady()).toBe(null);
+
+    [, hook] = getHook();
+    [isReady] = hook.result.current;
+    jest.advanceTimersByTime(5);
+    expect(isReady()).toBe(true);
+  });
+
+  it('second function should cancel timeout', () => {
+    const [spy, hook] = getHook();
+    const [isReady, cancel] = hook.result.current;
+
+    expect(spy).not.toHaveBeenCalled();
+    expect(isReady()).toBe(false);
+
+    act(() => {
+      cancel();
+    });
+    jest.advanceTimersByTime(5);
+
+    expect(spy).not.toHaveBeenCalled();
+    expect(isReady()).toBe(null);
+  });
+
+  it('third function should reset timeout', () => {
+    const [spy, hook] = getHook();
+    const [isReady, cancel, reset] = hook.result.current;
+
+    expect(isReady()).toBe(false);
+
+    act(() => {
+      cancel();
+    });
+    jest.advanceTimersByTime(5);
+
+    expect(isReady()).toBe(null);
+
+    act(() => {
+      reset();
+    });
+    expect(isReady()).toBe(false);
+
+    jest.advanceTimersByTime(5);
+
+    expect(spy).toHaveBeenCalledTimes(1);
+    expect(isReady()).toBe(true);
+  });
+
+  it('should reset timeout on delay change', () => {
+    const [spy, hook] = getHook(50);
+
+    expect(spy).not.toHaveBeenCalled();
+    hook.rerender({ delay: 5 });
+
+    jest.advanceTimersByTime(5);
+    expect(spy).toHaveBeenCalledTimes(1);
+  });
+});