Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: add useDebouncedValue hook #104

Merged
merged 3 commits into from
Aug 6, 2024
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion .github/workflows/size-limit.yml
Original file line number Diff line number Diff line change
Expand Up @@ -20,4 +20,4 @@ jobs:
cache: "pnpm"
- uses: andresz1/size-limit-action@v1
with:
github_token: ${{ secrets.GH_TOKEN }}
github_token: ${{ secrets.GITHUB_TOKEN }}
37 changes: 37 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,23 @@ npm install @charlietango/hooks --save
All the hooks are exported on their own, so we don't have a barrel file with all the hooks.
This guarantees that you only import the hooks you need, and don't bloat your bundle with unused code.

### `useDebouncedValue`

Debounce a value. The value will only be updated after the delay has passed without the value changing.

```ts
import { useDebouncedValue } from "@charlietango/hooks/use-debounced-value";

const [debouncedValue, setDebouncedValue] = useDebouncedValue(
initialValue,
500,
);

setDebouncedValue("Hello");
setDebouncedValue("World");
console.log(debouncedValue); // Will log "Hello" until 500ms has passed
```

### `useDebouncedCallback`

Debounce a callback function. The callback will only be called after the delay has passed without the function being called again.
Expand All @@ -41,6 +58,26 @@ debouncedCallback("Hello");
debouncedCallback("World"); // Will only log "World" after 500ms
```

The `debouncedCallback` also contains a few methods, that can be useful:

- `flush`: Call the callback immediately, and cancel the debounce.
- `cancel`: Cancel the debounce, and the callback will never be called.
- `isPending`: Check if the callback is waiting to be called.

You can use them like this:

```tsx
const debouncedCallback = useDebouncedCallback((value: string) => {
console.log(value);
}, 500);

debouncedCallback("Hello");
debouncedCallback.isPending(); // true
debouncedCallback.flush(); // Logs "Hello"
debouncedCallback("world");
debouncedCallback.cancel(); // Will never log "world"
```

### `useElementSize`

Monitor the size of an element, and return the size object.
Expand Down
79 changes: 79 additions & 0 deletions src/__tests__/useDebouncedValue.test.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,79 @@
import { act, renderHook } from "@testing-library/react";
import { afterEach, beforeAll } from "vitest";
import { useDebouncedValue } from "../hooks/useDebouncedValue";

beforeAll(() => {
vi.useFakeTimers();
});

afterEach(() => {
// Should be no pending timers after each test
expect(vi.getTimerCount()).toBe(0);
});

test("should update the value after the delay", async () => {
const initialValue = "hello";
const { result } = renderHook(() => useDebouncedValue(initialValue, 500));

expect(result.current[0]).toBe(initialValue);
result.current[1]("world");
act(() => {
vi.runAllTimers();
});

expect(result.current[0]).toBe("world");
});

test("should skip old value", async () => {
const initialValue = "hello";
const { result } = renderHook(() => useDebouncedValue(initialValue, 500));

expect(result.current[0]).toBe(initialValue);
result.current[1]("new");
act(() => {
vi.advanceTimersByTime(250);
});

expect(result.current[0]).toBe(initialValue);

result.current[1]("world");
act(() => {
vi.runAllTimers();
});

expect(result.current[0]).toBe("world");
});

test("should update if 'initial value' is changed", async () => {
const { result, rerender } = renderHook((initialValue = "hello") =>
useDebouncedValue(initialValue, 500),
);

expect(result.current[0]).toBe("hello");
rerender("world");

act(() => {
// Should have triggered the update, when the value changes
expect(vi.getTimerCount()).toBe(1);
vi.runAllTimers();
});

expect(result.current[0]).toBe("world");
});

test("should update the value immediately if leading is true", async () => {
const initialValue = "hello";
const { result } = renderHook(() =>
useDebouncedValue(initialValue, 500, { leading: true }),
);

expect(result.current[0]).toBe(initialValue);
act(() => {
result.current[1]("world");
});
expect(result.current[0]).toBe("world");

act(() => {
vi.runAllTimers();
});
});
51 changes: 51 additions & 0 deletions src/hooks/useDebouncedValue.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { useEffect, useMemo, useRef, useState } from "react";
import { useDebouncedCallback } from "./useDebouncedCallback";

type DebounceOptions = {
/**
* If `leading`, and another callback is not pending, the value will be called immediately,
* @default false
*/
leading?: boolean;
/**
* If `trailing`, the value will be updated after the wait period
* @default true
*/
trailing?: boolean;
};

/**
* Debounce the update of a value
* @param initialValue The initial value of the debounced value
* @param wait Wait period after function hasn't been called for
* @param options {DebounceOptions} Options for the debounced callback
* @returns Array with the debounced value and a function to update the debounced value
*
* ```tsx
* const [value, setValue] = useDebouncedValue('hello', 500);
*
* setValue('world'); // Will only update the value to 'world' after 500ms
* ```
*/
export function useDebouncedValue<T>(
initialValue: T,
wait: number,
options: DebounceOptions = { trailing: true },
): [T, (value: T) => void] {
const [debouncedValue, setDebouncedValue] = useState<T>(initialValue);
const previousValueRef = useRef<T | undefined>(initialValue);

const updateDebouncedValue = useDebouncedCallback(
setDebouncedValue,
wait,
options,
);

// Update the debounced value if the initial value changes
if (previousValueRef.current !== initialValue) {
updateDebouncedValue(initialValue);
previousValueRef.current = initialValue;
}

return [debouncedValue, updateDebouncedValue];
}
Loading