Skip to content

Commit

Permalink
Add useDebounceFn hook (#318)
Browse files Browse the repository at this point in the history
  • Loading branch information
juancwu authored Dec 31, 2024
2 parents c90299e + 9739fb9 commit d8b365d
Show file tree
Hide file tree
Showing 3 changed files with 210 additions and 0 deletions.
1 change: 1 addition & 0 deletions frontend/src/hooks/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export { useDebounceFn } from './useDebounceFn';
148 changes: 148 additions & 0 deletions frontend/src/hooks/tests/useDebounceFn.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
import { renderHook, act } from '@testing-library/react';
import { vi, expect, beforeEach, afterEach, describe, it } from 'vitest';
import { useDebounceFn } from '@/hooks';

describe('useDebounceFn', () => {
beforeEach(() => {
vi.useFakeTimers();
});

afterEach(() => {
vi.restoreAllMocks();
});

it('should debounce the callback function', () => {
const callback = vi.fn();
const { result } = renderHook(() => useDebounceFn(callback, 500));
const debouncedFn = result.current;

// call the debounced function multiple times
act(() => {
debouncedFn('test1');
debouncedFn('test2');
debouncedFn('test3');
});

// callback should not have been called yet
expect(callback).not.toHaveBeenCalled();

// fast forward time by 500ms
act(() => {
vi.advanceTimersByTime(500);
});

// callback should have been called once with the last value
expect(callback).toHaveBeenCalledTimes(1);
expect(callback).toHaveBeenCalledWith('test3');
});

it('should respect the delay parameter', () => {
const callback = vi.fn();
const { result } = renderHook(() => useDebounceFn(callback, 1000));
const debouncedFn = result.current;

act(() => {
debouncedFn('test');
});

// advance time by 500ms
act(() => {
vi.advanceTimersByTime(500);
});

// callback should not have been called yet
expect(callback).not.toHaveBeenCalled();

// advance time by another 500ms
act(() => {
vi.advanceTimersByTime(500);
});

// callback should have been called
expect(callback).toHaveBeenCalledTimes(1);
});

it('should handle multiple parameters correctly', () => {
const callback = vi.fn();
const { result } = renderHook(() => useDebounceFn(callback, 500));
const debouncedFn = result.current;

act(() => {
debouncedFn('test', 123, { foo: 'bar' });
});

act(() => {
vi.advanceTimersByTime(500);
});

expect(callback).toHaveBeenCalledWith('test', 123, { foo: 'bar' });
});

it('should clear timeout on unmount', () => {
const callback = vi.fn();
const { result, unmount } = renderHook(() =>
useDebounceFn(callback, 500)
);
const debouncedFn = result.current;

act(() => {
debouncedFn('test');
});

// unmount before the delay
unmount();

// advance time
act(() => {
vi.advanceTimersByTime(500);
});

// callback should not have been called
expect(callback).not.toHaveBeenCalled();
});

it('should handle dependencies changes correctly', () => {
const callback = vi.fn();
const deps = ['dep1'];
const { result, rerender } = renderHook(
({ cb, dependencies }) => useDebounceFn(cb, 500, dependencies),
{
initialProps: { cb: callback, dependencies: deps },
}
);

const firstDebouncedFn = result.current;

// change dependencies
deps[0] = 'dep2';
rerender({ cb: callback, dependencies: deps });

const secondDebouncedFn = result.current;

// should be different functions due to dependency change
expect(firstDebouncedFn).not.toBe(secondDebouncedFn);
});

it('should preserve the latest callback reference', () => {
let capturedCallback;
const TestComponent = () => {
const callback = vi.fn();
const debouncedFn = useDebounceFn(callback, 500);
capturedCallback = callback;
return debouncedFn;
};

const { result } = renderHook(() => TestComponent());
const debouncedFn = result.current;

act(() => {
debouncedFn('test');
});

act(() => {
vi.advanceTimersByTime(500);
});

expect(capturedCallback).toHaveBeenCalledWith('test');
});
});
61 changes: 61 additions & 0 deletions frontend/src/hooks/useDebounceFn.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { useCallback, useRef, useEffect } from 'react';

/**
* A hook that returns a debounced version of the provided callback function.
* The debounced function will only execute after the specified delay has passed
* without any new invocations.
*
* @param callback The function to debounce
* @param delay The delay in milliseconds (defaults to 500ms)
* @param deps Dependencies array for the callback (optional)
* @returns A debounced version of the callback
*
* @example
* function SearchComponent() {
* const handleSearch = async (query: string) => {
* const results = await searchAPI(query);
* setSearchResults(results);
* };
*
* const debouncedSearch = useDebounce(handleSearch, 300);
*
* return (
* <input
* type="text"
* onChange={(e) => debouncedSearch(e.target.value)}
* />
* );
* }
*/
export function useDebounceFn<T extends (...args: any[]) => any>(
callback: T,
delay: number = 500,
deps: any[] = []
): T {
// use ref to store the timeout ID so it persists across renders
const timeoutRef = useRef<number | null>(null);

// clean up the timeout when the component unmounts
useEffect(() => {
return () => {
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}
};
}, []);

return useCallback(
(...args: Parameters<T>) => {
// clear the previous timeout if it exists
if (timeoutRef.current) {
clearTimeout(timeoutRef.current);
}

// set up new timeout
timeoutRef.current = window.setTimeout(() => {
callback(...args);
}, delay);
},
[delay, ...deps]
) as T;
}

0 comments on commit d8b365d

Please sign in to comment.