feat(ui): add searchDebounceMs/filterDebounceMs to useTable complete mode (#34127)

* feat(ui): add internal useDebouncedValue hook for Table

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): expose searchDebounceMs and filterDebounceMs on UseTableCompleteOptions

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* feat(ui): debounce search/filter in useTable complete mode pipeline

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): add SearchWithDebounce Table dev story

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): document searchDebounceMs/filterDebounceMs and fix Table search note

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): add searchDebounceMs/filterDebounceMs to Table props reference

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* docs(ui): note controlled-callback behavior on Table debounce props

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore(ui): regenerate API report for useTable debounce options

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore(ui): changeset for useTable complete-mode debounce options

Signed-off-by: Johan Persson <johanopersson@gmail.com>

* chore: accept 'debouncing' in Vale vocabulary

Signed-off-by: Johan Persson <johanopersson@gmail.com>

---------

Signed-off-by: Johan Persson <johanopersson@gmail.com>
This commit is contained in:
Johan Persson
2026-05-05 16:45:18 +02:00
committed by GitHub
parent ddca41f775
commit 25909ba27a
9 changed files with 211 additions and 13 deletions
+2
View File
@@ -3569,6 +3569,8 @@ export type UseTableCompleteOptions<
sortFn?: (data: T[], sort: SortDescriptor) => T[];
filterFn?: (data: T[], filter: TFilter) => T[];
searchFn?: (data: T[], search: string) => T[];
searchDebounceMs?: number;
filterDebounceMs?: number;
} & (
| {
data: T[] | undefined;
@@ -111,6 +111,18 @@ export type UseTableCompleteOptions<
sortFn?: (data: T[], sort: SortDescriptor) => T[];
filterFn?: (data: T[], filter: TFilter) => T[];
searchFn?: (data: T[], search: string) => T[];
/**
* Trailing-edge debounce delay (ms) applied to the search value before it
* reaches `searchFn`. Defaults to `0` no debounce, no extra render. The
* controlled `search` / `onSearchChange` surface is unaffected.
*/
searchDebounceMs?: number;
/**
* Trailing-edge debounce delay (ms) applied to the filter value before it
* reaches `filterFn`. Defaults to `0` no debounce, no extra render. The
* controlled `filter` / `onFilterChange` surface is unaffected.
*/
filterDebounceMs?: number;
} & (
| {
data: T[] | undefined;
@@ -22,6 +22,7 @@ import type {
UseTableCompleteOptions,
} from './types';
import { useStableCallback } from './useStableCallback';
import { useDebouncedValue } from './useDebouncedValue';
import { getEffectivePageSize } from './getEffectivePageSize';
/** @internal */
@@ -36,6 +37,8 @@ export function useCompletePagination<T extends TableItem, TFilter>(
sortFn,
filterFn,
searchFn,
searchDebounceMs = 0,
filterDebounceMs = 0,
} = options;
const hasGetData = 'getData' in options;
const noPagination = paginationOptions.type === 'none';
@@ -97,14 +100,25 @@ export function useCompletePagination<T extends TableItem, TFilter>(
};
}, [data, getData, hasGetData, loadCount]);
// Reset offset when query changes (query object is memoized)
const prevQueryRef = useRef(query);
// Debounced surrogates of search and filter feed the processing pipeline.
// At delayMs === 0 (the default) these are referentially equal to the live
// values, so behavior is identical to before this refactor.
const debouncedSearch = useDebouncedValue(search, searchDebounceMs);
const debouncedFilter = useDebouncedValue(filter, filterDebounceMs);
// Reset offset when the *debounced* query changes — keying on the live query
// would briefly flash page 1 of unfiltered data while the debounce settles.
const debouncedQuery = useMemo(
() => ({ sort, filter: debouncedFilter, search: debouncedSearch }),
[sort, debouncedFilter, debouncedSearch],
);
const prevDebouncedQueryRef = useRef(debouncedQuery);
useEffect(() => {
if (prevQueryRef.current !== query) {
prevQueryRef.current = query;
if (prevDebouncedQueryRef.current !== debouncedQuery) {
prevDebouncedQueryRef.current = debouncedQuery;
setOffset(0);
}
}, [query]);
}, [debouncedQuery]);
const resolvedItems = useMemo(() => data ?? items, [data, items]);
@@ -114,17 +128,25 @@ export function useCompletePagination<T extends TableItem, TFilter>(
return undefined;
}
let result = [...resolvedItems];
if (filter !== undefined && filterFn) {
result = filterFn(result, filter);
if (debouncedFilter !== undefined && filterFn) {
result = filterFn(result, debouncedFilter);
}
if (search && searchFn) {
result = searchFn(result, search);
if (debouncedSearch && searchFn) {
result = searchFn(result, debouncedSearch);
}
if (sort && sortFn) {
result = sortFn(result, sort);
}
return result;
}, [resolvedItems, sort, filter, search, filterFn, searchFn, sortFn]);
}, [
resolvedItems,
sort,
debouncedFilter,
debouncedSearch,
filterFn,
searchFn,
sortFn,
]);
const totalCount = processedData?.length ?? 0;
@@ -0,0 +1,43 @@
/*
* Copyright 2026 The Backstage Authors
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
import { useEffect, useState } from 'react';
/**
* Returns a trailing-edge debounced surrogate of `value`.
*
* - When `delayMs <= 0`, the live `value` is returned directly without arming
* a timer a true bypass with no observable change relative to using
* `value` itself.
* - When `delayMs > 0`, the returned value lags behind `value` by `delayMs`
* of stability. The timer is cleared on `value` change, `delayMs` change,
* and on unmount.
*
* @internal
*/
export function useDebouncedValue<T>(value: T, delayMs: number): T {
const [debounced, setDebounced] = useState<T>(value);
useEffect(() => {
if (delayMs <= 0) {
return undefined;
}
const timer = setTimeout(() => setDebounced(value), delayMs);
return () => clearTimeout(timer);
}, [value, delayMs]);
return delayMs <= 0 ? value : debounced;
}
@@ -15,7 +15,7 @@
* limitations under the License.
*/
import { useState, Fragment } from 'react';
import { useState, Fragment, useMemo } from 'react';
import type { Meta, StoryObj } from '@storybook/react-vite';
import {
Table,
@@ -212,6 +212,95 @@ export const Search: Story = {
},
};
export const SearchWithDebounce: Story = {
render: () => {
// Amplify the ~100-row mocked dataset to several thousand rows so the
// perf difference between debounced and non-debounced typing is visible.
const largeData = useMemo(() => {
const result: Data1Item[] = [];
for (let i = 0; i < 50; i++) {
for (const item of data1) {
result.push({ ...item, id: `${item.id}-${i}` });
}
}
return result;
}, []);
const columns: ColumnConfig<Data1Item>[] = [
{
id: 'name',
label: 'Name',
isRowHeader: true,
cell: item => <CellText title={item.name} />,
},
{
id: 'owner',
label: 'Owner',
cell: item => <CellText title={item.owner.name} />,
},
{
id: 'type',
label: 'Type',
cell: item => <CellText title={item.type} />,
},
];
const searchFn = (items: Data1Item[], query: string) => {
const lowerQuery = query.toLowerCase();
return items.filter(
item =>
item.name.toLowerCase().includes(lowerQuery) ||
item.owner.name.toLowerCase().includes(lowerQuery) ||
item.type.toLowerCase().includes(lowerQuery),
);
};
const immediate = useTable({
mode: 'complete',
data: largeData,
paginationOptions: { pageSize: 10 },
searchFn,
});
const debounced = useTable({
mode: 'complete',
data: largeData,
paginationOptions: { pageSize: 10 },
searchFn,
searchDebounceMs: 200,
});
return (
<div style={{ display: 'grid', gap: '32px' }}>
<div>
<h3 style={{ marginBottom: '8px' }}>
Immediate (searchDebounceMs: 0)
</h3>
<SearchField
aria-label="Immediate search"
placeholder="Type to search..."
style={{ marginBottom: '16px' }}
{...immediate.search}
/>
<Table columnConfig={columns} {...immediate.tableProps} />
</div>
<div>
<h3 style={{ marginBottom: '8px' }}>
Debounced (searchDebounceMs: 200)
</h3>
<SearchField
aria-label="Debounced search"
placeholder="Type to search..."
style={{ marginBottom: '16px' }}
{...debounced.search}
/>
<Table columnConfig={columns} {...debounced.tableProps} />
</div>
</div>
);
},
};
export const Selection: Story = {
render: () => {
const [selected, setSelected] = useState<Set<string | number> | 'all'>(