diff --git a/src/hooks/useDelayState.ts b/src/hooks/useDelayState.ts new file mode 100644 index 00000000..978f7421 --- /dev/null +++ b/src/hooks/useDelayState.ts @@ -0,0 +1,62 @@ +import * as React from 'react'; +import raf from '../raf'; +import useEvent from './useEvent'; + +export type DelayConfig = + { frame: number; ms?: never } | { frame?: never; ms: number }; + +export type SetDelayState = ( + nextValue: React.SetStateAction, + /** `true` updates immediately. `false` delays the update by one frame. */ + immediatelyOrDelay?: boolean | DelayConfig, +) => void; + +type DelayInfo = + | [isRaf: true, delay: number] + | [isRaf: false, delay: ReturnType]; + +/** + * Similar to `useState`, but updates on the next frame by default. + * Pending updates are always replaced by the latest one. + */ +export default function useDelayState( + defaultValue: T | (() => T), +): [T, SetDelayState] { + const [value, setValue] = React.useState(defaultValue); + const delayRef = React.useRef(null); + + const cancelPending = useEvent(() => { + if (delayRef.current) { + const [isRaf, delay] = delayRef.current; + if (isRaf) { + raf.cancel(delay); + } else { + clearTimeout(delay); + } + delayRef.current = null; + } + }); + + const setDelayValue = useEvent>( + (nextValue, immediatelyOrDelay) => { + const delayConfig = immediatelyOrDelay || { frame: 1 }; + cancelPending(); + + if (delayConfig === true) { + setValue(nextValue); + } else if ('ms' in delayConfig) { + delayRef.current = [ + false, + setTimeout(() => setValue(nextValue), delayConfig.ms), + ]; + } else { + delayRef.current = [ + true, + raf(() => setValue(nextValue), delayConfig.frame), + ]; + } + }, + ); + + return [value, setDelayValue]; +} diff --git a/src/index.ts b/src/index.ts index 24c69d83..00665eee 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,6 +1,8 @@ export { default as useEvent } from './hooks/useEvent'; export { default as useMergedState } from './hooks/useMergedState'; export { default as useControlledState } from './hooks/useControlledState'; +export { default as useDelayState } from './hooks/useDelayState'; +export type { DelayConfig, SetDelayState } from './hooks/useDelayState'; export { default as useId, getId } from './hooks/useId'; export { default as useLayoutEffect, diff --git a/tests/useDelayState.test.tsx b/tests/useDelayState.test.tsx new file mode 100644 index 00000000..60c8131f --- /dev/null +++ b/tests/useDelayState.test.tsx @@ -0,0 +1,133 @@ +import { act, renderHook } from '@testing-library/react'; +import useDelayState from '../src/hooks/useDelayState'; + +describe('useDelayState', () => { + beforeEach(() => { + jest.useFakeTimers(); + }); + + afterEach(() => { + jest.runOnlyPendingTimers(); + jest.useRealTimers(); + }); + + it('updates on the next frame by default', () => { + const { result } = renderHook(() => useDelayState(0)); + + act(() => { + result.current[1](1); + }); + expect(result.current[0]).toBe(0); + + act(() => { + jest.advanceTimersByTime(16); + }); + expect(result.current[0]).toBe(1); + }); + + it('updates immediately', () => { + const { result } = renderHook(() => useDelayState(0)); + + act(() => { + result.current[1](1, true); + }); + expect(result.current[0]).toBe(1); + }); + + it('delays when immediate is false', () => { + const { result } = renderHook(() => useDelayState(0)); + + act(() => { + result.current[1](1, false); + }); + expect(result.current[0]).toBe(0); + + act(() => { + jest.advanceTimersByTime(16); + }); + expect(result.current[0]).toBe(1); + }); + + it('supports frame delay', () => { + const { result } = renderHook(() => useDelayState(0)); + + act(() => { + result.current[1](1, { frame: 2 }); + jest.advanceTimersByTime(16); + }); + expect(result.current[0]).toBe(0); + + act(() => { + jest.advanceTimersByTime(16); + }); + expect(result.current[0]).toBe(1); + }); + + it('supports millisecond delay', () => { + const { result } = renderHook(() => useDelayState(0)); + + act(() => { + result.current[1](1, { ms: 100 }); + jest.advanceTimersByTime(99); + }); + expect(result.current[0]).toBe(0); + + act(() => { + jest.advanceTimersByTime(1); + }); + expect(result.current[0]).toBe(1); + }); + + it('uses the latest pending update', () => { + const { result } = renderHook(() => useDelayState(0)); + + act(() => { + result.current[1](1, { ms: 100 }); + result.current[1](2); + jest.advanceTimersByTime(16); + }); + expect(result.current[0]).toBe(2); + + act(() => { + jest.advanceTimersByTime(100); + }); + expect(result.current[0]).toBe(2); + + act(() => { + result.current[1](3, { frame: 2 }); + result.current[1](4, { ms: 100 }); + jest.advanceTimersByTime(32); + }); + expect(result.current[0]).toBe(2); + + act(() => { + jest.advanceTimersByTime(68); + }); + expect(result.current[0]).toBe(4); + }); + + it('cancels a pending update when updating immediately', () => { + const { result } = renderHook(() => useDelayState(0)); + + act(() => { + result.current[1](1, { frame: 2 }); + result.current[1](2, true); + }); + expect(result.current[0]).toBe(2); + + act(() => { + jest.advanceTimersByTime(32); + }); + expect(result.current[0]).toBe(2); + }); + + it('supports updater function', () => { + const { result } = renderHook(() => useDelayState(1)); + + act(() => { + result.current[1](value => value + 1); + jest.advanceTimersByTime(16); + }); + expect(result.current[0]).toBe(2); + }); +});