Skip to content

[combobox] Add getValueFromItem prop#4097

Closed
atomiks wants to merge 6 commits into
mui:masterfrom
atomiks:codex/combobox-get-value-from-item
Closed

[combobox] Add getValueFromItem prop#4097
atomiks wants to merge 6 commits into
mui:masterfrom
atomiks:codex/combobox-get-value-from-item

Conversation

@atomiks

@atomiks atomiks commented Feb 16, 2026

Copy link
Copy Markdown
Contributor

Fixes #3865
Fixes #3951
Closes #3853

Smaller and more flexible alternative to #3954. This explicitly supports object items with primitive value.

<Combobox.Root> needs to know what is passed to <Combobox.Item value={...}>value could be a primitive string or number, or the whole item object (when items is an array of objects). This new prop getValueFromItem lets it know explicitly.

This can't be inferred automatically except in two cases:

  • { value, label } is the shape of the items objects
  • defaultValue/value aren't null

getValueFromItem supports arbitrary object shapes and the initial null value.

<Combobox.Root> will error if getValueFromItem is missing and value/defaultValue don't match the shape of items:

// @ts-expect-error - primitive selected values with object items require `getValueFromItem`
<Combobox.Root items={objectItems} defaultValue="a" />;
// @ts-expect-error - primitive selected values with object items require `getValueFromItem`
<Combobox.Root items={objectItems} value="a" />;
// @ts-expect-error - primitive selected values with object items require `getValueFromItem`
<Combobox.Root items={numberObjectItems} defaultValue={1} />;
// @ts-expect-error - primitive selected values with object items require `getValueFromItem`
<Combobox.Root items={numberObjectItems} value={1} />;

This does require synchronizing <Combobox.Item value={...} and getValueFromItem together. I added a wrapper example in the spec file.

The getValueFromItem name is differentiated from the other mappers (itemToStringValue, itemToStringLabel) as those ones operate on item values, not item objects.


Explanation why this is needed:

  • Combobox.Root owns the selection state and matching logic (selectedIndex, highlight, input label), so it must know the selected value shape before items are interacted with.
  • items can be objects, but the selected value can be either:
    • the full object, or
    • a primitive extracted from it (id, value, etc.).
      Root cannot guess which contract you intend.
  • Combobox.Item value={...} is not a reliable source for automatic inference at Root level: values can be dynamic, transformed, grouped, virtualized, or rendered in different order.
  • In single mode, initial value/defaultValue can be null, so type/value-shape cannot be inferred from those props at mount time.
  • The { value, label } object shape is the only safe convention Base UI can special-case automatically; arbitrary object shapes are not inferable.
  • getValueFromItem gives Root an explicit mapping from item object -> selected value, so all internal comparisons and label resolution stay consistent.
  • It also improves TypeScript correctness: the mapper’s return type defines the selected value type and catches mismatches early.

@atomiks atomiks added type: new feature Expand the scope of the product to solve a new problem. component: combobox Changes related to the combobox component. labels Feb 16, 2026
@pkg-pr-new

pkg-pr-new Bot commented Feb 16, 2026

Copy link
Copy Markdown

commit: 48d2b22

@mui-bot

mui-bot commented Feb 16, 2026

Copy link
Copy Markdown

Bundle size report

Bundle Parsed size Gzip size
@base-ui/react 🔺+882B(+0.19%) 🔺+417B(+0.28%)

Details of bundle changes


Check out the code infra dashboard for more information about this PR.

@netlify

netlify Bot commented Feb 16, 2026

Copy link
Copy Markdown

Deploy Preview for base-ui ready!

Name Link
🔨 Latest commit 85dde84
🔍 Latest deploy log https://app.netlify.com/projects/base-ui/deploys/69ae7b367eb31500092188a2
😎 Deploy Preview https://deploy-preview-4097--base-ui.netlify.app
📱 Preview on mobile
Toggle QR Code...

QR Code

Use your smartphone camera to open QR code link.

To edit notification comments on pull requests, go to your Netlify project configuration.

@atomiks
atomiks force-pushed the codex/combobox-get-value-from-item branch 6 times, most recently from d844dfe to 5dd5f3f Compare February 16, 2026 07:10
@atomiks
atomiks marked this pull request as ready for review March 2, 2026 11:04

