diff --git a/.changeset/usetable-complete-debounce.md b/.changeset/usetable-complete-debounce.md new file mode 100644 index 0000000000..2b6cd564da --- /dev/null +++ b/.changeset/usetable-complete-debounce.md @@ -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 diff --git a/.github/vale/config/vocabularies/Backstage/accept.txt b/.github/vale/config/vocabularies/Backstage/accept.txt index 40cff3ea5f..8549c058a6 100644 --- a/.github/vale/config/vocabularies/Backstage/accept.txt +++ b/.github/vale/config/vocabularies/Backstage/accept.txt @@ -103,6 +103,7 @@ dayjs debounce debounced debounces +debouncing debuggability declaratively deduplicate diff --git a/docs-ui/src/app/components/table/page.mdx b/docs-ui/src/app/components/table/page.mdx index 3bc6e0cd24..38b29ec458 100644 --- a/docs-ui/src/app/components/table/page.mdx +++ b/docs-ui/src/app/components/table/page.mdx @@ -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). diff --git a/docs-ui/src/app/components/table/props-definition.tsx b/docs-ui/src/app/components/table/props-definition.tsx index fcec3a6278..5d4d94c083 100644 --- a/docs-ui/src/app/components/table/props-definition.tsx +++ b/docs-ui/src/app/components/table/props-definition.tsx @@ -157,6 +157,28 @@ export const useTableOptionsPropDefs: Record = { ), }, + searchDebounceMs: { + type: 'number', + description: ( + <> + Trailing-edge debounce delay (ms) applied to the search value before it + reaches searchFn. Defaults to 0 (no debounce). + Does not affect the controlled onSearchChange callback. + Only used with complete mode. + + ), + }, + filterDebounceMs: { + type: 'number', + description: ( + <> + Trailing-edge debounce delay (ms) applied to the filter value before it + reaches filterFn. Defaults to 0 (no debounce). + Does not affect the controlled onFilterChange callback. + Only used with complete mode. + + ), + }, }; export const useTableReturnPropDefs: Record = { diff --git a/packages/ui/report.api.md b/packages/ui/report.api.md index 3a51b61b38..ac6c9f13a2 100644 --- a/packages/ui/report.api.md +++ b/packages/ui/report.api.md @@ -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; diff --git a/packages/ui/src/components/Table/hooks/types.ts b/packages/ui/src/components/Table/hooks/types.ts index 01cc8ac02e..ffc3214e35 100644 --- a/packages/ui/src/components/Table/hooks/types.ts +++ b/packages/ui/src/components/Table/hooks/types.ts @@ -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; diff --git a/packages/ui/src/components/Table/hooks/useCompletePagination.ts b/packages/ui/src/components/Table/hooks/useCompletePagination.ts index 22711511d4..fad770735c 100644 --- a/packages/ui/src/components/Table/hooks/useCompletePagination.ts +++ b/packages/ui/src/components/Table/hooks/useCompletePagination.ts @@ -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( 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( }; }, [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( 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; diff --git a/packages/ui/src/components/Table/hooks/useDebouncedValue.ts b/packages/ui/src/components/Table/hooks/useDebouncedValue.ts new file mode 100644 index 0000000000..382cefd24f --- /dev/null +++ b/packages/ui/src/components/Table/hooks/useDebouncedValue.ts @@ -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(value: T, delayMs: number): T { + const [debounced, setDebounced] = useState(value); + + useEffect(() => { + if (delayMs <= 0) { + return undefined; + } + const timer = setTimeout(() => setDebounced(value), delayMs); + return () => clearTimeout(timer); + }, [value, delayMs]); + + return delayMs <= 0 ? value : debounced; +} diff --git a/packages/ui/src/components/Table/stories/Table.dev.stories.tsx b/packages/ui/src/components/Table/stories/Table.dev.stories.tsx index 24b8246c4d..af7b281703 100644 --- a/packages/ui/src/components/Table/stories/Table.dev.stories.tsx +++ b/packages/ui/src/components/Table/stories/Table.dev.stories.tsx @@ -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[] = [ + { + id: 'name', + label: 'Name', + isRowHeader: true, + cell: item => , + }, + { + id: 'owner', + label: 'Owner', + cell: item => , + }, + { + id: 'type', + label: 'Type', + cell: item => , + }, + ]; + + 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 ( +
+
+

+ Immediate (searchDebounceMs: 0) +

+ + + +
+

+ Debounced (searchDebounceMs: 200) +

+ +
+ + + ); + }, +}; + export const Selection: Story = { render: () => { const [selected, setSelected] = useState | 'all'>(