diff --git a/README.md b/README.md index 32b706d..9edfc49 100644 --- a/README.md +++ b/README.md @@ -22,6 +22,9 @@ All hooks from this package support SSR. Some hooks use browser-only APIs, e.g. ### Table of contents - [`useCurrentPosition`](#useCurrentPosition) +- [`useDebouncedEffect`](#useDebouncedEffect) +- [`useDebouncedState`](#useDebouncedState) +- [`useDebouncedValue`](#useDebouncedValue) - [`useEventListener`](#useEventListener) - [`useIntersectionObserver`](#useIntersectionObserver) - [`useLocalStorage`](#useLocalStorage) @@ -57,6 +60,48 @@ import { useCurrentPosition } from '@wojtekmaj/react-hooks'; useCurrentPosition(); // { latitude: 0, longitude: 0 } ``` +### `useDebouncedEffect` + +Runs a given effect after a given delay. + +#### Sample usage + +```ts +import { useDebouncedEffect } from '@wojtekmaj/react-hooks'; + +useDebouncedEffect( + () => { + // This will run after 1 second of value not changing. + }, + [value], + 1000, +); +``` + +### `useDebouncedState` + +Returns a debounced state and a function to update it. + +#### Sample usage + +```ts +import { useDebouncedState } from '@wojtekmaj/react-hooks'; + +const [value, setValue] = useDebouncedState('initialState', 1000); // ['initialState', Function] +``` + +### `useDebouncedValue` + +Returns a debounced value. + +#### Sample usage + +```ts +import { useDebouncedValue } from '@wojtekmaj/react-hooks'; + +const debouncedValue = useDebouncedValue(value, 1000); // This value will be updated after 1 second of value not changing. +``` + ### `useEventListener` Adds event listener to a given element. diff --git a/src/index.spec.ts b/src/index.spec.ts index 38228f0..aedb515 100644 --- a/src/index.spec.ts +++ b/src/index.spec.ts @@ -1,6 +1,9 @@ import { describe, expect, it } from 'vitest'; import { useCurrentPosition, + useDebouncedEffect, + useDebouncedState, + useDebouncedValue, useEventListener, useIntersectionObserver, useLocalStorage, @@ -28,6 +31,18 @@ describe('index', () => { expect(useCurrentPosition).toBeInstanceOf(Function); }); + it('has useDebouncedEffect exported properly', () => { + expect(useDebouncedEffect).toBeInstanceOf(Function); + }); + + it('has useDebouncedState exported properly', () => { + expect(useDebouncedState).toBeInstanceOf(Function); + }); + + it('has useDebouncedValue exported properly', () => { + expect(useDebouncedValue).toBeInstanceOf(Function); + }); + it('has useEventListener exported properly', () => { expect(useEventListener).toBeInstanceOf(Function); }); diff --git a/src/index.ts b/src/index.ts index a21dce0..191680a 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,7 @@ import useCurrentPosition from './useCurrentPosition.js'; +import useDebouncedEffect from './useDebouncedEffect.js'; +import useDebouncedState from './useDebouncedState.js'; +import useDebouncedValue from './useDebouncedValue.js'; import useEventListener from './useEventListener.js'; import useIntersectionObserver from './useIntersectionObserver.js'; import useLocalStorage from './useLocalStorage.js'; @@ -24,6 +27,9 @@ import useWindowWidth from './useWindowWidth.js'; export { useCurrentPosition, + useDebouncedEffect, + useDebouncedState, + useDebouncedValue, useEventListener, useIntersectionObserver, useLocalStorage, diff --git a/src/useDebouncedEffect.spec.ts b/src/useDebouncedEffect.spec.ts new file mode 100644 index 0000000..ad03d5d --- /dev/null +++ b/src/useDebouncedEffect.spec.ts @@ -0,0 +1,113 @@ +import { describe, expect, it, vi } from 'vitest'; +import { renderHook, act } from '@testing-library/react-hooks'; + +import useDebouncedEffect from './useDebouncedEffect.js'; + +import type { DependencyList, EffectCallback } from 'react'; + +const itIfDocumentDefined = it.runIf(typeof document !== 'undefined'); + +vi.useFakeTimers(); + +describe('useDebouncedEffect()', () => { + itIfDocumentDefined('should call effect after debounce time on mount', () => { + const fn = vi.fn(); + + renderHook(() => useDebouncedEffect(fn, [], 500)); + + expect(fn).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + itIfDocumentDefined('should call effect after debounce time on dependency change', () => { + const fn = vi.fn(); + + const { rerender } = renderHook< + { + effect: EffectCallback; + deps: DependencyList; + }, + void + >(({ effect, deps }) => useDebouncedEffect(effect, deps, 500), { + initialProps: { effect: fn, deps: [1] }, + }); + + expect(fn).not.toHaveBeenCalled(); + + rerender({ effect: fn, deps: [2] }); + + expect(fn).not.toHaveBeenCalled(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(1); + }); + + itIfDocumentDefined('should cancel effect on unmount', () => { + const cleanup = vi.fn(); + + const fn = vi.fn().mockReturnValue(cleanup); + + const { unmount } = renderHook< + { + effect: EffectCallback; + deps: DependencyList; + }, + void + >(({ effect, deps }) => useDebouncedEffect(effect, deps, 500), { + initialProps: { effect: fn, deps: [] }, + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(cleanup).not.toHaveBeenCalled(); + + unmount(); + + expect(cleanup).toHaveBeenCalledTimes(1); + }); + + itIfDocumentDefined('should cancel and re-run effect on dependency change', () => { + const cleanup = vi.fn(); + + const fn = vi.fn().mockReturnValue(cleanup); + + const { rerender } = renderHook< + { + effect: EffectCallback; + deps: DependencyList; + }, + void + >(({ effect, deps }) => useDebouncedEffect(effect, deps, 500), { + initialProps: { effect: fn, deps: [1] }, + }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(1); + expect(cleanup).not.toHaveBeenCalled(); + + rerender({ effect: fn, deps: [2] }); + + expect(cleanup).toHaveBeenCalledTimes(1); + expect(fn).toHaveBeenCalledTimes(1); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(fn).toHaveBeenCalledTimes(2); + }); +}); diff --git a/src/useDebouncedEffect.ts b/src/useDebouncedEffect.ts new file mode 100644 index 0000000..9681eb6 --- /dev/null +++ b/src/useDebouncedEffect.ts @@ -0,0 +1,34 @@ +import { useEffect } from 'react'; + +import type { DependencyList, EffectCallback } from 'react'; + +/** + * Runs a given effect after a given delay. + * + * @param {EffectCallback} effect The effect to run + * @param {DependencyList} deps The dependencies to watch + * @param {number} [debounceTime=250] The delay to wait before running the effect + * @returns {void} + */ +export default function useDebouncedEffect( + effect: EffectCallback, + deps: DependencyList, + debounceTime = 250, +): void { + useEffect(() => { + let cleanup: ReturnType; + + const timeout = setTimeout(() => { + cleanup = effect(); + }, debounceTime); + + return () => { + clearTimeout(timeout); + + if (cleanup) { + cleanup(); + } + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, deps); +} diff --git a/src/useDebouncedState.spec.ts b/src/useDebouncedState.spec.ts new file mode 100644 index 0000000..b1739db --- /dev/null +++ b/src/useDebouncedState.spec.ts @@ -0,0 +1,42 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { describe, expect, it, vi } from 'vitest'; + +import useDebouncedState from './useDebouncedState.js'; + +const itIfDocumentDefined = it.runIf(typeof document !== 'undefined'); + +vi.useFakeTimers(); + +describe('useDebouncedState()', () => { + it('should return the initial value immediately', () => { + const { result } = renderHook(() => useDebouncedState('test', 500)); + + expect(result.current).toEqual(['test', expect.any(Function)]); + }); + + itIfDocumentDefined('should not update the value before the debounce time', () => { + const { result } = renderHook(() => useDebouncedState('test', 500)); + + const [, setValue] = result.current; + + setValue('updated'); + + expect(result.current).toEqual(['test', expect.any(Function)]); + }); + + itIfDocumentDefined('should update the value after the debounce time', () => { + const { result } = renderHook(() => useDebouncedState('test', 500)); + + const [, setValue] = result.current; + + setValue('updated'); + + vi.runAllTimers(); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toEqual(['updated', expect.any(Function)]); + }); +}); diff --git a/src/useDebouncedState.ts b/src/useDebouncedState.ts new file mode 100644 index 0000000..c157646 --- /dev/null +++ b/src/useDebouncedState.ts @@ -0,0 +1,22 @@ +import { useState } from 'react'; + +import useDebouncedValue from './useDebouncedValue.js'; + +/** + * Returns a debounced state and a function to update it. + * + * @template T + * @param {T} initialValue The initial value of the state + * @param {number} [debounceTime=250] The delay to wait before updating the state + * @returns {[T, React.Dispatch>]} The debounced state and a function to update it + */ +export default function useDebouncedState( + initialValue: T, + debounceTime = 250, +): [T, React.Dispatch>] { + const [value, setValue] = useState(initialValue); + + const debouncedValue = useDebouncedValue(value, debounceTime); + + return [debouncedValue, setValue]; +} diff --git a/src/useDebouncedValue.spec.ts b/src/useDebouncedValue.spec.ts new file mode 100644 index 0000000..bc841bc --- /dev/null +++ b/src/useDebouncedValue.spec.ts @@ -0,0 +1,40 @@ +import { renderHook, act } from '@testing-library/react-hooks'; +import { describe, expect, it, vi } from 'vitest'; + +import useDebouncedValue from './useDebouncedValue.js'; + +const itIfDocumentDefined = it.runIf(typeof document !== 'undefined'); + +vi.useFakeTimers(); + +describe('useDebouncedValue()', () => { + it('should return the initial value immediately', () => { + const { result } = renderHook(() => useDebouncedValue('test', 500)); + + expect(result.current).toBe('test'); + }); + + itIfDocumentDefined('should not update the value before the debounce time', () => { + const { result, rerender } = renderHook(({ value, time }) => useDebouncedValue(value, time), { + initialProps: { value: 'test', time: 500 }, + }); + + rerender({ value: 'updated', time: 500 }); + + expect(result.current).toBe('test'); + }); + + itIfDocumentDefined('should update the value after the debounce time', () => { + const { result, rerender } = renderHook(({ value, time }) => useDebouncedValue(value, time), { + initialProps: { value: 'test', time: 500 }, + }); + + rerender({ value: 'updated', time: 500 }); + + act(() => { + vi.advanceTimersByTime(500); + }); + + expect(result.current).toBe('updated'); + }); +}); diff --git a/src/useDebouncedValue.ts b/src/useDebouncedValue.ts new file mode 100644 index 0000000..6a4c929 --- /dev/null +++ b/src/useDebouncedValue.ts @@ -0,0 +1,21 @@ +import { useState } from 'react'; + +import useSetTimeout from './useSetTimeout.js'; + +/** + * Returns a debounced value. + * + * @template T + * @param {T} value The value to debounce + * @param {number} [debounceTime=250] The delay to wait before updating the value + * @returns {T} The debounced value + */ +export default function useDebouncedValue(value: T, debounceTime = 250): T { + const [debouncedValue, setDebouncedValue] = useState(value); + + useSetTimeout(() => { + setDebouncedValue(value); + }, debounceTime); + + return debouncedValue; +}