@mnajdova mnajdova left a comment

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

First quick pass, should the name of the prop be itemToValue, to be closer to existing props lie itemToStringValue, itemToStringLabel etc?

@atomiks

atomiks commented Mar 3, 2026

Copy link
Copy Markdown
Contributor Author

@mnajdova I explained why it's different:

The getValueFromItem name is differentiated from the other mappers (itemToStringValue, itemToStringLabel) as those ones operate on item values, not item objects.

So:

  • <Combobox.Item value={ItemObject.Primitive}> -> itemToStringValue, itemToStringLabel receive Primitive
  • <Combobox.Root items={ItemObject[]}> -> getValueFromItem receives ItemObject

itemToValue would be incongruent with the other mappers if you pass items={objects} but <Combobox.Item value={primitive}> since the other mappers receive the primitive. (Those mappers are intended to be used only with objects as values for items.)

@mnajdova

mnajdova commented Mar 4, 2026

Copy link
Copy Markdown
Member

Ok, too late to rename those, maybe add it in the v2 ideas. Restructuring the name to have get, doesn't make it more clear that they are receiving different thing as an argument. Ideally those should be named: itemValueToXXX in my opinion.

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 5, 2026
@atomiks
atomiks force-pushed the codex/combobox-get-value-from-item branch from 5dd5f3f to a0096f6 Compare March 9, 2026 07:01
@github-actions github-actions Bot removed the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 9, 2026
@atomiks

atomiks commented Mar 9, 2026

Copy link
Copy Markdown
Contributor Author

Codex Review

Overview

This patch adds getValueFromItem so comboboxes can keep object items while storing a different selected value shape. It also updates filtering, label resolution, selected-index sync, typing, and tests so that mapped-value flow behaves the same way as the existing combobox paths.

Findings (None)

No blocking issues found in this patch.

Confidence: 4/5

High confidence based on a full branch pass, the expanded regression coverage around mapped values, and a follow-up perf check after the latest simplification. Residual risk is mostly around edge-case tradeoffs in very large lists with custom equality.

Notes

  • Latest change: the extra mapped-item arrays and index map are now only built when getValueFromItem is actually provided, and the default item path uses the older direct selected-label lookup again.
  • Revalidated with pnpm test:jsdom ComboboxRoot --no-watch, pnpm test:chromium ComboboxRoot --no-watch, and pnpm typescript.
  • Re-ran the 10,000-item mount + rerender check against master after the latest perf fix. The earlier always-on common-path slowdown is no longer consistent across repeated runs:
Run Scenario master mean (ms) head mean (ms)
1 Primitive items 3.326 3.498
1 Object items 3.034 2.992
2 Primitive items 5.241 4.770
2 Object items 3.835 4.751

@github-actions github-actions Bot added the PR: out-of-date The pull request has merge conflicts and can't be merged. label Mar 11, 2026
@michaldudak

Copy link
Copy Markdown
Member

Code review

No issues found. Checked for bugs and CLAUDE.md compliance.

🤖 Generated with Claude Code

- If this code review was useful, please react with 👍. Otherwise, react with 👎.

@atomiks

atomiks commented Mar 16, 2026

Copy link
Copy Markdown
Contributor Author

Had a long discussion with Codex then fed it into GPT-5.4 Pro to analyze. Here are the conclusions.

GPT-5.4 Pro Overview - Combobox keyed values with object items

Summary

Combobox APIs are easiest to understand when the following all have the same type:

  • items
  • value / defaultValue
  • <Combobox.Item value={...}>

That model works well for:

  • primitive options such as string[]
  • object options where the selected value is the whole object

A common application shape does not fit that model as cleanly:

  • items is an array of objects
  • the controlled selection is a primitive key such as an id
  • the UI still needs a label from the object

Example:

const users = [
  { id: '1', name: 'Ada' },
  { id: '2', name: 'Grace' },
];

<Combobox.Root items={users} defaultValue="2">
  {users.map((user) => (
    <Combobox.Item key={user.id} value={user.id}>
      {user.name}
    </Combobox.Item>
  ))}
