From 25060d95c6d3785c2bae33b510309c372be73a6c Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Jul 2026 17:21:15 +0800 Subject: [PATCH 1/5] feat: add useDelayState hook --- src/hooks/useDelayState.ts | 59 ++++++++++++++ src/index.ts | 2 + tests/useDelayState.test.tsx | 144 +++++++++++++++++++++++++++++++++++ 3 files changed, 205 insertions(+) create mode 100644 src/hooks/useDelayState.ts create mode 100644 tests/useDelayState.test.tsx diff --git a/src/hooks/useDelayState.ts b/src/hooks/useDelayState.ts new file mode 100644 index 00000000..8e78f14a --- /dev/null +++ b/src/hooks/useDelayState.ts @@ -0,0 +1,59 @@ +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; + +/** + * 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 rafRef = React.useRef(null); + const timeoutRef = React.useRef>(null); + + const cancelPending = useEvent(() => { + raf.cancel(rafRef.current!); + clearTimeout(timeoutRef.current!); + rafRef.current = null; + timeoutRef.current = null; + }); + + const setDelayValue = useEvent>( + (nextValue, immediatelyOrDelay) => { + cancelPending(); + + if (immediatelyOrDelay === true) { + setValue(nextValue); + } else if ( + typeof immediatelyOrDelay === 'object' && + 'ms' in immediatelyOrDelay + ) { + timeoutRef.current = setTimeout( + () => setValue(nextValue), + immediatelyOrDelay.ms, + ); + } else { + const frame = + typeof immediatelyOrDelay === 'object' + ? immediatelyOrDelay.frame + : undefined; + rafRef.current = raf(() => setValue(nextValue), frame); + } + }, + ); + + React.useEffect(() => cancelPending, [cancelPending]); + + 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..d289b132 --- /dev/null +++ b/tests/useDelayState.test.tsx @@ -0,0 +1,144 @@ +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); + }); + + it('cancels pending update on unmount', () => { + const { result, unmount } = renderHook(() => useDelayState(0)); + + act(() => { + result.current[1](1, { ms: 100 }); + }); + unmount(); + + expect(jest.getTimerCount()).toBe(0); + }); +}); From dd526d07b8373490f5b28329bac55ed1c3af29f1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Jul 2026 17:35:40 +0800 Subject: [PATCH 2/5] fix: guard pending delay handles --- src/hooks/useDelayState.ts | 16 ++++++++++------ 1 file changed, 10 insertions(+), 6 deletions(-) diff --git a/src/hooks/useDelayState.ts b/src/hooks/useDelayState.ts index 8e78f14a..6a7968df 100644 --- a/src/hooks/useDelayState.ts +++ b/src/hooks/useDelayState.ts @@ -19,14 +19,18 @@ export default function useDelayState( defaultValue: T | (() => T), ): [T, SetDelayState] { const [value, setValue] = React.useState(defaultValue); - const rafRef = React.useRef(null); - const timeoutRef = React.useRef>(null); + const rafRef = React.useRef(null); + const timeoutRef = React.useRef | null>(null); const cancelPending = useEvent(() => { - raf.cancel(rafRef.current!); - clearTimeout(timeoutRef.current!); - rafRef.current = null; - timeoutRef.current = null; + if (rafRef.current !== null) { + raf.cancel(rafRef.current); + rafRef.current = null; + } + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + timeoutRef.current = null; + } }); const setDelayValue = useEvent>( From d1486a00d0f6dc8b331d24f6d5cb0dc56971da40 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Jul 2026 17:41:24 +0800 Subject: [PATCH 3/5] refactor: simplify delayed state cleanup --- src/hooks/useDelayState.ts | 30 ++++++++++++++---------------- tests/useDelayState.test.tsx | 11 ----------- 2 files changed, 14 insertions(+), 27 deletions(-) diff --git a/src/hooks/useDelayState.ts b/src/hooks/useDelayState.ts index 6a7968df..4d9b8066 100644 --- a/src/hooks/useDelayState.ts +++ b/src/hooks/useDelayState.ts @@ -19,17 +19,17 @@ export default function useDelayState( defaultValue: T | (() => T), ): [T, SetDelayState] { const [value, setValue] = React.useState(defaultValue); - const rafRef = React.useRef(null); - const timeoutRef = React.useRef | null>(null); + const delayRef = React.useRef<[isRaf: boolean, delay: number] | null>(null); const cancelPending = useEvent(() => { - if (rafRef.current !== null) { - raf.cancel(rafRef.current); - rafRef.current = null; - } - if (timeoutRef.current !== null) { - clearTimeout(timeoutRef.current); - timeoutRef.current = null; + if (delayRef.current) { + const [isRaf, delay] = delayRef.current; + if (isRaf) { + raf.cancel(delay); + } else { + clearTimeout(delay); + } + delayRef.current = null; } }); @@ -43,21 +43,19 @@ export default function useDelayState( typeof immediatelyOrDelay === 'object' && 'ms' in immediatelyOrDelay ) { - timeoutRef.current = setTimeout( - () => setValue(nextValue), - immediatelyOrDelay.ms, - ); + delayRef.current = [ + false, + window.setTimeout(() => setValue(nextValue), immediatelyOrDelay.ms), + ]; } else { const frame = typeof immediatelyOrDelay === 'object' ? immediatelyOrDelay.frame : undefined; - rafRef.current = raf(() => setValue(nextValue), frame); + delayRef.current = [true, raf(() => setValue(nextValue), frame)]; } }, ); - React.useEffect(() => cancelPending, [cancelPending]); - return [value, setDelayValue]; } diff --git a/tests/useDelayState.test.tsx b/tests/useDelayState.test.tsx index d289b132..60c8131f 100644 --- a/tests/useDelayState.test.tsx +++ b/tests/useDelayState.test.tsx @@ -130,15 +130,4 @@ describe('useDelayState', () => { }); expect(result.current[0]).toBe(2); }); - - it('cancels pending update on unmount', () => { - const { result, unmount } = renderHook(() => useDelayState(0)); - - act(() => { - result.current[1](1, { ms: 100 }); - }); - unmount(); - - expect(jest.getTimerCount()).toBe(0); - }); }); From b1f5de3a2b74e379bc1906e37fb10058ca5ebca1 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Jul 2026 17:50:56 +0800 Subject: [PATCH 4/5] refactor: normalize delay config --- src/hooks/useDelayState.ts | 19 ++++++++----------- 1 file changed, 8 insertions(+), 11 deletions(-) diff --git a/src/hooks/useDelayState.ts b/src/hooks/useDelayState.ts index 4d9b8066..2b1c7a5f 100644 --- a/src/hooks/useDelayState.ts +++ b/src/hooks/useDelayState.ts @@ -35,24 +35,21 @@ export default function useDelayState( const setDelayValue = useEvent>( (nextValue, immediatelyOrDelay) => { + const delayConfig = immediatelyOrDelay || { frame: 1 }; cancelPending(); - if (immediatelyOrDelay === true) { + if (delayConfig === true) { setValue(nextValue); - } else if ( - typeof immediatelyOrDelay === 'object' && - 'ms' in immediatelyOrDelay - ) { + } else if ('ms' in delayConfig) { delayRef.current = [ false, - window.setTimeout(() => setValue(nextValue), immediatelyOrDelay.ms), + window.setTimeout(() => setValue(nextValue), delayConfig.ms), ]; } else { - const frame = - typeof immediatelyOrDelay === 'object' - ? immediatelyOrDelay.frame - : undefined; - delayRef.current = [true, raf(() => setValue(nextValue), frame)]; + delayRef.current = [ + true, + raf(() => setValue(nextValue), delayConfig.frame), + ]; } }, ); From 1ab58e529ff355ceabe3b077db5ba8e21999f932 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=E4=BA=8C=E8=B4=A7=E6=9C=BA=E5=99=A8=E4=BA=BA?= Date: Mon, 13 Jul 2026 17:53:19 +0800 Subject: [PATCH 5/5] fix: use global timeout handle type --- src/hooks/useDelayState.ts | 8 ++++++-- 1 file changed, 6 insertions(+), 2 deletions(-) diff --git a/src/hooks/useDelayState.ts b/src/hooks/useDelayState.ts index 2b1c7a5f..978f7421 100644 --- a/src/hooks/useDelayState.ts +++ b/src/hooks/useDelayState.ts @@ -11,6 +11,10 @@ export type SetDelayState = ( 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. @@ -19,7 +23,7 @@ export default function useDelayState( defaultValue: T | (() => T), ): [T, SetDelayState] { const [value, setValue] = React.useState(defaultValue); - const delayRef = React.useRef<[isRaf: boolean, delay: number] | null>(null); + const delayRef = React.useRef(null); const cancelPending = useEvent(() => { if (delayRef.current) { @@ -43,7 +47,7 @@ export default function useDelayState( } else if ('ms' in delayConfig) { delayRef.current = [ false, - window.setTimeout(() => setValue(nextValue), delayConfig.ms), + setTimeout(() => setValue(nextValue), delayConfig.ms), ]; } else { delayRef.current = [