From d4164998e201b3fa895226d2016439a670744adb Mon Sep 17 00:00:00 2001 From: xobotyi Date: Wed, 21 Aug 2019 19:30:46 +0300 Subject: [PATCH] useTimeout: - `useTimeoutFn` based implementation (as a special case of it); - improved docs; - added tests; --- README.md | 2 +- docs/useTimeout.md | 41 +++++++- src/__stories__/useTimeout.story.tsx | 21 +++- src/__tests__/useTimeout.test.ts | 138 +++++++++++++++++++++++++++ src/useTimeout.ts | 23 ++--- 5 files changed, 201 insertions(+), 24 deletions(-) create mode 100644 src/__tests__/useTimeout.test.ts diff --git a/README.md b/README.md index a812c06211..8e36339e0d 100644 --- a/README.md +++ b/README.md @@ -81,7 +81,7 @@ - [`useRaf`](./docs/useRaf.md) — re-renders component on each `requestAnimationFrame`. - [`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. + - [`useTimeout`](./docs/useTimeout.md) — re-renders component 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/useTimeout.md b/docs/useTimeout.md index 4c4c96e217..6c09e62c7d 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 ( +
+ { isReady() ? 'I\'m reloaded after timeout' : `I will be reloaded after ${ ms / 1000 }s` } + { isReady() === false ? : '' } +
+ ); +} - return
Ready: {ready ? 'Yes' : 'No'}
; +const Demo = () => { + return ( +
+ + +
+ ); }; ``` + +## 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/src/__stories__/useTimeout.story.tsx b/src/__stories__/useTimeout.story.tsx index a393ee21f7..99fda16a66 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 ( +
+ {isReady() ? "I'm reloaded after timeout" : `I will be reloaded after ${ms / 1000}s`} + {isReady() === false ? : ''} +
+ ); +} - return
Ready: {ready ? 'Yes' : 'No'}
; +const Demo = () => { + return ( +
+ + +
+ ); }; storiesOf('Animation|useTimeout', module) diff --git a/src/__tests__/useTimeout.test.ts b/src/__tests__/useTimeout.test.ts new file mode 100644 index 0000000000..905b40221b --- /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/useTimeout.ts b/src/useTimeout.ts index 84c7a143c8..387a7c768d 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); +}