</Combobox.Root>;

In this shape, the selected value is not the same thing as the source item. A Combobox that wants to support this cleanly needs an explicit way to derive the selected key from an item.

Why a new prop is necessary

Real application data is often normalized

Many applications store data like this:

const post = { authorId: '2' };
const authors = [
  { id: '1', name: 'Ada' },
  { id: '2', name: 'Grace' },
];

The UI wants a Combobox bound to post.authorId, but the available items are full author objects.

This is a normal and useful shape because it keeps state small and stable:

  • IDs are easy to serialize
  • IDs are easy to compare
  • IDs avoid referential equality problems
  • IDs fit well in forms, URLs, and normalized caches

Labels and identity are different concerns

When items are objects, Combobox usually needs at least two distinct pieces of information:

  • identity: which option is selected?
  • label: what text should be shown in the input, used for filtering, or announced to assistive tech?

If the selected value is the whole object, those concerns can often be resolved from the same value.
If the selected value is only a primitive key, they cannot.

That means a stringification prop is not enough on its own. A keyed mode needs:

  • a way to derive the selected key from an item
  • a way to derive display text from an item

Existing label/value props are doing different jobs

A prop that converts a value to a string is not the same as a prop that derives the selected identity from an item.

Those are separate responsibilities:

  • converting a selected value into a string for form submission
  • converting an item into a label for display/filtering
  • converting an item into the selected key

Trying to overload one existing prop to do all three makes the API harder to understand.

Why Base UI needs an explicit keyed mode

Base UI is more flexible than many Combobox libraries because it supports both:

  • a root items prop
  • explicit <Combobox.Item value={...}> parts

That flexibility also makes the relationship between source items and selected values more important to define clearly.

In same-shape mode, the rules are simple:

  • items, value, and <Combobox.Item value> all line up

In keyed mode, they do not:

  • items contains objects
  • value is a primitive key
  • the rendered item label still comes from the object

That relationship needs to be stated explicitly in the API rather than emerging indirectly from a combination of label, equality, and serialization props.

What other libraries do

Most libraries avoid this exact problem by choosing one primary model.

Library Source collection Controlled selected value Typical model
React Aria object items with keys key / selected key key-based selection
MUI Autocomplete options collection usually the full option object object-first
React Select options collection usually the full option object(s) object-first
Downshift items collection usually the full selected item object-first
Headless UI Combobox rendered options primitive or object value same value shape as each option

React Aria

React Aria is the clearest mainstream example of first-class keyed selection:

  • items can be objects
  • selection is represented by a key
  • labels are still derived from item content

This is the closest precedent for supporting object items with primitive selected values directly.

MUI Autocomplete

MUI Autocomplete is generally object-first:

  • options is usually an array of objects
  • value is usually the selected option object
  • getOptionLabel handles display
  • isOptionEqualToValue handles equality when references are unstable

When app state stores only an ID, the common pattern is to do an id -> object lookup before passing value into the component, then convert back to an ID in onChange.

React Select

React Select also usually keeps the public controlled value as the option object or array of option objects.

It exposes helpers like:

  • getOptionLabel
  • getOptionValue

But getOptionValue does not generally mean the public value prop becomes a primitive key API. It is primarily used for option identity and internal behavior while the controlled value still typically uses option objects.

Downshift

Downshift is also usually item-first:

  • selectedItem is the selected item
  • itemToString converts that item into display text

If application state stores only an ID, users typically resolve the selected item outside the component.

Headless UI Combobox

Headless UI typically uses the same value shape as each option:

  • primitives stay primitives
  • objects stay objects

If objects are used, comparison helpers can change how equality works, but the selected value still remains object-shaped.

Why Base UI is different

Base UI sits in a slightly unusual spot because it combines:

  • collection-driven data on the root
  • explicit item values in the JSX tree

That makes a keyed mode both more possible and more worth defining clearly.

Other libraries often push the normalization boundary outward:

const selectedAuthor = authors.find((author) => author.id === post.authorId) ?? null;

<Autocomplete
  options={authors}
  value={selectedAuthor}
  onChange={(_, author) => setAuthorId(author?.id ?? null)}
