Infinite Select

Single and multiple shadcn/ui selects that page through a server list with infinite scroll, debounced search and selected-item preloading.

October 21, 2026 (4d ago)lucide-reactshadcn/popovershadcn/commandshadcn/badgeshadcn/buttonshadcn/skeleton
$npx shadcn@latest add https://tiendat.work/r/infinite-select.json
Loading...

What is Infinite Select?

A combobox for lists too big to ship to the browser: cities, customers, products. It asks your API for one page at a time, loads the next page as you scroll, searches on the server, and still shows the right label for a value that sits on page 25. It comes in two flavours that share the same data layer:

  • SelectSingleInfiniteScroll - one value, clearable, with an optional footer action (e.g. "Create new…").
  • SelectMultipleInfiniteScroll - many values, with select all (walks every remaining page, cancellable), clear all, selection order and collapsible badges.

Installation

pnpm dlx shadcn@latest add https://tiendat.work/r/infinite-select.json

Usage

Both selects take the same data in one of two ways: a plain queryFn, or a source you manage yourself. Pass exactly one.

With a queryFn

The built-in hook handles cursor paging, a 350ms search debounce, a per-search cache, aborting stale requests and retrying after an error. Your function gets the cursor of the page to load (null for the first), the search term, the ids to preload, and an AbortSignal.

import { SelectSingleInfiniteScroll } from '@/components/InfiniteSelect/select-single-infinite-scroll'
import { SelectMultipleInfiniteScroll } from '@/components/InfiniteSelect/select-multiple-infinite-scroll'
import type { QueryFn, SelectItemType } from '@/components/InfiniteSelect/use-infinite-options'
 
const fetchCities: QueryFn<SelectItemType> = async (cursor, search, selectedIds, signal) => {
  const params = new URLSearchParams({ search })
 
  if (cursor) params.set('cursor', cursor)
  selectedIds?.forEach(id => params.append('selectedIds', id))
 
  const res = await fetch(`/api/cities?${params}`, { signal })
 
  // { items: { id, name }[], nextCursor: string | null, selected?: { id, name }[] }
  return res.json()
}
 
export function CityFields () {
  const [city, setCity] = useState('')
  const [cities, setCities] = useState<string[]>([])
 
  return (
    <>
      <SelectSingleInfiniteScroll aria-label='City' queryFn={fetchCities} value={city} onChange={setCity} clearable />
      <SelectMultipleInfiniteScroll aria-label='Cities' queryFn={fetchCities} value={cities} onChange={setCities} />
    </>
  )
}

If your app already uses TanStack Query, map useInfiniteQuery to a source. You get its cache shared across the app, devtools, and invalidation after a mutation. The select still owns the search box and tells you the debounced term through onSearchChange.

import { useInfiniteQuery } from '@tanstack/react-query'
import { useState } from 'react'
import type { InfiniteSource, QueryFn, SelectItemType } from '@/components/InfiniteSelect/use-infinite-options'
 
export function useInfiniteSelectQuery<T extends SelectItemType> (
  key: string,
  queryFn: QueryFn<T>,
  selectedIds: string[] = []
): InfiniteSource<T> {
  const [search, setSearch] = useState('')
  const query = useInfiniteQuery({
    queryKey: [key, search],
    initialPageParam: null as string | null,
    queryFn: ({ pageParam, signal }) =>
      queryFn(pageParam, search, !pageParam && selectedIds.length ? selectedIds : undefined, signal),
    getNextPageParam: last => last.nextCursor,
  })
 
  return {
    items: query.data?.pages.flatMap(page => page.items) ?? [],
    selected: query.data?.pages[0]?.selected,
    hasNextPage: query.hasNextPage,
    fetchNextPage: query.fetchNextPage,
    isLoading: query.isLoading,
    isFetching: query.isFetching,
    isFetchingNextPage: query.isFetchingNextPage,
    isError: query.isError,
    error: query.error,
    retry: query.refetch,
    onSearchChange: setSearch,
  }
}
 
// const source = useInfiniteSelectQuery('cities', fetchCities, cities)
// <SelectMultipleInfiniteScroll source={source} value={cities} onChange={setCities} />

Props

Shared by both selects:

PropTypeDefaultDescription
queryFn(cursor, search, selectedIds?, signal?) => Promise<{ items, nextCursor, selected? }>-Loads one page. Mutually exclusive with source.
sourceInfiniteSource<T>-Externally managed list state (e.g. TanStack Query). Mutually exclusive with queryFn.
labelsPartial<InfiniteSelectLabels>EnglishOverride any text: placeholder, search, loading, loadingMore, end, empty, noResults, error, retry, clear, selectAll, clearAll, cancel, selectedCount(n), more(n).
maxHeightnumber256Max height of the list in px.
enabledbooleantrueSet false to hold off loading, e.g. until a parent field is filled.
disabledbooleanfalseDisables the trigger.
errorbooleanfalseMarks the field invalid (aria-invalid, destructive border).
isEqualWidthPopoverContentbooleantrueMatch the list width to the trigger.
aria-label / aria-describedbystring-Name and describe the field for assistive tech.
classNamestring-Classes for the trigger.

SelectSingleInfiniteScroll:

PropTypeDefaultDescription
valuestring | null-Selected id.
onChange(value: string) => void-Called with the id, or '' when cleared or deselected.
onChangeItem(item: T | undefined) => void-Called with the whole item.
clearablebooleanfalseShow an × in the trigger.
renderItem(item, isSelected) => ReactNode-Custom row.
footerActionReactNode-Rendered under the list and in the empty state.
getErrorMessage(error) => string | undefined-Map your API errors to a message.
getEmptyMessage(search) => string-Custom empty state.
open / onOpenChangeboolean / (open) => void-Control the popover.

SelectMultipleInfiniteScroll:

PropTypeDefaultDescription
valuestring[]-Selected ids, in selection order.
onChange(value: string[]) => void-Called with the new ids.
renderItem(item, isSelected, order?) => ReactNode-Custom row.
renderBadge(item) => ReactNode-Custom badge content.
showSelectAllbooleantrue"Select all" fetches every remaining page, with a cancel button.
showClearAllbooleantrueShow "Clear all".
showSelectedCountbooleantrueShow "N selected" above the list.
showSelectionOrderbooleantrueNumber badge on each selected row.
collapseBadgesbooleantrueFirst badge plus "+N more" instead of one badge each.

Notes

Preloading selected items

A value often points at a row the user has not scrolled to yet: an edit form opens with city 137, but the first page holds 1-20. To show its name anyway, the first page request carries selectedIds and your API returns those items in selected. The select only asks for ids it does not know yet, so a form reset to new ids loads just those, without dropping the pages already scrolled. Until an id resolves, the multiple select shows a skeleton and then falls back to the raw id.

Aborting with signal

Typing "Amb" then "Ambe" fires two searches. The select aborts the older request through the AbortSignal passed as the 4th argument, and ignores its response even if your function does not honour the signal. Forward it to fetch (or axios) to save the round trip.

Contents
TABLE OF CONTENTS