Repository: streamich/react-use
Pull request #549 (2019-08-21 16:32:35)

feat: useTimeout add cancel and reset handles
useTimeout:
- added cancel method;
- added reset method;
- improved docs;
- added tests;


Patch:
diff --git a/README.md b/README.md
index d24873e0..8e36339e 100644
--- a/README.md
+++ b/README.md
@@ -81,7 +81,8 @@
   - [`useRaf`](./docs/useRaf.md) &mdash; re-renders component on each `requestAnimationFrame`.
   - [`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.
+  - [`useTimeout`](./docs/useTimeout.md) &mdash; re-renders component 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/useTimeout.md b/docs/useTimeout.md
index 4c4c96e2..6c09e62c 100644
--- a/docs/useTimeout.md
+++ b/docs/useTimeout.md
@@ -1,15 +1,48 @@
 # `useTimeout`
 
-Returns `true` after a specified number of milliseconds.
+Re-renders the component after a specified number of milliseconds.  
+Provides handles to cancel and/or reset the timeout.
 
 ## Usage
 
 ```jsx
 import { useTimeout } from 'react-use';
 
-const Demo = () => {
-  const ready = useTimeout(2000);
+function TestComponent(props: { ms?: number } = {}) {
+  const ms = props.ms || 5000;
+  const [isReady, cancel] = useTimeout(ms);
+
+  return (
+    <div>
+      { isReady() ? 'I\'m reloaded after timeout' : `I will be reloaded after ${ ms / 1000 }s` }
+      { isReady() === false ? <button onClick={ cancel }>Cancel</button> : '' }
+    </div>
+  );
+}
 
-  return <div>Ready: {ready ? 'Yes' : 'No'}</div>;
+const Demo = () => {
+  return (
+    <div>
+      <TestComponent />
+      <TestComponent ms={ 10000 } />
+    </div>
+  );
 };
 ```
+
+## Reference
+
+```ts 
+const [
+    isReady: () => boolean | null,
+    cancel: () => void,
+    reset: () => void,
+] = useTimeout(ms: number = 0);
+```
+
+- **`isReady`**_` :()=>boolean|null`_ - function returning current timeout state:
+    - `false` - pending re-render
+    - `true` - re-render performed
+    - `null` - re-render cancelled
+- **`cancel`**_` :()=>void`_ - cancel the timeout (component will not be re-rendered)
+- **`reset`**_` :()=>void`_ - reset the timeout
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__/useTimeout.story.tsx b/src/__stories__/useTimeout.story.tsx
index a393ee21..99fda16a 100644
--- a/src/__stories__/useTimeout.story.tsx
+++ b/src/__stories__/useTimeout.story.tsx
@@ -3,10 +3,25 @@ import * as React from 'react';
 import { useTimeout } from '..';
 import ShowDocs from './util/ShowDocs';
 
-const Demo = () => {
-  const ready = useTimeout(2e3);
+function TestComponent(props: { ms?: number } = {}) {
+  const ms = props.ms || 5000;
+  const [isReady, cancel] = useTimeout(ms);
+
+  return (
+    <div>
+      {isReady() ? "I'm reloaded after timeout" : `I will be reloaded after ${ms / 1000}s`}
+      {isReady() === false ? <button onClick={cancel}>Cancel</button> : ''}
+    </div>
+  );
+}
 
-  return <div>Ready: {ready ? 'Yes' : 'No'}</div>;
+const Demo = () => {
+  return (
+    <div>
+      <TestComponent />
+      <TestComponent ms={10000} />
+    </div>
+  );
 };
 
 storiesOf('Animation|useTimeout', module)
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/useTimeout.ts b/src/useTimeout.ts
index 84c7a143..387a7c76 100644
--- a/src/useTimeout.ts
+++ b/src/useTimeout.ts
@@ -1,19 +1,10 @@
-import { useEffect, useState } from 'react';
+import useTimeoutFn from './useTimeoutFn';
+import useUpdate from './useUpdate';
 
-const useTimeout = (ms: number = 0) => {
-  const [ready, setReady] = useState(false);
+export type UseTimeoutReturn = [() => boolean | null, () => void, () => void];
 
-  useEffect(() => {
-    const timer = setTimeout(() => {
-      setReady(true);
-    }, ms);
+export default function useTimeout(ms: number = 0): UseTimeoutReturn {
+  const update = useUpdate();
 
-    return () => {
-      clearTimeout(timer);
-    };
-  }, [ms]);
-
-  return ready;
-};
-
-export default useTimeout;
+  return useTimeoutFn(update, ms);
+}
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__/useTimeout.test.ts b/src/__tests__/useTimeout.test.ts
new file mode 100644
index 00000000..905b4022
--- /dev/null
+++ b/src/__tests__/useTimeout.test.ts
@@ -0,0 +1,138 @@
+import { act, renderHook, RenderHookResult } from '@testing-library/react-hooks';
+import { useTimeout } from '../index';
+import { UseTimeoutReturn } from '../useTimeout';
+
+describe('useTimeout', () => {
+  beforeAll(() => {
+    jest.useFakeTimers();
+  });
+
+  afterEach(() => {
+    jest.clearAllTimers();
+  });
+
+  afterAll(() => {
+    jest.useRealTimers();
+  });
+
+  it('should be defined', () => {
+    expect(useTimeout).toBeDefined();
+  });
+
+  it('should return three functions', () => {
+    const hook = renderHook(() => useTimeout(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 }, UseTimeoutReturn>] {
+    const spy = jest.fn();
+    return [
+      spy,
+      renderHook(
+        ({ delay = 5 }) => {
+          spy();
+          return useTimeout(delay);
+        },
+        { initialProps: { delay: ms } }
+      ),
+    ];
+  }
+
+  it('should re-render component after given amount of time', done => {
+    const [spy, hook] = getHook();
+    expect(spy).toHaveBeenCalledTimes(1);
+    hook.waitForNextUpdate().then(() => {
+      expect(spy).toHaveBeenCalledTimes(2);
+      done();
+    });
+    jest.advanceTimersByTime(5);
+  });
+
+  it('should cancel timeout on unmount', () => {
+    const [spy, hook] = getHook();
+
+    expect(spy).toHaveBeenCalledTimes(1);
+    hook.unmount();
+    jest.advanceTimersByTime(5);
+    expect(spy).toHaveBeenCalledTimes(1);
+  });
+
+  it('first function should return actual state of timeout', done => {
+    let [, hook] = getHook();
+    let [isReady] = hook.result.current;
+
+    expect(isReady()).toBe(false);
+    hook.unmount();
+    expect(isReady()).toBe(null);
+
+    [, hook] = getHook();
+    [isReady] = hook.result.current;
+    hook.waitForNextUpdate().then(() => {
+      expect(isReady()).toBe(true);
+
+      done();
+    });
+    jest.advanceTimersByTime(5);
+  });
+
+  it('second function should cancel timeout', () => {
+    const [spy, hook] = getHook();
+    const [isReady, cancel] = hook.result.current;
+
+    expect(spy).toHaveBeenCalledTimes(1);
+    expect(isReady()).toBe(false);
+
+    act(() => {
+      cancel();
+    });
+    jest.advanceTimersByTime(5);
+
+    expect(spy).toHaveBeenCalledTimes(1);
+    expect(isReady()).toBe(null);
+  });
+
+  it('third function should reset timeout', done => {
+    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);
+
+    hook.waitForNextUpdate().then(() => {
+      expect(spy).toHaveBeenCalledTimes(2);
+      expect(isReady()).toBe(true);
+
+      done();
+    });
+    jest.advanceTimersByTime(5);
+  });
+
+  it('should reset timeout on delay change', done => {
+    const [spy, hook] = getHook(15);
+
+    expect(spy).toHaveBeenCalledTimes(1);
+    hook.rerender({ delay: 5 });
+
+    hook.waitForNextUpdate().then(() => {
+      expect(spy).toHaveBeenCalledTimes(3);
+
+      done();
+    });
+    jest.advanceTimersByTime(15);
+  });
+});
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);
+  });
+});