/>

That approach works, but it means the component API is object-first even when the application state is key-first.

A Base UI keyed mode could support the normalized shape directly instead:

<Combobox.Root value={post.authorId} items={authors} />

That is a meaningful API distinction.

Why overloading an existing prop is not enough

It may be tempting to avoid a new prop by changing how an existing prop is interpreted.

For example, one idea is to make an existing stringification prop double as the place where the selected key is inferred.

That has a few problems:

  1. The name becomes misleading. A prop that sounds like string conversion would now also define identity.
  2. The label problem remains. Even if the selected key can be inferred, the component still needs a label from the item.
  3. The rules become hard to explain. The API would quietly switch from same-shape mode to keyed mode based on how a stringification prop is used.
  4. It mixes separate concerns. Identity, display, filtering, and form serialization are related, but they are not the same thing.

If keyed mode is supported, it is better to make that mode explicit.

Recommended direction

The smallest clear addition is a dedicated prop for deriving the selected key from an item.

For example:

<Combobox.Root
  items={users}
  defaultValue="2"
  itemValue="id"
  itemToStringLabel={(item) => item.name}
>
  {users.map((user) => (
    <Combobox.Item key={user.id} value={user.id}>
      {user.name}
    </Combobox.Item>
  ))}
</Combobox.Root>

Or function form:

<Combobox.Root
  items={users}
  defaultValue="2"
  itemValue={(item) => item.id}
  itemToStringLabel={(item) => item.name}
>
  {users.map((user) => (
    <Combobox.Item key={user.id} value={user.id}>
      {user.name}
    </Combobox.Item>
  ))}
</Combobox.Root>

Semantics

Without itemValue:

  • items, value, and <Combobox.Item value> stay in the same shape
  • existing behavior stays unchanged

With itemValue:

  • items becomes source data
  • value becomes the selected key
  • <Combobox.Item value> uses that selected key shape
  • label lookup continues to come from the item object

Tradeoffs

Benefits

  • supports normalized application state directly
  • avoids forcing users to rehydrate full objects before rendering
  • avoids duplicated object snapshots in component state
  • keeps primitive selected values easy to serialize and compare
  • makes the object-items + primitive-value case intentional rather than accidental

Costs

  • adds a new concept to the API
  • requires docs to explain two modes clearly
  • means Base UI must define how selected-label lookup works in keyed mode
  • increases the number of combinations between items, value, and item rendering

Alternative: object-first only

A simpler alternative is to keep Combobox fully same-shape and recommend object-first usage:

const selectedAuthor = authors.find((author) => author.id === post.authorId) ?? null;

<Combobox.Root
  items={authors}
  value={selectedAuthor}
  itemToStringLabel={(author) => author.name}
  itemToStringValue={(author) => author.id}
  isItemEqualToValue={(item, selected) => item.id === selected.id}
>
  {authors.map((author) => (
    <Combobox.Item key={author.id} value={author}>
      {author.name}
    </Combobox.Item>
  ))}
</Combobox.Root>

This is a valid model, and it matches what many libraries encourage.

Its downsides are:

  • users must do id -> object lookup outside the component
  • state may duplicate data already present in items
  • stale labels become a question if selected objects are partial or snapshotted
  • async data loading can get awkward when the selected object is not yet available

Final recommendation

If Base UI wants to keep the API strictly same-shape, it should document that clearly and treat object-items + primitive-selected-value as out of scope.

If Base UI wants to support normalized application data directly, it should add an explicit keyed mode.

The cleanest minimal version of that is:

  • keep the existing same-shape model as the default
  • add one prop that derives the selected key from an item, such as itemValue
  • continue using a label prop for display/filtering
  • keep identity and string serialization as separate concepts

That makes the most common normalized-data use case first-class without requiring a larger API redesign.

@atomiks

atomiks commented Jun 10, 2026

Copy link
Copy Markdown
Contributor Author

Replacing with #4985

@atomiks atomiks closed this Jun 10, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

component: combobox Changes related to the combobox component. PR: out-of-date The pull request has merge conflicts and can't be merged. type: new feature Expand the scope of the product to solve a new problem.

Projects

None yet

4 participants