diff --git a/packages/components/src/modal/DebouncedModal.tsx b/packages/components/src/modal/DebouncedModal.tsx index da8fec67e9..290926a0f5 100644 --- a/packages/components/src/modal/DebouncedModal.tsx +++ b/packages/components/src/modal/DebouncedModal.tsx @@ -28,7 +28,7 @@ function DebouncedModal({ debounceMs, isOpen = false, }: DebouncedModalProps): JSX.Element { - const debouncedIsOpen = useDebouncedValue(isOpen, debounceMs); + const { value: debouncedIsOpen } = useDebouncedValue(isOpen, debounceMs); return ( <> diff --git a/packages/jsapi-components/src/useCheckIfExistsValue.ts b/packages/jsapi-components/src/useCheckIfExistsValue.ts index 5b52015486..078df50f80 100644 --- a/packages/jsapi-components/src/useCheckIfExistsValue.ts +++ b/packages/jsapi-components/src/useCheckIfExistsValue.ts @@ -36,7 +36,10 @@ export function useCheckIfExistsValue( const tableUtils = useTableUtils(); const [valueTrimmed, setValueTrimmed] = useState(''); - const valueTrimmedDebounced = useDebouncedValue(valueTrimmed, debounceMs); + const { value: valueTrimmedDebounced } = useDebouncedValue( + valueTrimmed, + debounceMs + ); const trimAndUpdateValue = useCallback((text: string) => { setValueTrimmed(text.trim()); diff --git a/packages/jsapi-components/src/useDebouncedViewportSelectionFilter.test.ts b/packages/jsapi-components/src/useDebouncedViewportSelectionFilter.test.ts index d27bc07f11..3fa2289852 100644 --- a/packages/jsapi-components/src/useDebouncedViewportSelectionFilter.test.ts +++ b/packages/jsapi-components/src/useDebouncedViewportSelectionFilter.test.ts @@ -32,7 +32,10 @@ const mockViewportData = createMockProxy>>(); const mockSelection = { - useDebounceValueResult: { selection: new Set('a'), isInverted: true }, + useDebounceValueResult: { + isDebouncing: false, + value: { selection: new Set('a'), isInverted: true }, + }, useIsEqualMemoResult: { selection: new Set('b'), isInverted: true }, useMappedSelectionResult: { selection: new Set('c'), isInverted: true }, } as const; @@ -96,7 +99,7 @@ it('should memoize debounced selection based on value equality', () => { renderHook(() => useDebouncedViewportSelectionFilter(options)); expect(useIsEqualMemo).toHaveBeenCalledWith( - mockSelection.useDebounceValueResult, + mockSelection.useDebounceValueResult.value, isSelectionMaybeInvertedEqual ); }); diff --git a/packages/jsapi-components/src/useDebouncedViewportSelectionFilter.ts b/packages/jsapi-components/src/useDebouncedViewportSelectionFilter.ts index dd64864d8c..75ea6a89d7 100644 --- a/packages/jsapi-components/src/useDebouncedViewportSelectionFilter.ts +++ b/packages/jsapi-components/src/useDebouncedViewportSelectionFilter.ts @@ -44,7 +44,7 @@ export function useDebouncedViewportSelectionFilter({ // Debounce so user can rapidly select multiple items in a row without the // cost of updating the table on each change - const debouncedValuesSelection = useDebouncedValue( + const { value: debouncedValuesSelection } = useDebouncedValue( valuesSelection, DEBOUNCE_MS ); diff --git a/packages/jsapi-components/src/usePickerWithSelectedValues.test.ts b/packages/jsapi-components/src/usePickerWithSelectedValues.test.ts index 558063ba79..175f3576a3 100644 --- a/packages/jsapi-components/src/usePickerWithSelectedValues.test.ts +++ b/packages/jsapi-components/src/usePickerWithSelectedValues.test.ts @@ -7,11 +7,10 @@ import { TableUtils, } from '@deephaven/jsapi-utils'; import { - KeyedItem, - SEARCH_DEBOUNCE_MS, - TestUtils, - WindowedListData, -} from '@deephaven/utils'; + useDebouncedValue, + type WindowedListData, +} from '@deephaven/react-hooks'; +import { KeyedItem, SEARCH_DEBOUNCE_MS, TestUtils } from '@deephaven/utils'; import usePickerWithSelectedValues from './usePickerWithSelectedValues'; import useViewportData, { UseViewportDataResult } from './useViewportData'; import useViewportFilter from './useViewportFilter'; @@ -27,6 +26,10 @@ jest.mock('@deephaven/jsapi-utils', () => ({ createSearchTextFilter: jest.fn(), createSelectedValuesFilter: jest.fn(), })); +jest.mock('@deephaven/react-hooks', () => ({ + ...jest.requireActual('@deephaven/react-hooks'), + useDebouncedValue: jest.fn(), +})); const { asMock, createMockProxy } = TestUtils; @@ -38,6 +41,8 @@ const tableUtils = createMockProxy(); const mock = { columnName: 'mock.columnName', + isDebouncingFalse: { isDebouncing: false }, + isDebouncingTrue: { isDebouncing: true }, filter: [ createMockProxy(), createMockProxy(), @@ -48,7 +53,8 @@ const mock = { item: { type: 'mock.item' }, }), mapItemToValue: jest.fn]>(), - searchText: 'mock.searchText', + searchText: ' mock searchText ', + searchTextTrimmed: 'mock searchText', searchTextFilter: jest.fn() as FilterConditionFactory, selectedKey: 'mock.selectedKey', excludeSelectedValuesFilter: jest.fn() as FilterConditionFactory, @@ -73,14 +79,17 @@ function mockUseViewportData(size: number) { .mockReturnValue(viewportData as UseViewportDataResult); } -async function renderOnceAndWait() { +async function renderOnceAndWait( + overrides?: Partial[0]> +) { const hookResult = renderHook(() => - usePickerWithSelectedValues( - mockTable.usersAndGroups, - mock.columnName, - mock.mapItemToValue, - ...mock.filterConditionFactories - ) + usePickerWithSelectedValues({ + maybeTable: mockTable.usersAndGroups, + columnName: mock.columnName, + mapItemToValue: mock.mapItemToValue, + filterConditionFactories: mock.filterConditionFactories, + ...overrides, + }) ); await hookResult.waitForNextUpdate(); @@ -115,6 +124,10 @@ beforeEach(() => { .mockName('useFilterConditionFactories') .mockReturnValue(mock.filter); + asMock(useDebouncedValue) + .mockName('useDebouncedValue') + .mockReturnValue(mock.isDebouncingFalse); + mockUseViewportData(1); asMock(mock.viewportData.findItem) @@ -130,37 +143,54 @@ afterEach(() => { jest.useRealTimers(); }); -it('should initially filter viewport by empty search text and exclude nothing', async () => { - const { result } = await renderOnceAndWait(); +it.each([undefined, false, true])( + 'should initially filter viewport by empty search text and exclude nothing: %s', + async trimSearchText => { + const { result } = await renderOnceAndWait({ trimSearchText }); - expect(result.current.searchText).toEqual(''); - expect(result.current.selectedKey).toBeNull(); + expect(result.current.searchText).toEqual(''); + expect(result.current.selectedKey).toBeNull(); - expect(createSearchTextFilter).toHaveBeenCalledWith( - tableUtils, - mock.columnName, - '' - ); + expect(createSearchTextFilter).toHaveBeenCalledWith( + tableUtils, + mock.columnName, + '' + ); - expect(createSelectedValuesFilter).toHaveBeenCalledWith( - tableUtils, - mock.columnName, - new Set(), - false, - true - ); + expect(createSelectedValuesFilter).toHaveBeenCalledWith( + tableUtils, + mock.columnName, + new Set(), + false, + true + ); - expect(useFilterConditionFactories).toHaveBeenCalledWith( - mockTable.list, - mock.searchTextFilter, - mock.excludeSelectedValuesFilter - ); + expect(useFilterConditionFactories).toHaveBeenCalledWith( + mockTable.list, + mock.searchTextFilter, + mock.excludeSelectedValuesFilter + ); - expect(useViewportFilter).toHaveBeenCalledWith( - asMock(useViewportData).mock.results[0].value, - mock.filter - ); -}); + expect(useViewportFilter).toHaveBeenCalledWith( + asMock(useViewportData).mock.results[0].value, + mock.filter + ); + } +); + +it.each([[undefined], [mock.filterConditionFactories]])( + 'should create distinct sorted column table applying filter condition factories: %s', + async filterConditionFactories => { + await renderOnceAndWait({ filterConditionFactories }); + + expect(tableUtils.createDistinctSortedColumnTable).toHaveBeenCalledWith( + mockTable.usersAndGroups, + mock.columnName, + 'asc', + ...(filterConditionFactories ?? []) + ); + } +); it('should memoize results', async () => { const { rerender, result } = await renderOnceAndWait(); @@ -172,46 +202,51 @@ it('should memoize results', async () => { expect(result.current).toBe(prevResult); }); -it('should filter viewport by search text after debounce', async () => { - const { result, waitForNextUpdate } = await renderOnceAndWait(); +it.each([undefined, false, true])( + 'should filter viewport by search text after debounce', + async trimSearchText => { + const { result, waitForNextUpdate } = await renderOnceAndWait({ + trimSearchText, + }); - jest.clearAllMocks(); + jest.clearAllMocks(); - act(() => { - result.current.onSearchTextChange(mock.searchText); - }); + act(() => { + result.current.onSearchTextChange(mock.searchText); + }); - // search text updated, but debounce not expired - expect(result.current.searchText).toEqual(mock.searchText); - expect(createSearchTextFilter).not.toHaveBeenCalled(); + // search text updated, but debounce not expired + expect(result.current.searchText).toEqual(mock.searchText); + expect(createSearchTextFilter).not.toHaveBeenCalled(); - act(() => { - jest.advanceTimersByTime(SEARCH_DEBOUNCE_MS); - }); + act(() => { + jest.advanceTimersByTime(SEARCH_DEBOUNCE_MS); + }); - // debouncedSearchText change will trigger another doesColumnValueExist - // call which will update state once resolved - await waitForNextUpdate(); + // debouncedSearchText change will trigger another doesColumnValueExist + // call which will update state once resolved + await waitForNextUpdate(); - expect(createSearchTextFilter).toHaveBeenCalledWith( - tableUtils, - mock.columnName, - mock.searchText - ); + expect(createSearchTextFilter).toHaveBeenCalledWith( + tableUtils, + mock.columnName, + trimSearchText === true ? mock.searchTextTrimmed : mock.searchText + ); - expect(createSelectedValuesFilter).not.toHaveBeenCalled(); + expect(createSelectedValuesFilter).not.toHaveBeenCalled(); - expect(useFilterConditionFactories).toHaveBeenCalledWith( - mockTable.list, - mock.searchTextFilter, - mock.excludeSelectedValuesFilter - ); + expect(useFilterConditionFactories).toHaveBeenCalledWith( + mockTable.list, + mock.searchTextFilter, + mock.excludeSelectedValuesFilter + ); - expect(useViewportFilter).toHaveBeenCalledWith( - asMock(useViewportData).mock.results[0].value, - mock.filter - ); -}); + expect(useViewportFilter).toHaveBeenCalledWith( + asMock(useViewportData).mock.results[0].value, + mock.filter + ); + } +); it.each([ [null, null], @@ -294,55 +329,70 @@ it.each([ describe('Flags', () => { const search = { - empty: '', - inSelection: 'in.selection', - notInSelection: 'not.in.selection', + empty: ' ', + inSelection: ' in.selection ', + notInSelection: ' not.in.selection ', }; beforeEach(() => { asMock(mock.mapItemToValue) .mockName('mockItemToValue') - .mockReturnValue(search.inSelection); + .mockReturnValue(search.inSelection.trim()); }); - it.each([ - [search.empty, 0], - [search.empty, 1], - [search.inSelection, 0], - [search.inSelection, 1], - [search.notInSelection, 0], - [search.notInSelection, 1], - ])('should return search text flags: %s', async (searchText, listSize) => { - mockUseViewportData(listSize); - - const { result, waitForNextUpdate } = await renderOnceAndWait(); - - // Initial values - expect(result.current).toMatchObject({ - hasSearchTextWithZeroResults: false, - searchTextIsInSelectedValues: false, - }); - - act(() => { - // We need to set something so that selectedValueMap will get populated - // with result of mapItemToValue which returns `search.inSelection` - result.current.onSelectKey('some.key'); - - result.current.onSearchTextChange(searchText); - jest.advanceTimersByTime(SEARCH_DEBOUNCE_MS); - }); - - // debouncedSearchText change will trigger another doesColumnValueExist - // call which will update state once resolved - if (searchText !== '') { - await waitForNextUpdate(); + describe.each([undefined, false, true])( + 'trimSearchText: %s', + trimSearchText => { + it.each([ + [search.empty, 0], + [search.empty, 1], + [search.inSelection, 0], + [search.inSelection, 1], + [search.notInSelection, 0], + [search.notInSelection, 1], + ])( + 'should return search text flags: %s', + async (searchText, listSize) => { + mockUseViewportData(listSize); + + const { result, waitForNextUpdate } = await renderOnceAndWait({ + trimSearchText, + }); + + const searchTextMaybeTrimmed = + trimSearchText === true ? searchText.trim() : searchText; + + // Initial values + expect(result.current).toMatchObject({ + hasSearchTextWithZeroResults: false, + searchTextIsInSelectedValues: false, + }); + + act(() => { + // We need to set something so that selectedValueMap will get populated + // with result of mapItemToValue which returns `search.inSelection` + result.current.onSelectKey('some.key'); + + result.current.onSearchTextChange(searchText); + jest.advanceTimersByTime(SEARCH_DEBOUNCE_MS); + }); + + // debouncedSearchText change will trigger another doesColumnValueExist + // call which will update state once resolved + if (searchTextMaybeTrimmed !== '') { + await waitForNextUpdate(); + } + + expect(result.current).toMatchObject({ + hasSearchTextWithZeroResults: + searchTextMaybeTrimmed.length > 0 && listSize === 0, + searchTextIsInSelectedValues: + searchTextMaybeTrimmed === search.inSelection.trim(), + }); + } + ); } - - expect(result.current).toMatchObject({ - hasSearchTextWithZeroResults: searchText.length > 0 && listSize === 0, - searchTextIsInSelectedValues: searchText === search.inSelection, - }); - }); + ); }); describe('onAddValues', () => { @@ -411,6 +461,7 @@ describe('searchTextExists', () => { it.each([ // isLoading, isDebouncing, exists + // (at least one of `isLoading` or `isDebouncing` is true in all cases) [true, true, true], [true, true, false], [true, false, true], @@ -418,27 +469,26 @@ describe('searchTextExists', () => { [false, true, true], [false, true, false], ])( - 'should be null if check is in progress: isLoading:%s, isDebouncing:%s, exists:%s', - async (isLoading, isDebouncing, exists) => { + 'should be null if check is in progress: isLoading:%s, isDebouncing:%s, valueExists:%s', + async (valueExistsIsLoading, isDebouncing, valueExists) => { asMock(tableUtils.doesColumnValueExist).mockReturnValue( - isLoading ? unresolvedPromise : Promise.resolve(exists) + valueExistsIsLoading ? unresolvedPromise : Promise.resolve(valueExists) ); + asMock(useDebouncedValue).mockReturnValue({ + isDebouncing, + }); + const { result } = await renderOnceAndWait(); - if (isDebouncing) { - act(() => { - // cause a mismatch of searchText with debouncedSearchText - result.current.onSearchTextChange('mismatch'); - }); - } + expect(useDebouncedValue).toHaveBeenCalledWith('', SEARCH_DEBOUNCE_MS); expect(result.current.searchTextExists).toBeNull(); } ); it.each([true, false])( - 'should be doesColumnValueExist return value if check is complete', + 'should equal the return value of `doesColumnValueExist` if check is complete', async exists => { asMock(tableUtils.doesColumnValueExist).mockResolvedValue(exists); diff --git a/packages/jsapi-components/src/usePickerWithSelectedValues.ts b/packages/jsapi-components/src/usePickerWithSelectedValues.ts index b56af5e437..ff65256c58 100644 --- a/packages/jsapi-components/src/usePickerWithSelectedValues.ts +++ b/packages/jsapi-components/src/usePickerWithSelectedValues.ts @@ -7,6 +7,7 @@ import { } from '@deephaven/jsapi-utils'; import { useDebouncedCallback, + useDebouncedValue, usePromiseFactory, } from '@deephaven/react-hooks'; import { @@ -42,24 +43,45 @@ export interface UsePickerWithSelectedValuesResult { * items are removed from the list and managed in a selectedValueMap data * structure. Useful for components that contain a picker but show selected * values in a separate component. - * @param maybeTable - * @param columnName - * @param mapItemToValue - * @param filterConditionFactories + * @param maybeTable The table to get the list of items from + * @param columnName The column name to get the list of items from + * @param mapItemToValue A function to map an item to a value + * @param filterConditionFactories Optional filter condition factories to apply to the list + * @param trimSearchText Whether to trim the search text before filtering. Defaults to false */ -export function usePickerWithSelectedValues( - maybeTable: Table | null, - columnName: string, - mapItemToValue: (item: KeyedItem) => TValue, - ...filterConditionFactories: FilterConditionFactory[] -): UsePickerWithSelectedValuesResult { +export function usePickerWithSelectedValues({ + maybeTable, + columnName, + mapItemToValue, + filterConditionFactories = [], + trimSearchText = false, +}: { + maybeTable: Table | null; + columnName: string; + mapItemToValue: (item: KeyedItem) => TValue; + filterConditionFactories?: FilterConditionFactory[]; + trimSearchText?: boolean; +}): UsePickerWithSelectedValuesResult { const tableUtils = useTableUtils(); // `searchText` should always be up to date for controlled search input. - // `debouncedSearchText` will get updated after a delay to avoid updating - // filters on every key stroke. + // `appliedSearchText` will get updated after a delay to avoid updating + // filters on every key stroke. It will also be trimmed of leading / trailing + // spaces if `trimSearchText` is true. const [searchText, setSearchText] = useState(''); - const [debouncedSearchText, setDebouncedSearchText] = useState(''); + const [appliedSearchText, setAppliedSearchText] = useState(''); + + const applySearchText = useCallback( + (text: string) => { + setAppliedSearchText(trimSearchText ? text.trim() : text); + }, + [trimSearchText] + ); + + const searchTextMaybeTrimmed = useMemo( + () => (trimSearchText ? searchText.trim() : searchText), + [searchText, trimSearchText] + ); const [selectedKey, setSelectedKey] = useState(null); const [selectedValueMap, setSelectedValueMap] = useState< @@ -70,20 +92,31 @@ export function usePickerWithSelectedValues( usePromiseFactory(tableUtils.doesColumnValueExist, [ maybeTable, columnName, - debouncedSearchText, + appliedSearchText, false /* isCaseSensitive */, ]); + // The `searchTextFilter` starts getting applied to the list whenever + // `appliedSearchText` changes, after which there is a small delay before the + // items are in sync. Use a debounce timer to allow a little extra time + // before calculating `searchTextExists` below. Note that there are 2 debounce + // timers at play here: + // 1. `onDebouncedSearchTextChange` applies the search text after user stops typing + // 2. `useDebouncedValue` debounces whenever the result of the first debounce + // changes, and `isApplyingFilter` will be true while this 2nd timer is active. + const { isDebouncing: isApplyingFilter } = useDebouncedValue( + appliedSearchText, + SEARCH_DEBOUNCE_MS + ); + // If value exists check is still loading or if debounce hasn't completed, set // `searchTextExists` to null since it is indeterminate. const searchTextExists = - valueExistsIsLoading || debouncedSearchText !== searchText - ? null - : valueExists; + isApplyingFilter || valueExistsIsLoading ? null : valueExists; const searchTextFilter = useMemo( - () => createSearchTextFilter(tableUtils, columnName, debouncedSearchText), - [columnName, debouncedSearchText, tableUtils] + () => createSearchTextFilter(tableUtils, columnName, appliedSearchText), + [appliedSearchText, columnName, tableUtils] ); // Filter out selected values from the picker @@ -113,13 +146,14 @@ export function usePickerWithSelectedValues( viewportPadding: VIEWPORT_PADDING, }); - const hasSearchTextWithZeroResults = searchText.length > 0 && list.size === 0; + const hasSearchTextWithZeroResults = + searchTextMaybeTrimmed.length > 0 && list.size === 0; const searchTextIsInSelectedValues = selectedValueMap.has( - searchText as TValue + searchTextMaybeTrimmed as TValue ); const onDebouncedSearchTextChange = useDebouncedCallback( - setDebouncedSearchText, + applySearchText, SEARCH_DEBOUNCE_MS ); @@ -136,7 +170,7 @@ export function usePickerWithSelectedValues( const onSelectKey = useCallback( (key: Key | null) => { setSearchText(''); - setDebouncedSearchText(''); + applySearchText(''); // Set the selection temporarily to avoid the picker staying open setSelectedKey(key); @@ -165,7 +199,12 @@ export function usePickerWithSelectedValues( return next; }); }, - [setSelectedKeyOnNextFrame, list.viewportData, mapItemToValue] + [ + applySearchText, + setSelectedKeyOnNextFrame, + list.viewportData, + mapItemToValue, + ] ); const onAddValues = useCallback((values: ReadonlySet) => { diff --git a/packages/react-hooks/src/useDebouncedValue.test.ts b/packages/react-hooks/src/useDebouncedValue.test.ts index 502f19bc35..df60adb18d 100644 --- a/packages/react-hooks/src/useDebouncedValue.test.ts +++ b/packages/react-hooks/src/useDebouncedValue.test.ts @@ -15,7 +15,7 @@ it('should return the initial value', () => { const { result } = renderHook(() => useDebouncedValue(value, DEFAULT_DEBOUNCE_MS) ); - expect(result.current).toBe(value); + expect(result.current).toEqual({ isDebouncing: true, value }); }); it('should return the initial value after the debounce time has elapsed', () => { @@ -23,14 +23,18 @@ it('should return the initial value after the debounce time has elapsed', () => const { result, rerender } = renderHook(() => useDebouncedValue(value, DEFAULT_DEBOUNCE_MS) ); - expect(result.current).toBe(value); + expect(result.current).toEqual({ isDebouncing: true, value }); expect(result.all.length).toBe(1); + rerender(); + expect(result.current).toEqual({ isDebouncing: true, value }); + expect(result.all.length).toBe(2); + act(() => { jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS); }); - expect(result.current).toBe(value); - expect(result.all.length).toBe(2); + expect(result.current).toEqual({ isDebouncing: false, value }); + expect(result.all.length).toBe(3); }); it('should return the updated value after the debounce time has elapsed', () => { @@ -39,12 +43,15 @@ it('should return the updated value after the debounce time has elapsed', () => const { result, rerender } = renderHook((val = value) => useDebouncedValue(val, DEFAULT_DEBOUNCE_MS) ); - expect(result.current).toBe(value); + expect(result.current).toEqual({ isDebouncing: true, value }); + rerender(newValue); + expect(result.current).toEqual({ isDebouncing: true, value }); + act(() => { jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS); }); - expect(result.current).toBe(newValue); + expect(result.current).toEqual({ isDebouncing: false, value: newValue }); }); it('should not return an intermediate value if the debounce time has not elapsed', () => { @@ -54,19 +61,19 @@ it('should not return an intermediate value if the debounce time has not elapsed const { result, rerender } = renderHook((val = value) => useDebouncedValue(val, DEFAULT_DEBOUNCE_MS) ); - expect(result.current).toBe(value); + expect(result.current).toEqual({ isDebouncing: true, value }); rerender(intermediateValue); act(() => { jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 5); }); - expect(result.current).toBe(value); + expect(result.current).toEqual({ isDebouncing: true, value }); rerender(newValue); act(() => { jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS - 5); }); - expect(result.current).toBe(value); + expect(result.current).toEqual({ isDebouncing: true, value }); act(() => { jest.advanceTimersByTime(DEFAULT_DEBOUNCE_MS); }); - expect(result.current).toBe(newValue); + expect(result.current).toEqual({ isDebouncing: false, value: newValue }); }); diff --git a/packages/react-hooks/src/useDebouncedValue.ts b/packages/react-hooks/src/useDebouncedValue.ts index c9efad5319..8fc60a50d4 100644 --- a/packages/react-hooks/src/useDebouncedValue.ts +++ b/packages/react-hooks/src/useDebouncedValue.ts @@ -6,20 +6,42 @@ import { useEffect, useState } from 'react'; * Returns the latest value after no changes have occurred for the debounce duration. * @param value Value to debounce * @param debounceMs Amount of time to debounce - * @returns The debounced value + * @returns The debounced value + whether the value is still debouncing */ -export function useDebouncedValue(value: T, debounceMs: number): T { - const [debouncedValue, setDebouncedValue] = useState(value); +export function useDebouncedValue( + value: T, + debounceMs: number +): { isDebouncing: boolean; value: T } { + const [isDebouncing, setIsDebouncing] = useState(true); + const [debouncedValue, setDebouncedValue] = useState(value); + + // Keep `isDebouncing` in sync with `value` and `debounceMs` by setting state + // during render instead of in `useEffect` + // https://react.dev/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes + const [previousValue, setPreviousValue] = useState(value); + const [previousDebounceMs, setPreviousDebounceMs] = useState(debounceMs); + if (value !== previousValue || debounceMs !== previousDebounceMs) { + setIsDebouncing(true); + setPreviousValue(value); + setPreviousDebounceMs(debounceMs); + } + useEffect(() => { + let isCancelled = false; + const timeoutId = setTimeout(() => { - setDebouncedValue(value); + if (!isCancelled) { + setIsDebouncing(false); + setDebouncedValue(value); + } }, debounceMs); return () => { + isCancelled = true; clearTimeout(timeoutId); }; }, [value, debounceMs]); - return debouncedValue; + return { isDebouncing, value: debouncedValue }; } export default useDebouncedValue;