Skip to content

Commit 26487d3

Browse files
committed
feat: add useDebouncedValue hook
1 parent 0183c78 commit 26487d3

File tree

3 files changed

+147
-0
lines changed

3 files changed

+147
-0
lines changed

README.md

+17
Original file line numberDiff line numberDiff line change
@@ -26,6 +26,23 @@ npm install @charlietango/hooks --save
2626
All the hooks are exported on their own, so we don't have a barrel file with all the hooks.
2727
This guarantees that you only import the hooks you need, and don't bloat your bundle with unused code.
2828

29+
### `useDebouncedValue`
30+
31+
Debounce a value. The value will only be updated after the delay has passed without the value changing.
32+
33+
```ts
34+
import { useDebouncedValue } from "@charlietango/hooks/use-debounced-value";
35+
36+
const [debouncedValue, setDebouncedValue] = useDebouncedValue(
37+
initialValue,
38+
500,
39+
);
40+
41+
setDebouncedValue("Hello");
42+
setDebouncedValue("World");
43+
console.log(debouncedValue); // Will log "Hello" until 500ms has passed
44+
```
45+
2946
### `useDebouncedCallback`
3047

3148
Debounce a callback function. The callback will only be called after the delay has passed without the function being called again.
+79
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,79 @@
1+
import { act, renderHook } from "@testing-library/react";
2+
import { afterEach, beforeAll } from "vitest";
3+
import { useDebouncedValue } from "../hooks/useDebouncedValue";
4+
5+
beforeAll(() => {
6+
vi.useFakeTimers();
7+
});
8+
9+
afterEach(() => {
10+
// Should be no pending timers after each test
11+
expect(vi.getTimerCount()).toBe(0);
12+
});
13+
14+
test("should update the value after the delay", async () => {
15+
const initialValue = "hello";
16+
const { result } = renderHook(() => useDebouncedValue(initialValue, 500));
17+
18+
expect(result.current[0]).toBe(initialValue);
19+
result.current[1]("world");
20+
act(() => {
21+
vi.runAllTimers();
22+
});
23+
24+
expect(result.current[0]).toBe("world");
25+
});
26+
27+
test("should skip old value", async () => {
28+
const initialValue = "hello";
29+
const { result } = renderHook(() => useDebouncedValue(initialValue, 500));
30+
31+
expect(result.current[0]).toBe(initialValue);
32+
result.current[1]("new");
33+
act(() => {
34+
vi.advanceTimersByTime(250);
35+
});
36+
37+
expect(result.current[0]).toBe(initialValue);
38+
39+
result.current[1]("world");
40+
act(() => {
41+
vi.runAllTimers();
42+
});
43+
44+
expect(result.current[0]).toBe("world");
45+
});
46+
47+
test("should update if 'initial value' is changed", async () => {
48+
const { result, rerender } = renderHook((initialValue = "hello") =>
49+
useDebouncedValue(initialValue, 500),
50+
);
51+
52+
expect(result.current[0]).toBe("hello");
53+
rerender("world");
54+
55+
act(() => {
56+
// Should have triggered the update, when the value changes
57+
expect(vi.getTimerCount()).toBe(1);
58+
vi.runAllTimers();
59+
});
60+
61+
expect(result.current[0]).toBe("world");
62+
});
63+
64+
test("should update the value immediately if leading is true", async () => {
65+
const initialValue = "hello";
66+
const { result } = renderHook(() =>
67+
useDebouncedValue(initialValue, 500, { leading: true }),
68+
);
69+
70+
expect(result.current[0]).toBe(initialValue);
71+
act(() => {
72+
result.current[1]("world");
73+
});
74+
expect(result.current[0]).toBe("world");
75+
76+
act(() => {
77+
vi.runAllTimers();
78+
});
79+
});

src/hooks/useDebouncedValue.ts

+51
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,51 @@
1+
import { useEffect, useMemo, useRef, useState } from "react";
2+
import { useDebouncedCallback } from "./useDebouncedCallback";
3+
4+
type DebounceOptions = {
5+
/**
6+
* If `leading`, and another callback is not pending, the value will be called immediately,
7+
* @default false
8+
*/
9+
leading?: boolean;
10+
/**
11+
* If `trailing`, the value will be updated after the wait period
12+
* @default true
13+
*/
14+
trailing?: boolean;
15+
};
16+
17+
/**
18+
* Debounce the update of a value
19+
* @param initialValue The initial value of the debounced value
20+
* @param wait Wait period after function hasn't been called for
21+
* @param options {DebounceOptions} Options for the debounced callback
22+
* @returns Array with the debounced value and a function to update the debounced value
23+
*
24+
* ```tsx
25+
* const [value, setValue] = useDebouncedValue('hello', 500);
26+
*
27+
* setValue('world'); // Will only update the value to 'world' after 500ms
28+
* ```
29+
*/
30+
export function useDebouncedValue<T>(
31+
initialValue: T,
32+
wait: number,
33+
options: DebounceOptions = { trailing: true },
34+
): [T, (value: T) => void] {
35+
const [debouncedValue, setDebouncedValue] = useState<T>(initialValue);
36+
const previousValueRef = useRef<T | undefined>(initialValue);
37+
38+
const updateDebouncedValue = useDebouncedCallback(
39+
setDebouncedValue,
40+
wait,
41+
options,
42+
);
43+
44+
// Update the debounced value if the initial value changes
45+
if (previousValueRef.current !== initialValue) {
46+
updateDebouncedValue(initialValue);
47+
previousValueRef.current = initialValue;
48+
}
49+
50+
return [debouncedValue, updateDebouncedValue];
51+
}

0 commit comments

Comments
 (0)