TanStack Select RFC: concept inventory and rewrite direction
Status: Reconciled draft
Scope: Product and architecture direction, not a final API
Source: The current implementation, its history, repository feedback, and Kevin Van Cott's initial architecture assessment
Summary
TanStack Select should be a headless, framework-agnostic engine for building accessible, collection-based selection inputs.
A sufficient select is a small data grid. It needs stable item identity, derived models, filtering and ranking, grouping, selection models, keyboard navigation, async data, virtualization, and controlled state. Table solves many of the data problems that make selects powerful; it solves almost none of the accessibility and focus problems that make selects hard. The rewrite should use the same broad separation that makes TanStack Table work:
- User data becomes a stable option model.
- Feature stages derive the options available for display and interaction.
- State and actions operate on option IDs, not array positions or DOM nodes.
- A framework-agnostic core owns data and behavior.
- Framework adapters make the core reactive and expose accessible prop getters.
- Rendering, markup, styling, positioning, and virtualization remain replaceable.
This is not a proposal to turn Select into a styled component library or to make Table a dependency. It is a proposal to give Select a similarly capable collection core and a purpose-built interaction layer.
Product contract
The package should make simple selects easy without limiting complex ones. Its irreducible responsibilities are:
- Model arbitrary user data as identifiable, selectable options.
- Derive the options that are searchable, available, visible, and navigable.
- Keep committed selection separate from transient interaction state.
- Support ordered single and multiple selection, including repeated values when enabled.
- Support range selection and select-all semantics when the active feature set enables them.
- Support local, controlled, and remote search.
- Support creating values that are not in the current option collection.
- Implement complete keyboard, pointer, touch, focus, and screen-reader behavior.
- Remain usable with portals, popovers, variable layouts, and virtualized lists.
- Let every important state slice be controlled without requiring all state to be controlled.
- Expose enough models and actions to build product-specific interfaces without forking the engine.
Scope boundary
Stable initial scope
- Select-only comboboxes with a button-like trigger and popup listbox.
- Editable restricted comboboxes.
- Free-form autocomplete and suggestions.
- Always-visible listboxes.
- Single and multiple selection, including tag/chip navigation and removal.
- Disabled options and groups, typeahead, local filtering, and relevance sorting.
- Controlled and uncontrolled state.
- Deterministic IDs, SSR, RTL, localized announcements, forms, reset, validation, and an explicit autofill strategy.
- Async and loading state without owning fetching.
- A virtualization bridge without depending on TanStack Virtual.
Designed later
- Hierarchical and tree pickers.
- Grid popups for rich structured options.
Out of scope
- Ranger and sliders. A future TanStack Slider may share infrastructure, but it is a different widget family.
- Date, time, and color pickers; radio groups; segmented controls; and number inputs.
- Required component anatomy, CSS, popup positioning, animation, or data fetching.
Documentation should be native-first. If a native <select> meets the product's requirements, recommend it before a custom control.
Opinions already encoded in the current library
These are the concepts to preserve. Their current names and implementations are not commitments.
| Existing concept |
Product opinion to preserve |
Rewrite implication |
| Headless hook and prop getters |
Behavior must not dictate markup or styling. User event handlers must compose with library behavior. |
Core plus framework adapters; accessible prop getters or equivalent bindings. |
Controlled value and onChange |
The application owns committed selection. |
Controlled/uncontrolled state slices with typed change metadata. |
Internal searchValue, isOpen, and highlightedIndex |
Committed selection and interaction state are different things. |
Separate selection, query, open, active option, focus, and async state. |
stateReducer with action types |
Consumers must be able to intercept and replace default transitions. |
Every transition has a typed action/reason and can be controlled or reduced. |
multi |
Multiple selection is a primary mode, not an add-on. |
One internal ordered selection model with type-safe single/multiple adapters. |
duplicates and removal by index |
A multi-select may be an ordered multiset, not only a set. One occurrence must be removable without removing all equal values. |
Selection entries need identity distinct from option value. |
| Hide selected options unless duplicates are allowed |
Availability is a derived model and selection can affect it. |
A configurable availability stage, not destructive filtering of source data. |
| Missing selected options are synthesized |
A selected value must survive when its option is absent, stale, remote, or not loaded yet. |
First-class unresolved/orphaned value resolution with explicit labels and hydration. |
create and getCreateLabel |
User input can become a committed value, and its presentation is customizable. |
A typed creation candidate and lifecycle, separate from real options. |
filterFn |
Search is replaceable and may both filter and rank. |
Pluggable filtering/ranking stages with metadata. |
| Raw and debounced resolved search |
Input feedback must be immediate while expensive derivation or fetching may lag. |
Separate input query from effective query; debounce belongs at the derivation/fetch boundary. |
| Debounce based on option count |
Large collections need a different performance strategy. |
Indexing, memoized models, worker/remote escape hatches, and consumer-configured debounce rather than a hard-coded heuristic. |
visibleOptions |
Consumers need the final navigable option model, not only source data. |
Named option-model stages with public getters. |
| Index-based option actions |
The implementation anticipated windowing and items not all being mounted. |
Public identity should be ID-based; adapters may resolve visible indices for virtualizers. |
scrollToIndex |
Active-item navigation must coordinate with virtualization. |
A scroll-to-option contract and official TanStack Virtual integration example. |
| Arrow, page, home/end, enter, escape, tab, and backspace behavior |
Keyboard interaction is core behavior. |
A tested interaction state machine following the applicable ARIA pattern. |
| Backspace removes the last value only when query is empty |
Text editing takes precedence over tag removal. |
Preserve as a configurable default with selection-entry focus for richer tag navigation. |
| Single selection closes; multiple selection stays open and clears search |
Commit behavior depends on selection mode. |
Typed close/query-reset policies keyed by action reason. |
| Search resets active item |
Query changes invalidate current navigation. |
Configurable active-item reconciliation by ID, with a deterministic fallback. |
| Outside interaction closes the popup |
Dismissal is part of the behavior contract. |
Interaction boundary may include trigger, input, popup, and portaled descendants. |
| Deferred blur and refocus after opening |
Focus must survive composite controls and portals. |
Explicit focus-within and dismissal semantics; no timer-based focus hacks. |
| Values are separate from option objects |
Applications should not have to store library-owned option wrappers. |
Generic accessors for ID, value, label, disabled state, text, and metadata. |
Historical feedback worth accommodating
Repository issues also exposed needs that were not fully implemented:
- Async option fetching.
- TypeScript-first APIs with correct single/multiple inference.
- Search that moves the active item without filtering the collection.
- Optional inline completion/ghost text.
- Examples without styling or rendering dependencies.
- First-class accessibility rather than a future improvement.
These should enter the design backlog, but they do not have the same status as behavior already present in the library.
Concepts that should not survive literally
- Array indices must not be durable option identity. Filtering, sorting, grouping, async updates, and virtualization all invalidate them.
{ value, label } must not be the only accepted data shape.
- A creatable value must not masquerade as a normal option. That creates identity collisions and cannot represent validation, pending creation, or failure.
- Missing selected values must not silently become fake source options. Their unresolved state should be observable.
- Filtering must not assume string values or mutate meaning by sorting with an incomplete comparator.
- Option-count-based debounce must not be built-in policy.
- Focus and blur correctness must not depend on
setTimeout.
- An
optionsRef that represents only one DOM subtree is insufficient for portaled composite controls.
- A single global reducer should not be the only control point. State slices and callbacks should also be individually controllable.
- The core must not import React or the DOM.
- Accessibility cannot be delegated entirely to consumers; the adapter must produce correct roles, relationships, IDs, and interaction handlers.
Relationship to Table v9 and the TanStack ecosystem
Select should replicate the proven shape of Table v9 without importing Table or extracting a shared generic framework prematurely:
- A static feature object selects first-party features and preserves tree-shaking and inference.
- Features contribute initial state, default options, instance data, models, and APIs.
- State slices use individually subscribable TanStack Store atoms.
- Uncontrolled state, external atoms, and controlled state share the same update machinery.
- Feature slots declare prerequisites and produce useful type errors for invalid combinations.
- Type maps and declaration merging avoid large conditional unions and preserve declaration performance.
The feature graph is a typed assembly mechanism, not permission for features to own arbitrary interaction behavior. Cross-cutting keyboard, focus, and ARIA invariants remain coordinated by the interaction layer. We should let Table and Select converge as separate implementations before deciding whether any feature infrastructure belongs in a shared package.
TanStack Store should be an internal dependency. Form, Table, Virtual, Pacer, Query, and Hotkeys should integrate by composition without becoming core dependencies.
The market position is not merely another Downshift. It is a multi-framework, Table-grade option engine with accessible interaction bindings, fine-grained subscriptions, inspectable models, and no required rendering or positioning system.
Option and selection models
Source option
Users should pass arbitrary objects. Accessors derive the fields Select requires.
type SelectOptions<TOption, TValue> = {
options: TOption[]
getOptionId: (option: TOption, index: number) => string
getOptionValue: (option: TOption) => TValue
getOptionLabel: (option: TOption) => string
getOptionText?: (option: TOption) => string
getOptionDisabled?: (option: TOption) => boolean | string
getSubOptions?: (option: TOption) => TOption[] | undefined
}
getOptionId is the engine identity. getOptionValue is the application payload. They are deliberately different: multiple options may share a value, values may not be strings, and selected occurrences may repeat.
Derived option
The core wraps each source option with stable derived behavior and metadata, similar to a Table row.
type SelectOption<TOption, TValue> = {
id: string
original: TOption
value: TValue
label: string
depth: number
parentId?: string
disabled: boolean
disabledReason?: string
index: number
getIsSelected(): boolean
getIsActive(): boolean
getIsAvailable(): boolean
getFilterMeta(): unknown
select(reason?: SelectActionReason): void
}
The exact methods are open, but the model must expose stable identity, source data, derived state, and actions.
Selection entry
Committed multi-selection is an ordered list of occurrences.
type SelectionEntry<TValue> = {
id: string
value: TValue
optionId?: string
}
optionId may be unresolved when a value is restored before its option is loaded. id distinguishes duplicate occurrences. Applications may continue to receive plain values in the common API; entries are the lossless internal model and advanced API.
Selection equality must be configurable with isValueEqual, with referential or Object.is semantics as a documented default. Serialization is a separate concern.
Option-model pipeline
Each stage should be memoizable, replaceable, and observable. Features may skip stages with manual* options, following familiar TanStack conventions.
source options
-> core option model (identity, hierarchy, accessors)
-> availability model (disabled/hidden/already selected/policy)
-> filtered model (query and custom filters)
-> ranked/custom-sorted model
-> grouped model
-> expanded hierarchy
-> flattened visible model
-> navigable model (non-disabled interactive options)
The public names can change. The important constraint is that “options” is not one mutable array.
Filtering and autocomplete
Support three query behaviors:
filter: remove non-matches and optionally rank matches.
navigate: keep the list intact and move the active option to the best match.
manual: expose query changes while the application provides the resulting options.
Filter functions should return match metadata, not only booleans, so ranking and highlighted text can reuse the same work. The core should ship a small text filter; fuzzy matching can be a separate entry point if its size is material.
The query model needs at least:
inputValue: what is currently in the editable input.
query: the value used by local derivation or remote loading.
completion: optional inline completion derived from the active match.
IME composition must suppress selection and filtering transitions until composition is committed.
Grouping and faceted input
Grouping belongs in the option pipeline. Faceting and aggregation usually belong upstream. For example, Table can produce faceted values and counts while Select presents them.
- Groups may come from nested source data or a grouping function.
- Group headers are presentational/navigational nodes, not selectable options by default.
- Selected options remain resolvable when filtering or grouping excludes them from the visible model.
- Select should accept count and facet metadata on source options without needing to calculate it.
- A later faceting feature is reasonable only if independent Select use cases justify owning that derivation.
State and actions
Suggested state shape:
type SelectState<TValue> = {
selection: SelectionEntry<TValue>[]
inputValue: string
query: string
isOpen: boolean
activeOptionId: string | null
focusedSelectionId: string | null
filters: SelectFilterState
expanded: Record<string, boolean>
}
Async request status should be modeled but may live in an async feature rather than the base state.
Every update should carry metadata:
type SelectChangeMeta = {
action:
| 'select-option'
| 'deselect-option'
| 'remove-selection'
| 'clear-selection'
| 'create-option'
| 'set-input-value'
| 'set-query'
| 'set-active-option'
| 'open'
| 'close'
reason: 'keyboard' | 'pointer' | 'touch' | 'focus' | 'programmatic' | 'outside' | 'form-reset'
optionId?: string
selectionId?: string
originalEvent?: unknown
}
Each state slice should support the TanStack controlled-state pattern: initial state, external state, a slice callback, and a whole-state callback. A reducer/transition hook remains useful for changing default behavior, but consumers should not need it for routine control.
Interaction contract
The interaction layer should be a state machine tested independently from every framework adapter.
Default behavior
- Opening chooses a deterministic active option: selected option when appropriate, otherwise the first navigable option.
- Arrow keys open and move through navigable options.
- Home/End move to boundaries. Page Up/Down move by a configurable page calculation, not a magic fixed offset.
- Shift-range selection extends an anchor through the navigable option model when enabled.
- Enter commits the active option while open and never submits a form when it performed a select action.
- Escape closes and optionally restores the pre-open input value; a second Escape may clear according to policy.
- Tab closes without trapping focus. Whether it commits an active option is explicit policy and off by default.
- Backspace edits text first; with an empty input in multiple mode, it focuses or removes the last selected occurrence according to policy.
- Pointer movement may activate options without stealing DOM focus from the input.
- Disabled options are never committed and are skipped by keyboard navigation.
- Single commit closes by default. Multiple commit keeps the popup open and clears the query by default.
- A query/model change reconciles the active option by ID before falling back to the first navigable match.
Accessibility
The adapter should implement and test the WAI-ARIA combobox/listbox patterns, including:
- Stable IDs and
aria-controls, aria-expanded, aria-activedescendant, and aria-selected relationships.
- Correct single- versus multi-select listbox semantics.
- Labels, descriptions, validation state, required state, and disabled/read-only state.
- Live announcements for result counts, selection changes, creation, loading, and failures without double announcements.
- Portaled listboxes while DOM focus remains on the combobox input.
- Virtualized active options that remain represented for assistive technology.
- High-contrast and reduced-motion compatibility in any optional UI examples.
The supported markup patterns should be explicit. A prop getter cannot guarantee accessibility if consumers attach it to an arbitrary element with an incompatible role.
Focus strategy should be an explicit first-party feature rather than one universal implementation:
- Editable comboboxes generally keep DOM focus on the input and use
aria-activedescendant.
- Always-visible listboxes and compatible select-only patterns should be able to use real DOM focus with roving
tabindex.
- Adapters may select or constrain the strategy by widget pattern and platform because VoiceOver and Safari behavior is not uniform.
This is the largest greenfield part of the project. It needs a real assistive-technology matrix, not only ARIA attribute assertions.
Async data
Async behavior should be supported without making TanStack Query a dependency.
manualFiltering lets query changes drive external fetching.
- Loading, refreshing, empty, and error are distinct states.
- Results are associated with a query/request identity so stale responses cannot replace newer ones.
- Aborted requests are not failures.
- Selected unresolved values can be hydrated independently from the visible result page.
- Pagination and infinite loading append or reconcile by option ID.
- Cached data may remain visible while a new query loads.
- Creation may be sync or async and needs idle, validating, pending, success, and error outcomes.
Documentation should include TanStack Query integration, but the core contract should work with any data source.
Creatable values
Creation should be a feature with an explicit candidate:
type CreateCandidate<TValue> = {
inputValue: string
value?: TValue
label: string
status: 'valid' | 'invalid' | 'pending'
reason?: string
}
The consumer controls parsing, normalization, duplicate detection, validation, and persistence. The candidate can appear in the navigable model but cannot collide with an option ID. On success it may select a returned option/value; on failure it preserves input and exposes the error.
Virtualization and performance
- Core operations use ID maps and memoized model stages instead of repeated linear lookups where avoidable.
- The core never requires every option to have a mounted element.
- Active-item APIs are ID-first. The visible model can map IDs to indices for a virtualizer.
- Adapters expose a scroll request with option ID, current visible index, alignment, and reason.
- Variable-height and grouped lists must work; Select should not own measurement.
- Filtering can be deferred, indexed, moved to a worker, or made manual.
- Benchmarks should cover 100, 10,000, and 100,000 options, query updates, selection updates, and virtualized navigation.
TanStack Virtual should be the reference integration, not a hard dependency.
Form behavior
The package should define rather than accidentally inherit form semantics:
- Configurable value serialization for hidden inputs or form-associated adapters.
- Multiple values represented without lossy delimiter joining.
- Native form reset restores initial selection and interaction state.
- Required and validation behavior works for single and multiple modes.
- Pressing Enter only prevents form submission when Select handled the key.
- Autofill is documented as supported, limited, or unsupported for each rendering pattern.
Package architecture
Proposed package boundaries:
@tanstack/select-core Framework-agnostic models, features, state, and actions
@tanstack/react-select React adapter and DOM prop getters
@tanstack/solid-select Solid adapter
@tanstack/vue-select Vue adapter
@tanstack/svelte-select Svelte adapter
Optional text-filter utilities may live in core entry points if tree-shaking is reliable. Positioning, popovers, animation, and virtualization should stay integrations.
Core feature modules should contribute state, defaults, instance methods, option methods, and model stages. Likely built-in features are:
- Selection
- Query/filtering/ranking
- Availability
- Active-option navigation
- Active-descendant and roving-focus strategies
- Range selection
- Grouping/expansion
- Creation
- Async/manual data
The public feature object should select and validate first-party features. We should not promise arbitrary third-party feature definitions in v1; that extension surface is difficult to evolve and is not required for tree-shaking.
Public API sketches
These examples illustrate the intended layers, not settled naming. The API should use predictable families: get*Model for derived data, get*Props for DOM bindings, verbs such as selectOption for actions, and on*Change callbacks with action metadata. That consistency should make the library legible to both people and coding agents.
Simple select
The common path should infer { value, label } options and support uncontrolled state.
const select = useSelect({
options: [
{ value: 'red', label: 'Red' },
{ value: 'blue', label: 'Blue' },
],
defaultValue: 'red',
})
return (
<>
<button {...select.getTriggerProps()}>
{select.getSelectedOption()?.label}
</button>
{select.getIsOpen() && (
<ul {...select.getListboxProps()}>
{select.getVisibleOptionModel().flatOptions.map(option => (
<li key={option.id} {...select.getOptionProps(option)}>
{option.label}
</li>
))}
</ul>
)}
</>
)
Select owns state and interaction. The consumer owns markup, styling, positioning, and animation.
Arbitrary domain data and controlled state
Applications should not need to convert domain objects into library-owned wrappers.
const select = useSelect({
options: users,
getOptionId: user => user.id,
getOptionValue: user => user.id,
getOptionLabel: user => user.name,
getOptionText: user => `${user.name} ${user.email}`,
getOptionDisabled: user =>
user.status === 'suspended' ? 'Account suspended' : false,
value: selectedUserId,
onValueChange: (value, meta) => setSelectedUserId(value),
})
select.getVisibleOptionModel().flatOptions.map(option => (
<div key={option.id} {...select.getOptionProps(option)}>
<UserResult user={option.original} />
</div>
))
The option ID is engine identity, the value is application state, and original preserves the source object.
Feature-composed multi-select combobox
Advanced behavior should be explicit and tree-shakable.
const features = selectFeatures({
combobox: comboboxFeature,
selection: multipleSelectionFeature({
allowDuplicates: true,
rangeSelection: true,
}),
filtering: optionFilteringFeature,
focus: activeDescendantFeature,
filteredOptionModel: getFilteredOptionModel(),
filterFns: { fuzzy },
})
const select = useSelect({
features,
options: people,
getOptionId: person => person.id,
getOptionValue: person => person.id,
getOptionLabel: person => person.name,
filterFn: 'fuzzy',
state: { selection, query },
onSelectionChange: (updater, meta) => setSelection(updater),
onQueryChange: (updater, meta) => setQuery(updater),
})
return (
<div {...select.getControlProps()}>
{select.getSelections().map(selection => (
<span
key={selection.id}
{...select.getSelectionProps(selection)}
>
{selection.getOption()?.label ?? String(selection.value)}
<button {...select.getRemoveSelectionProps(selection)}>Remove</button>
</span>
))}
<input {...select.getInputProps()} />
</div>
)
Duplicate occurrences have distinct selection IDs even when their option and value are equal. Omitting comboboxFeature produces a select-only control. Choosing rovingFocusFeature instead of activeDescendantFeature changes focus behavior where the widget pattern permits it. Invalid feature/model combinations should fail at compile time.
Manual async search with TanStack Query
Select coordinates query and interaction state without owning fetching.
const usersQuery = useQuery({
queryKey: ['users', search],
queryFn: ({ signal }) => fetchUsers({ search, signal }),
})
const select = useSelect({
features: selectFeatures({
combobox: comboboxFeature,
filtering: manualFilteringFeature,
focus: activeDescendantFeature,
}),
options: usersQuery.data ?? [],
query: search,
onQueryChange: setSearch,
loading: usersQuery.isLoading,
error: usersQuery.error,
})
Select owns input, active-option reconciliation, loading/error bindings, announcements, and unresolved selections. Query owns fetching, caching, cancellation, retry, and stale data.
Table-produced faceted filter
Table owns facet derivation; Select owns presentation and selection interaction.
const statusCounts = table
.getColumn('status')
.getFacetedUniqueValues()
const statusOptions = Array.from(statusCounts, ([status, count]) => ({
status,
count,
}))
const select = useSelect({
options: statusOptions,
getOptionId: option => option.status,
getOptionValue: option => option.status,
getOptionLabel: option => option.status,
value: table.getColumn('status').getFilterValue(),
onValueChange: value =>
table.getColumn('status').setFilterValue(value),
})
select.getVisibleOptionModel().flatOptions.map(option => (
<div key={option.id} {...select.getOptionProps(option)}>
<span>{option.label}</span>
<span>{option.original.count}</span>
</div>
))
TanStack Virtual bridge
Select should request scrolling by stable option ID rather than owning measurement.
const visibleOptions = select.getVisibleOptionModel().flatOptions
const virtualizer = useVirtualizer({
count: visibleOptions.length,
getScrollElement: () => listRef.current,
})
select.onScrollRequest(request => {
const index = visibleOptions.findIndex(
option => option.id === request.optionId,
)
virtualizer.scrollToIndex(index, { align: request.align })
})
virtualizer.getVirtualItems().map(item => {
const option = visibleOptions[item.index]
return (
<div key={option.id} {...select.getOptionProps(option)}>
{option.label}
</div>
)
})
The core never requires every option to have a mounted element.
Inspectable model and actions
The instance should make its state and models directly inspectable for debugging, devtools, tests, and agent-authored integrations.
select.getState()
select.getOption(optionId)
select.getSelectedOptionModel()
select.getVisibleOptionModel()
select.getNavigableOptionModel()
select.open({ reason: 'programmatic' })
select.setQuery('lin', { reason: 'programmatic' })
select.setActiveOption(optionId, { reason: 'programmatic' })
select.selectOption(optionId, { reason: 'programmatic' })
select.clearSelection({ reason: 'programmatic' })
Required examples
Examples are part of the product specification:
- Native-looking single select.
- Searchable combobox.
- Multiple select with removable tags and duplicate occurrences.
- Creatable select with async validation.
- Grouped and disabled options.
- Table-produced faceted filter picker.
- Remote search with loading, stale results, error, and pagination.
- 100,000-option virtualized select using TanStack Virtual.
- Controlled state and custom transition behavior.
- Portaled popup in a dialog.
- Form submission, validation, and reset.
- Right-to-left layout and IME input.
Each behavior example should use unstyled semantic markup first. Styled examples can be separate.
Verification strategy
Core tests
- Identity remains stable across reorder, filter, grouping, and async replacement.
- Every model stage composes correctly and memoizes on unrelated state changes.
- Single, multiple, duplicate, unresolved, disabled, and created selections behave losslessly.
- Controlled and uncontrolled state produce the same transitions.
- Action metadata identifies every cause.
- Stale async results and failed creation cannot corrupt selection.
Interaction conformance
Use a shared behavior suite against every adapter:
- Keyboard matrices for closed/open, empty/non-empty query, single/multiple, and LTR/RTL.
- Pointer, touch, focus-within, outside dismissal, portals, and dialogs.
- IME composition and mobile input.
- Screen-reader-oriented ARIA assertions and automated accessibility checks.
- Virtualized active-option and scroll coordination.
- Form submit/reset/validation behavior.
Type tests
- Arbitrary option and value types.
- Single/multiple mode inference.
- Nullable and unresolved values.
- Custom accessors and updater callbacks.
- Framework adapter prop types and ref types.
Delivery plan
Phase 0: reconcile the RFCs
- Mark each item
required for v1, designed now/shipped later, or out of scope.
- Resolve the open decisions below before public API work.
- Turn examples into executable acceptance fixtures.
Phase 1: core model and state
- TypeScript package skeleton, static feature object, prerequisite validation, and TanStack Store-backed state slices.
- Option identity/accessors and core option model.
- Ordered selection entries, unresolved value resolution, and controlled state.
- Query, availability, filtering/ranking, and navigable models.
- Typed actions and transition metadata.
Phase 2: React interaction adapter
- Combobox/listbox prop getters and focus/dismissal state machine.
- Single, multiple, duplicate, disabled, and creatable behavior.
- Portal and form support.
- Shared conformance suite and unstyled examples.
React is the proving adapter because the existing behavior is React-based, not because the core may rely on React.
Phase 3: scale and data features
- TanStack Virtual integration and benchmarks.
- Manual/remote filtering, request identity, pagination, and unresolved-value hydration.
- Grouping, expansion, and range selection.
- TanStack Query integration guide.
Phase 4: additional adapters and release
- Solid, Vue, and Svelte adapters running the shared behavior suite.
- Documentation organized by concepts, APIs, and examples.
- Bundle, performance, accessibility, browser, and type-compatibility gates.
- Prerelease feedback before stabilizing v1.
Remaining open decisions
- Is v1's public abstraction an instance with option models, framework primitives/components, or both?
- Do we expose selection entries publicly, or keep them internal except when duplicate occurrences require them?
- What is the default value equality rule, and when is
getOptionId required?
- Which state slices are core versus optional features?
- Which model stages beyond filtering and ranking are required for v1?
- Do nested options and arbitrary grouping share one model, or remain separate features?
- Should inline completion ship in core or as adapter-derived behavior?
- How much accessible markup flexibility can prop getters safely support?
- Do adapters expose only prop getters, optional unstyled primitives, or both?
- Is async request state in core, or is core limited to manual data plus documented integrations?
- Should create parsing return a value, an option, or an async result that can contain either?
- What are the exact close, query-reset, active-option, and Escape defaults for each mode?
- Which browser and assistive-technology matrix blocks v1?
- Do we expose custom third-party feature definitions in v1? This RFC recommends no; the first-party feature object remains public.
Definition of v1
V1 is ready when a consumer can build a production-grade, accessible, unstyled single or multiple combobox over arbitrary typed data; control any important state; search locally or remotely; create and resolve values; render through a portal; virtualize a large option model; integrate with forms; and rely on deterministic behavior covered by a cross-framework conformance suite.
Grouping, hierarchical models, and richer async helpers may be delivered incrementally, but their model boundaries must be present before the core API is stabilized. Otherwise the rewrite will reproduce the ceiling of a conventional combobox library instead of becoming TanStack Select.
TanStack Select RFC: concept inventory and rewrite direction
Status: Reconciled draft
Scope: Product and architecture direction, not a final API
Source: The current implementation, its history, repository feedback, and Kevin Van Cott's initial architecture assessment
Summary
TanStack Select should be a headless, framework-agnostic engine for building accessible, collection-based selection inputs.
A sufficient select is a small data grid. It needs stable item identity, derived models, filtering and ranking, grouping, selection models, keyboard navigation, async data, virtualization, and controlled state. Table solves many of the data problems that make selects powerful; it solves almost none of the accessibility and focus problems that make selects hard. The rewrite should use the same broad separation that makes TanStack Table work:
This is not a proposal to turn Select into a styled component library or to make Table a dependency. It is a proposal to give Select a similarly capable collection core and a purpose-built interaction layer.
Product contract
The package should make simple selects easy without limiting complex ones. Its irreducible responsibilities are:
Scope boundary
Stable initial scope
Designed later
Out of scope
Documentation should be native-first. If a native
<select>meets the product's requirements, recommend it before a custom control.Opinions already encoded in the current library
These are the concepts to preserve. Their current names and implementations are not commitments.
valueandonChangesearchValue,isOpen, andhighlightedIndexstateReducerwith action typesmultiduplicatesand removal by indexcreateandgetCreateLabelfilterFnvisibleOptionsscrollToIndexHistorical feedback worth accommodating
Repository issues also exposed needs that were not fully implemented:
These should enter the design backlog, but they do not have the same status as behavior already present in the library.
Concepts that should not survive literally
{ value, label }must not be the only accepted data shape.setTimeout.optionsRefthat represents only one DOM subtree is insufficient for portaled composite controls.Relationship to Table v9 and the TanStack ecosystem
Select should replicate the proven shape of Table v9 without importing Table or extracting a shared generic framework prematurely:
The feature graph is a typed assembly mechanism, not permission for features to own arbitrary interaction behavior. Cross-cutting keyboard, focus, and ARIA invariants remain coordinated by the interaction layer. We should let Table and Select converge as separate implementations before deciding whether any feature infrastructure belongs in a shared package.
TanStack Store should be an internal dependency. Form, Table, Virtual, Pacer, Query, and Hotkeys should integrate by composition without becoming core dependencies.
The market position is not merely another Downshift. It is a multi-framework, Table-grade option engine with accessible interaction bindings, fine-grained subscriptions, inspectable models, and no required rendering or positioning system.
Option and selection models
Source option
Users should pass arbitrary objects. Accessors derive the fields Select requires.
getOptionIdis the engine identity.getOptionValueis the application payload. They are deliberately different: multiple options may share a value, values may not be strings, and selected occurrences may repeat.Derived option
The core wraps each source option with stable derived behavior and metadata, similar to a Table row.
The exact methods are open, but the model must expose stable identity, source data, derived state, and actions.
Selection entry
Committed multi-selection is an ordered list of occurrences.
optionIdmay be unresolved when a value is restored before its option is loaded.iddistinguishes duplicate occurrences. Applications may continue to receive plain values in the common API; entries are the lossless internal model and advanced API.Selection equality must be configurable with
isValueEqual, with referential orObject.issemantics as a documented default. Serialization is a separate concern.Option-model pipeline
Each stage should be memoizable, replaceable, and observable. Features may skip stages with
manual*options, following familiar TanStack conventions.The public names can change. The important constraint is that “options” is not one mutable array.
Filtering and autocomplete
Support three query behaviors:
filter: remove non-matches and optionally rank matches.navigate: keep the list intact and move the active option to the best match.manual: expose query changes while the application provides the resulting options.Filter functions should return match metadata, not only booleans, so ranking and highlighted text can reuse the same work. The core should ship a small text filter; fuzzy matching can be a separate entry point if its size is material.
The query model needs at least:
inputValue: what is currently in the editable input.query: the value used by local derivation or remote loading.completion: optional inline completion derived from the active match.IME composition must suppress selection and filtering transitions until composition is committed.
Grouping and faceted input
Grouping belongs in the option pipeline. Faceting and aggregation usually belong upstream. For example, Table can produce faceted values and counts while Select presents them.
State and actions
Suggested state shape:
Async request status should be modeled but may live in an async feature rather than the base state.
Every update should carry metadata:
Each state slice should support the TanStack controlled-state pattern: initial state, external state, a slice callback, and a whole-state callback. A reducer/transition hook remains useful for changing default behavior, but consumers should not need it for routine control.
Interaction contract
The interaction layer should be a state machine tested independently from every framework adapter.
Default behavior
Accessibility
The adapter should implement and test the WAI-ARIA combobox/listbox patterns, including:
aria-controls,aria-expanded,aria-activedescendant, andaria-selectedrelationships.The supported markup patterns should be explicit. A prop getter cannot guarantee accessibility if consumers attach it to an arbitrary element with an incompatible role.
Focus strategy should be an explicit first-party feature rather than one universal implementation:
aria-activedescendant.tabindex.This is the largest greenfield part of the project. It needs a real assistive-technology matrix, not only ARIA attribute assertions.
Async data
Async behavior should be supported without making TanStack Query a dependency.
manualFilteringlets query changes drive external fetching.Documentation should include TanStack Query integration, but the core contract should work with any data source.
Creatable values
Creation should be a feature with an explicit candidate:
The consumer controls parsing, normalization, duplicate detection, validation, and persistence. The candidate can appear in the navigable model but cannot collide with an option ID. On success it may select a returned option/value; on failure it preserves input and exposes the error.
Virtualization and performance
TanStack Virtual should be the reference integration, not a hard dependency.
Form behavior
The package should define rather than accidentally inherit form semantics:
Package architecture
Proposed package boundaries:
Optional text-filter utilities may live in core entry points if tree-shaking is reliable. Positioning, popovers, animation, and virtualization should stay integrations.
Core feature modules should contribute state, defaults, instance methods, option methods, and model stages. Likely built-in features are:
The public feature object should select and validate first-party features. We should not promise arbitrary third-party feature definitions in v1; that extension surface is difficult to evolve and is not required for tree-shaking.
Public API sketches
These examples illustrate the intended layers, not settled naming. The API should use predictable families:
get*Modelfor derived data,get*Propsfor DOM bindings, verbs such asselectOptionfor actions, andon*Changecallbacks with action metadata. That consistency should make the library legible to both people and coding agents.Simple select
The common path should infer
{ value, label }options and support uncontrolled state.Select owns state and interaction. The consumer owns markup, styling, positioning, and animation.
Arbitrary domain data and controlled state
Applications should not need to convert domain objects into library-owned wrappers.
The option ID is engine identity, the value is application state, and
originalpreserves the source object.Feature-composed multi-select combobox
Advanced behavior should be explicit and tree-shakable.
Duplicate occurrences have distinct selection IDs even when their option and value are equal. Omitting
comboboxFeatureproduces a select-only control. ChoosingrovingFocusFeatureinstead ofactiveDescendantFeaturechanges focus behavior where the widget pattern permits it. Invalid feature/model combinations should fail at compile time.Manual async search with TanStack Query
Select coordinates query and interaction state without owning fetching.
Select owns input, active-option reconciliation, loading/error bindings, announcements, and unresolved selections. Query owns fetching, caching, cancellation, retry, and stale data.
Table-produced faceted filter
Table owns facet derivation; Select owns presentation and selection interaction.
TanStack Virtual bridge
Select should request scrolling by stable option ID rather than owning measurement.
The core never requires every option to have a mounted element.
Inspectable model and actions
The instance should make its state and models directly inspectable for debugging, devtools, tests, and agent-authored integrations.
Required examples
Examples are part of the product specification:
Each behavior example should use unstyled semantic markup first. Styled examples can be separate.
Verification strategy
Core tests
Interaction conformance
Use a shared behavior suite against every adapter:
Type tests
Delivery plan
Phase 0: reconcile the RFCs
required for v1,designed now/shipped later, orout of scope.Phase 1: core model and state
Phase 2: React interaction adapter
React is the proving adapter because the existing behavior is React-based, not because the core may rely on React.
Phase 3: scale and data features
Phase 4: additional adapters and release
Remaining open decisions
getOptionIdrequired?Definition of v1
V1 is ready when a consumer can build a production-grade, accessible, unstyled single or multiple combobox over arbitrary typed data; control any important state; search locally or remotely; create and resolve values; render through a portal; virtualize a large option model; integrate with forms; and rely on deterministic behavior covered by a cross-framework conformance suite.
Grouping, hierarchical models, and richer async helpers may be delivered incrementally, but their model boundaries must be present before the core API is stabilized. Otherwise the rewrite will reproduce the ceiling of a conventional combobox library instead of becoming TanStack Select.