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
+7
View File
@@ -0,0 +1,7 @@
---
'@backstage/ui': patch
---
Added `searchDebounceMs` and `filterDebounceMs` options to `useTable` in `complete` mode. Both default to `0` (no debounce, no observable change for existing consumers); set them to defer the client-side filter/search/sort pipeline on large datasets without reimplementing input-layer debouncing. The controlled `search` / `onSearchChange` and `filter` / `onFilterChange` callbacks continue to fire on every change.
**Affected components:** Table
@@ -103,6 +103,7 @@ dayjs
debounce
debounced
debounces
debouncing
debuggability
declaratively
deduplicate
+2 -2
View File
@@ -126,9 +126,9 @@ Configure page size and available options through `paginationOptions`. The table
### Search
The `useTable` hook returns a `search` object with `value` and `onChange` properties, ready to connect to a search input. With `mode: 'complete'`, provide a `searchFn` that filters the dataset based on the search query.
The `useTable` hook returns a `search` object with `value` and `onChange` properties, ready to connect to a search input. With `mode: 'complete'`, provide a `searchFn` that filters the dataset based on the search query. When the search query changes, pagination resets to the first page automatically.
The search state is debounced internally, so rapid typing doesn't trigger excessive re-filtering. When the search query changes, pagination resets to the first page automatically.
In `complete` mode, set `searchDebounceMs` (and/or `filterDebounceMs`) to defer the filtering pipeline until typing settles — useful for large datasets. Both default to `0` (no debounce). The controlled `search` / `onSearchChange` (and `filter` / `onFilterChange`) surface continues to fire on every change. For `offset` and `cursor` modes, requests are already debounced internally, so these options don't apply.
For server-side search with `offset` or `cursor` modes, the search query is passed to your `getData` function. See [Server-Side Data](#server-side-data).
@@ -157,6 +157,28 @@ export const useTableOptionsPropDefs: Record<string, PropDef> = {
</>
),
},
searchDebounceMs: {
type: 'number',
description: (
<>
Trailing-edge debounce delay (ms) applied to the search value before it
reaches <Chip>searchFn</Chip>. Defaults to <Chip>0</Chip> (no debounce).
Does not affect the controlled <Chip>onSearchChange</Chip> callback.
Only used with <Chip>complete</Chip> mode.
</>
),
},
filterDebounceMs: {
type: 'number',
description: (
<>
Trailing-edge debounce delay (ms) applied to the filter value before it
reaches <Chip>filterFn</Chip>. Defaults to <Chip>0</Chip> (no debounce).
Does not affect the controlled <Chip>onFilterChange</Chip> callback.
Only used with <Chip>complete</Chip> mode.
</>
),
},
};
export const useTableReturnPropDefs: Record<string, PropDef> = {
+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'>(