Skip to content

Commit 25060d9

Browse files
committed
feat: add useDelayState hook
1 parent 27a3007 commit 25060d9

3 files changed

Lines changed: 205 additions & 0 deletions

File tree

src/hooks/useDelayState.ts

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
import * as React from 'react';
2+
import raf from '../raf';
3+
import useEvent from './useEvent';
4+
5+
export type DelayConfig =
6+
{ frame: number; ms?: never } | { frame?: never; ms: number };
7+
8+
export type SetDelayState<T> = (
9+
nextValue: React.SetStateAction<T>,
10+
/** `true` updates immediately. `false` delays the update by one frame. */
11+
immediatelyOrDelay?: boolean | DelayConfig,
12+
) => void;
13+
14+
/**
15+
* Similar to `useState`, but updates on the next frame by default.
16+
* Pending updates are always replaced by the latest one.
17+
*/
18+
export default function useDelayState<T>(
19+
defaultValue: T | (() => T),
20+
): [T, SetDelayState<T>] {
21+
const [value, setValue] = React.useState(defaultValue);
22+
const rafRef = React.useRef<number>(null);
23+
const timeoutRef = React.useRef<ReturnType<typeof setTimeout>>(null);
24+
25+
const cancelPending = useEvent(() => {
26+
raf.cancel(rafRef.current!);
27+
clearTimeout(timeoutRef.current!);
28+
rafRef.current = null;
29+
timeoutRef.current = null;
30+
});
31+
32+
const setDelayValue = useEvent<SetDelayState<T>>(
33+
(nextValue, immediatelyOrDelay) => {
34+
cancelPending();
35+
36+
if (immediatelyOrDelay === true) {
37+
setValue(nextValue);
38+
} else if (
39+
typeof immediatelyOrDelay === 'object' &&
40+
'ms' in immediatelyOrDelay
41+
) {
42+
timeoutRef.current = setTimeout(
43+
() => setValue(nextValue),
44+
immediatelyOrDelay.ms,
45+
);
46+
} else {
47+
const frame =
48+
typeof immediatelyOrDelay === 'object'
49+
? immediatelyOrDelay.frame
50+
: undefined;
51+
rafRef.current = raf(() => setValue(nextValue), frame);
52+
}
53+
},
54+
);
55+
56+
React.useEffect(() => cancelPending, [cancelPending]);
57+
58+
return [value, setDelayValue];
59+
}

src/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,8 @@
11
export { default as useEvent } from './hooks/useEvent';
22
export { default as useMergedState } from './hooks/useMergedState';
33
export { default as useControlledState } from './hooks/useControlledState';
4+
export { default as useDelayState } from './hooks/useDelayState';
5+
export type { DelayConfig, SetDelayState } from './hooks/useDelayState';
46
export { default as useId, getId } from './hooks/useId';
57
export {
68
default as useLayoutEffect,

tests/useDelayState.test.tsx

Lines changed: 144 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,144 @@
1+
import { act, renderHook } from '@testing-library/react';
2+
import useDelayState from '../src/hooks/useDelayState';
3+
4+
describe('useDelayState', () => {
5+
beforeEach(() => {
6+
jest.useFakeTimers();
7+
});
8+
9+
afterEach(() => {
10+
jest.runOnlyPendingTimers();
11+
jest.useRealTimers();
12+
});
13+
14+
it('updates on the next frame by default', () => {
15+
const { result } = renderHook(() => useDelayState(0));
16+
17+
act(() => {
18+
result.current[1](1);
19+
});
20+
expect(result.current[0]).toBe(0);
21+
22+
act(() => {
23+
jest.advanceTimersByTime(16);
24+
});
25+
expect(result.current[0]).toBe(1);
26+
});
27+
28+
it('updates immediately', () => {
29+
const { result } = renderHook(() => useDelayState(0));
30+
31+
act(() => {
32+
result.current[1](1, true);
33+
});
34+
expect(result.current[0]).toBe(1);
35+
});
36+
37+
it('delays when immediate is false', () => {
38+
const { result } = renderHook(() => useDelayState(0));
39+
40+
act(() => {
41+
result.current[1](1, false);
42+
});
43+
expect(result.current[0]).toBe(0);
44+
45+
act(() => {
46+
jest.advanceTimersByTime(16);
47+
});
48+
expect(result.current[0]).toBe(1);
49+
});
50+
51+
it('supports frame delay', () => {
52+
const { result } = renderHook(() => useDelayState(0));
53+
54+
act(() => {
55+
result.current[1](1, { frame: 2 });
56+
jest.advanceTimersByTime(16);
57+
});
58+
expect(result.current[0]).toBe(0);
59+
60+
act(() => {
61+
jest.advanceTimersByTime(16);
62+
});
63+
expect(result.current[0]).toBe(1);
64+
});
65+
66+
it('supports millisecond delay', () => {
67+
const { result } = renderHook(() => useDelayState(0));
68+
69+
act(() => {
70+
result.current[1](1, { ms: 100 });
71+
jest.advanceTimersByTime(99);
72+
});
73+
expect(result.current[0]).toBe(0);
74+
75+
act(() => {
76+
jest.advanceTimersByTime(1);
77+
});
78+
expect(result.current[0]).toBe(1);
79+
});
80+
81+
it('uses the latest pending update', () => {
82+
const { result } = renderHook(() => useDelayState(0));
83+
84+
act(() => {
85+
result.current[1](1, { ms: 100 });
86+
result.current[1](2);
87+
jest.advanceTimersByTime(16);
88+
});
89+
expect(result.current[0]).toBe(2);
90+
91+
act(() => {
92+
jest.advanceTimersByTime(100);
93+
});
94+
expect(result.current[0]).toBe(2);
95+
96+
act(() => {
97+
result.current[1](3, { frame: 2 });
98+
result.current[1](4, { ms: 100 });
99+
jest.advanceTimersByTime(32);
100+
});
101+
expect(result.current[0]).toBe(2);
102+
103+
act(() => {
104+
jest.advanceTimersByTime(68);
105+
});
106+
expect(result.current[0]).toBe(4);
107+
});
108+
109+
it('cancels a pending update when updating immediately', () => {
110+
const { result } = renderHook(() => useDelayState(0));
111+
112+
act(() => {
113+
result.current[1](1, { frame: 2 });
114+
result.current[1](2, true);
115+
});
116+
expect(result.current[0]).toBe(2);
117+
118+
act(() => {
119+
jest.advanceTimersByTime(32);
120+
});
121+
expect(result.current[0]).toBe(2);
122+
});
123+
124+
it('supports updater function', () => {
125+
const { result } = renderHook(() => useDelayState(1));
126+
127+
act(() => {
128+
result.current[1](value => value + 1);
129+
jest.advanceTimersByTime(16);
130+
});
131+
expect(result.current[0]).toBe(2);
132+
});
133+
134+
it('cancels pending update on unmount', () => {
135+
const { result, unmount } = renderHook(() => useDelayState(0));
136+
137+
act(() => {
138+
result.current[1](1, { ms: 100 });
139+
});
140+
unmount();
141+
142+
expect(jest.getTimerCount()).toBe(0);
143+
});
144+
});

0 commit comments

Comments
 (0)