react-spectrum
react-spectrum copied to clipboard
Setting Picker Item key prop causes keys to be coerced to a string regardless of the actual type
Provide a general summary of the issue here
The Picker component supports items with numeric id / key props.
Item.key unset
If the Item.key
prop is not explicitly set, keys get derived from the id
or key
props on the items array.
e.g.
function Example() {
let items = [
{ id: 1, name: 'Aardvark' },
{ id: 2, name: 'Cat' },
{ id: 3, name: 'Dog' }, ];
let [animalId, setAnimalId] = React.useState(null);
return (
<>
<Picker
label="Pick an animal"
items={items}
onSelectionChange={setAnimalId}
value={animalId}
>
{ /* Item.key not explicitly set */
(item) => <Item>{item.name}</Item>
}
</Picker>
<p>Animal id type: {typeof animalId}</p>
</>
);
}
The onSelectionChange
handler will pass a numeric key to setAnimalId
(consistent with the items data) and value
+ defaultValue
will work with values of type number
.
Item.key explicitly set
If the Item.key
prop is explicitly set e.g.
<Item key={item.id}>{item.name}</Item>}
The onSelectionChange
will pass a string id to setAnimalId
, and the value
+ defaultValue
props will require values of type string
in order to work.
🤔 Expected Behavior?
I would expect in the case of setting Item.key
prop explicitly:
-
onSelectionChange
should pass the actual value (not the stringified version) of the original id / key -
value
, anddefaultValue
should be able to match the id / key prop on the items instead of requiring them to be string values
😯 Current Behavior
When Item.key
prop is explicitly set
-
onSelectionChange
passes a stringified value of the original id / key -
value
, anddefaultValue
don't match the items id / key prop unless they are stringified
💁 Possible Solution
No response
🔦 Context
We have a wrapper component that needs to explicitly set the Item.key
prop based on properties on the items
. In cases where these props are numeric
or boolean
types, this forces the input value
+ onSelectedValue
change handlers to work with strings which doesn't match the original data.
🖥️ Steps to Reproduce
https://codesandbox.io/p/sandbox/modern-shadow-znqpwf
Version
3.34.1
What browsers are you seeing the problem on?
Chrome
If other, please specify.
No response
What operating system are you using?
MacOS
🧢 Your Company/Team
No response
🕷 Tracking Issue
No response
if you change from key
to id
, it should work. I'm not entirely sure the reason why key
doesn't, but I know we're trying not to use it in favor of id
so maybe something was overlooked.
I might be terribly wrong here, but I think it has to do with the fact that the key
provided to <Item>
is essentially the underlying React Component's key, and it's extracted as-is in CollectionBuilder
:
https://github.com/adobe/react-spectrum/blob/1293adf00a11ecac84ec9ab19af7dcea970af2b2/packages/%40react-stately/collections/src/CollectionBuilder.ts#L127
https://github.com/adobe/react-spectrum/blob/1293adf00a11ecac84ec9ab19af7dcea970af2b2/packages/%40react-stately/collections/src/CollectionBuilder.ts#L64-L84
Above, element
passed as the first argument of this.getkey()
is the object representation of <Item key={item.id}>
(in our context) containing the provided key
, and we extract this React key verbatim in the first if statement of getKey()
.
Thing is, React always seems to convert the provided key to string. (I found this stack exchange answer). For example if you specify something like <Item key={{hello: 'world'}}>
, you'll see the element object would be of form: {$$typeof: Symbol(react.element), key: '[object Object]', ref: null, props: {…}, type: ƒ, …}
@snowystinger I don't think this has anything to do with id vs key but is a side effect of how the React key
prop handles values as suggsted by @sookmax
It looks like this is actually documented in the React Stately docs which I missed before: https://react-spectrum.adobe.com/react-stately/collections.html#unique-keys
Note that if you do specify a custom key on each Item using the key prop, React will convert the provided key to a string. As a result, all the collection component's key related event handlers and props (e.g. onSelectionChange, selectedKey(s)) will expect and return strings. If you forgo specifying a key on the Item and instead rely on the id or key being defined in your items array, the key related event handlers and props will match what you have in your items array.
This is unfortunate since in my particular use case, I need to use the key
prop since it may not always originate from the same prop on the item data.
Another thought that comes to mind would be if the Picker
component supported an optional getKey
prop that could be used to derive the key from an item. That could allow users to not have to set the Item.key
explicitly and instead control how the component derives the key from the item.
Yes, @sookmax has the correct analysis. I'm not sure I follow why you must use key and can't change to using id though. Could you explain more?
We've discussed a getKey before, I've forgotten the resolution of that discussion. I'll bring it up with the team to see if anyone recalls.
In my case we have implemented Windowed data where data on items is lazy loaded based on scroll position. The item data looks something like:
interface {
key: Key,
data?: { realKey: number }
}
Where the key
prop is a stable incrementing key that is always present, but the data.realKey
is not.
To render this we use:
(item) => <Item key={item.data?.realKey ?? item.key}>{...}</Item>
This lets us incorporate the real key once it exists but fallback to the stable key that is always present if data isn't loaded for the item. I'm not aware of any way to coalesce props using the implicit keys, so have to be explicit.
Would you still be able to use id?
(item) => <Item id={item.data?.realKey ?? item.key}>{...}</Item>
Types don't show that <Item>
supports an id
prop.
That's interesting, I thought it was. It appears to works if you use it, so I'm not sure why it's not in the types. We can look into it.
Maybe I'm doing something wrong, but it doesn't seem to work with id
on <Item>
regardless the missing type?
I tested locally with the following story I temporarily created in Picker.stories.tsx
:
export const PickerTest: PickerStory = {
args: {
onSelectionChange: key => {
console.log(key, typeof key);
},
items: [
{fakeKey: 1, realKey: 11, name: 'Aardvark'},
{fakeKey: 2, name: 'Kangaroo'},
{fakeKey: 3, realKey: 33, name: 'Snake'},
{fakeKey: 4, name: 'Danni'},
{fakeKey: 5, realKey: 55, name: 'Devon'},
{fakeKey: 6, name: 'Ross'},
{fakeKey: 7, realKey: 77, name: 'Puppy'},
{fakeKey: 8, name: 'Doggo'},
{fakeKey: 9, realKey: 99, name: 'Floof'}
],
children: (item: any) => {
return <Item id={item.realKey ?? item.fakeKey}>{item.name}</Item>;
}
}
};
And it gives you the following runtime error when mounted:
The error is inside CollectionBuilder.getKey()
and it's throwing because it only checks the item's (here in variable v
) key
or id
properties. Note that here v
is not the React component <Item>
but rather the data item (here the first one in the list: {fakeKey: 1, realKey: 11, name: 'Aardvark'}
)
@sookmax I assume that will be a bigger issue if key
or id
actually exist on the data since it could get a value that doesn't match the id
set on Item
. @snowystinger this may be more than a TypeScript types problem. I don't think the id
prop quite solves this use case.
@bmingles
In fact, id
set on <Item>
doesn't seem to be considered at all in CollectionBuilder.getKey()
. Only the React key
prop on <Item>
(prioritized) and key
(prioritized) or id
on the data item
are considered as candidates of key:
https://github.com/adobe/react-spectrum/blob/1293adf00a11ecac84ec9ab19af7dcea970af2b2/packages/%40react-stately/collections/src/CollectionBuilder.ts#L64-L84
this may be more than a TypeScript types problem. I don't think the id prop quite solves this use case.
Yeah I agree.
an optional getKey prop that could be used to derive the key from an item. That could allow users to not have to set the Item.key explicitly and instead control how the component derives the key from the item.
This sounds like a good idea to me too.
Thanks for the link to that piece of code @sookmax, I had forgotten that it was only key on the item which was used and that the other options came from the data object.
I think you could make use of the key attribute on the data objects themselves though, or id. And just not use the key prop on Item?
[
{fakeKey: 1, key: 11, name: 'Aardvark'},
{fakeKey: 2, name: 'Kangaroo'},
{fakeKey: 3, key: 33, name: 'Snake'},
{fakeKey: 4, name: 'Danni'},
{fakeKey: 5, key: 55, name: 'Devon'},
{fakeKey: 6, name: 'Ross'},
{fakeKey: 7, key: 77, name: 'Puppy'},
{fakeKey: 8, name: 'Doggo'},
{fakeKey: 9, key: 99, name: 'Floof'}
].map(item => {
if (item.key === undefined) {
return {...item, key: item.fakeKey};
}
return key;
})
@snowystinger that would probably work but for my use case that would require mapping over the entire list any time an item changes which seems not ideal since our lists could be 10K-100K items.
@snowystinger
Yeah you're right in that the user can always reorganize (duplicate) their data to fit the constraints (here in our case, to have fields named id
or key
).
I just thought it would be nicer to give the user a way to specify the field to be used as key, without them having to preprocess their data to generate a possibly duplicate field for key.
But at the same time, I'm not sure how we could go about achieving that. Well roughly maybe would something like this work?
private getKey(item: CollectionElement<T>, partialNode: PartialNode<T>, state: CollectionBuilderState, parentKey?: Key): Key {
if (item.key != null) {
return item.key;
}
if (partialNode.type === 'cell' && partialNode.key != null) {
return `${parentKey}${partialNode.key}`;
}
let v = partialNode.value as any;
if (v != null) {
-- let key = v.key ?? v.id;
++ let key;
++ if (item.props.getKey) {
++ key = item.props.getKey(v);
++ } else {
++ key = v.key ?? v.id;
++ }
if (key == null) {
throw new Error('No key found for item');
}
return key;
}
return parentKey ? `${parentKey}.${partialNode.index}` : `$.${partialNode.index}`;
}
So that user can specify their key like:
(item) => <Item getKey={(item) => item.data?.realKey ?? item.key}>{...}</Item>
@snowystinger that would probably work but for my use case that would require mapping over the entire list any time an item changes which seems not ideal since our lists could be 10K-100K items.
Not ideal, but theoretically your items aren't changing that frequently. So if you're running into an issue, you could speed this up by doing your own caching so that you don't have {...item, key: item.fakeKey}
for every item. Also, this happens in linear time.
@sookmax agreed this may be nice; I'm bringing it up with the team this week, hopefully.
Also of note for anyone coming across this, we do change this in the RAC version to use the id prop instead of the key prop. https://github.com/adobe/react-spectrum/blob/e6923d9473264b972974513dacbcfab5a24d5616/packages/react-aria-components/src/Collection.tsx#L677 apologies for the confusion.
Is there any update?
No updates of note. It's low priority as it has a workaround. The fastest way to see this change would be to submit a PR for further discussion on implementation.
@snowystinger The proposed workaround does not appear to work for the use case I am reporting. Data can change very frequently in which case the mapping is a problem.