Subject Under Test(sut):
A hook that debounces value to eliminate the performance penalty caused by rapid changes to a value. Source: usehooks.com
Behaviour:
Should only emit the last value change when specified debounce time is past.
Code:
import { useDebouncedValue } from './useDebouncedValue'; | |
import { act } from '@testing-library/react-hooks'; | |
import { useState } from 'react'; | |
import { render } from '@testing-library/react'; | |
import userEvent from '@testing-library/user-event'; | |
function TestComponent({ initialValue = 0 }: { initialValue?: number }) { | |
const [value, setValue] = useState(initialValue); | |
const debouncedValue = useDebouncedValue(value, 1000); | |
return ( | |
<div> | |
<button onClick={() => setValue(value + 1)}>Increment</button> | |
<span data-testid={'debouncedValue'}>{debouncedValue}</span> | |
<span data-testid={'value'}>{value}</span> | |
</div> | |
); | |
} | |
describe('useDebouncedValue', function () { | |
afterEach(() => { | |
jest.useRealTimers(); | |
}); | |
it('should debounce and only change value when delay time has passed', function () { | |
jest.useFakeTimers(); | |
const { getByTestId, getByText } = render(<TestComponent />); | |
const incrementButton = getByText('Increment'); | |
const debouncedValue = getByTestId('debouncedValue'); | |
const value = getByTestId('value'); | |
const incrementAndPassTime = (passedTime: number) => { | |
act(() => { | |
userEvent.click(incrementButton); | |
jest.advanceTimersByTime(passedTime); | |
}); | |
}; | |
incrementAndPassTime(100); | |
expect(debouncedValue.textContent).toBe('0'); | |
expect(value.textContent).toBe('1'); | |
incrementAndPassTime(500); | |
expect(debouncedValue.textContent).toBe('0'); | |
expect(value.textContent).toBe('2'); | |
incrementAndPassTime(999); | |
expect(debouncedValue.textContent).toBe('0'); | |
expect(value.textContent).toBe('3'); | |
act(() => { | |
jest.advanceTimersByTime(1); | |
}); | |
expect(debouncedValue.textContent).toBe('3'); | |
expect(value.textContent).toBe('3'); | |
}); | |
}); | |
describe('Initial Value of DebouncedValue', function () { | |
it('should set initial value', function () { | |
const { getByTestId } = render(<TestComponent key={'1'} initialValue={1} />); | |
expect(getByTestId('debouncedValue').textContent).toBe('1'); | |
expect(getByTestId('value').textContent).toBe('1'); | |
}); | |
}); |
import { useEffect, useState } from 'react'; | |
// Hook | |
export function useDebouncedValue(value: any, delay = 250) { | |
// State and setters for debounced value | |
const [debouncedValue, setDebouncedValue] = useState(value); | |
useEffect( | |
() => { | |
// Update debounced value after delay | |
const handler = setTimeout(() => { | |
setDebouncedValue(value); | |
}, delay); | |
// Cancel the timeout if value changes (also on delay change or unmount) | |
// This is how we prevent debounced value from updating if value is changed ... | |
// .. within the delay period. Timeout gets cleared and restarted. | |
return () => { | |
clearTimeout(handler); | |
}; | |
}, | |
[value, delay] // Only re-call effect if value or delay changes | |
); | |
return debouncedValue; | |
} |
Notes:
The test uses a React Component to test the sut's behaviour. It shows how the hook should be used.
The Test uses
useFakeTimers
to control the pass of time and assert when the debounced value should and should not be changed
One weird and interesting behavior of Jest's fake timer is that the fake timer somehow got influenced by other tests before it:
If I move the second describe block (not using the fake timer) before the first, the last assessment fails:
act(() => {
jest.advanceTimersByTime(1);
});
expect(debouncedValue.textContent).toBe('3'); // fails, got 0 instead of 3
expect(value.textContent).toBe('3');
If anyone knows why the above fails, please, please let me know, I would be most grateful.
Top comments (0)