From 210ea60595122afd775014eacdbe58826d4df7d6 Mon Sep 17 00:00:00 2001 From: xobotyi Date: Tue, 20 Aug 2019 10:01:11 +0300 Subject: [PATCH] useTimeoutFn implementation; --- README.md | 1 + docs/useTimeoutFn.md | 65 ++++++++++++++ src/__stories__/useTimeoutFn.story.tsx | 40 +++++++++ src/__tests__/useTimeoutFn.test.ts | 116 +++++++++++++++++++++++++ src/index.ts | 1 + src/useTimeoutFn.ts | 29 +++++++ 6 files changed, 252 insertions(+) create mode 100644 docs/useTimeoutFn.md create mode 100644 src/__stories__/useTimeoutFn.story.tsx create mode 100644 src/__tests__/useTimeoutFn.test.ts create mode 100644 src/useTimeoutFn.ts 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) — re-renders component on a set interval using `setInterval`. - [`useSpring`](./docs/useSpring.md) — interpolates number over time according to spring dynamics. - [`useTimeout`](./docs/useTimeout.md) — returns true after a timeout. + - [`useTimeoutFn`](./docs/useTimeoutFn.md) — calls given function after a timeout. [![][img-demo]](https://streamich.github.io/react-use/?path=/story/animation-usetimeoutfn--demo) - [`useTween`](./docs/useTween.md) — re-renders component, while tweening a number from 0 to 1. [![][img-demo]](https://codesandbox.io/s/52990wwzyl) - [`useUpdate`](./docs/useUpdate.md) — returns a callback, which re-renders component when called.
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 ( +
+
{readyState !== null ? 'Function will be called in 5 seconds' : 'Timer cancelled'}
+ +
+
Function state: {readyState === false ? 'Pending' : readyState ? 'Called' : 'Cancelled'}
+
{state}
+
+ ); +}; +``` + +## 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 ( +
+
{readyState !== null ? 'Function will be called in 5 seconds' : 'Timer cancelled'}
+ +
+
Function state: {readyState === false ? 'Pending' : readyState ? 'Called' : 'Cancelled'}
+
{state}
+
+ ); +}; + +storiesOf('Animation|useTimeoutFn', module) + .add('Docs', () => ) + .add('Demo', () => ); 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); + }); +}); 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(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]; +}