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:
@@ -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
|
||||
|
||||
@@ -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> = {
|
||||
|
||||
@@ -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'>(
|
||||
|
||||
Reference in New Issue
Block a user