primitives icon indicating copy to clipboard operation
primitives copied to clipboard

[Select] Unable to clear value and return to `placeholder`

Open dungle-scrubs opened this issue 3 years ago • 4 comments

I'm playing with the new Select placeholder prop, and am wondering if the following is possible?

As an example, let's say you have a series of 3 selects for an automobile and the placeholder values for each are "make", "model", and "year". When you select a new "make" (after having already selected both a "make" and a "model") and the underlying list for "model" is updated, the component still technically has a value based on the previous list's selection, therefore the placeholder doesn't render.

Is there a way to reset value when updating the list?

dungle-scrubs avatar Jul 25 '22 13:07 dungle-scrubs

Hey @dungle-scrubs I imagine this is all possible via controlled props though I'm not 100% clear on what you're describing, would you be able to provide a sandbox showing your attempted solution?

andy-hook avatar Jul 25 '22 20:07 andy-hook

Hi @andy-hook I'll try to get a working example but it might take a day or two (sorry about that, I'm out of time right now). In the meantime, I can describe it better:

<Select.Root>
  <Select.Trigger>
    <Select.Value placeholder="make"/>
  </Select.Trigger>
  <Select.Portal>
    <Select.Content>
      <Select.Viewport>
        {apiResponse.makes?.map((item, i) => {
          return (
            <Select.Item value={item} key={i}>
              <Select.ItemText>{item.text}</Select.ItemText>
            </Select.Item>
          )
        })}
      </Select.Viewport>
    </Select.Content>
  </Select.Portal>
</Select.Root>

<Select.Root>
  <Select.Trigger>
    <Select.Value placeholder="model"/>
  </Select.Trigger>
  <Select.Portal>
    <Select.Content>
      <Select.Viewport>
        {apiResponse.models?.map((item, i) => {
          return (
            <Select.Item value={item} key={i}>
              <Select.ItemText>{item.text}</Select.ItemText>
            </Select.Item>
          )
        })}
      </Select.Viewport>
    </Select.Content>
  </Select.Portal>
</Select.Root>

<Select.Root>
  <Select.Trigger>
    <Select.Value placeholder="year"/>
  </Select.Trigger>
  <Select.Portal>
    <Select.Content>
      <Select.Viewport>
        {apiResponse.years?.map((item, i) => {
          return (
            <Select.Item value={item} key={i}>
              <Select.ItemText>{item.text}</Select.ItemText>
            </Select.Item>
          )
        })}
      </Select.Viewport>
    </Select.Content>
  </Select.Portal>
</Select.Root>

Looking at the placeholder values, these only render if value or defaultValue are not set.

If you've already selected a "model", but then apiResponse.models changes and a different list is rendered, the Select still holds the previously selected value (and that value might not correspond to an item in the new api response).

So at that point in time, if the items have changed, I would expect value to reset so that placeholder can render "model" again.

Thinking about this further though, I think this could be solved on my end with the logic, "if the data set is empty, set the value as undefined".

dungle-scrubs avatar Jul 26 '22 02:07 dungle-scrubs

@andy-hook Same with me ... When I select value then clear value to undefined SelectPrimitive still show previous value

<SelectPrimitive.Trigger asChild aria-label={ariaLabel}>
  <Button>
    <SelectPrimitive.Value
      placeholder={
        <span className="text-primary-500">{placeholder}</span>
      }
    />
    <SelectPrimitive.Icon className="ml-2">
      {allowClear ? (
        <span
          onPointerDown={(e) => {
            e.preventDefault();
            e.stopPropagation();
            props.onValueChange(undefined);
          }}
        >
          <Cross2Icon width={14} height={14} />
        </span>
      ) : (
        <CaretSortIcon width={14} height={14} />
      )}
    </SelectPrimitive.Icon>
  </Button>
</SelectPrimitive.Trigger>

pepelele avatar Jul 27 '22 07:07 pepelele

Thanks both for the examples, I can confirm this on my side. I'm going to mark this as a bug to look into.

In the meantime one workaround could be to forego placeholder and instead render an option with value="" and control / clear to it using setValue('')

andy-hook avatar Jul 27 '22 08:07 andy-hook

Bumping this, as its a pretty huge roadblock for us. A native "clear" functionality would be appreciated, but at a minimum, setting value to null/undefined needs to be possible to truly call this controlled.

localyost3000 avatar Nov 17 '22 21:11 localyost3000

Is there any update on this issue?

auduongtuan avatar Dec 23 '22 16:12 auduongtuan

I no longer think this is an issue as I just learned something about React I didn't know before.

Taking a look at the new beta docs, under the section, "Resetting all state when a prop changes", it turns out that if you add a key prop to any component, React will reset all of the state inside of that component and all of its children when that key changes.

Taking that advice, I made this sandbox where you can add/remove that key prop to see the effect.

Btw, In those docs, the page, "You Might Not Need an Effect" has really changed the way I think about and build components.

Cheers

dungle-scrubs avatar Dec 24 '22 02:12 dungle-scrubs

AFAICT, the internal Radix useControllableState hook switches between controlled and uncontrolled through value === undefined:

https://github.com/radix-ui/primitives/blob/91763d2ed7d84e03e0e6f1307a7a29d9c7b04433/packages/react/use-controllable-state/src/useControllableState.tsx#L17-L19

So that means that once you've given the value any non-undefined value, it is impossible to go back to undefined, as that will only cause the component to go to uncontrolled mode, and use the last value before undefined as its current value.

Moreover, you also can't just pass value={null}, because the placeholder check also uses a value === undefined check:

https://github.com/radix-ui/primitives/blob/91763d2ed7d84e03e0e6f1307a7a29d9c7b04433/packages/react/select/src/Select.tsx#L337

The only workaround I can see is the aforementioned one with changing the key to force full re-creation of the component and its hooks lifecycle.

cprecioso avatar Feb 07 '23 13:02 cprecioso

I've just ran into this myself, attempting to select a filter on a page search and then attempting to clear that filter. Unfortunate!

Edit: Here's my solution

  1. Create a key, and have a controlled value
const [key, setKey] = useState<number>(+new Date())
const [value, setValue] = useState<string>()
  1. Assign key to Select.Root
<Select.Root
  key={key}
  value={value}
  onValueChange={setValue}
  className='relative'
>
  {/* rest of select */}
</Select.Root>
  1. Add clear button within Select.Root
<button
  className='absolute transform -translate-y-1/2 right-3 top-1/2'
  type='button'
  onClick={e => {
    e.stopPropagation()
    setValue(undefined)
    setKey(+new Date())
  }}
>
  <XMarkIcon />
</button>

As suggested by cprecioso this forces the select to fully re-render. In my case I'm using react-hook-form to control the value of the select- dumbed down example above.

dextermb avatar Feb 17 '23 15:02 dextermb

I've also run into a similar issue, while using remix, of not being able to reset the placeholder after the form submission. I'm getting a formRef and resetting it after submission, but the Select doesn't reset. I found that adding a key to the Form component and resetting this after form submission did the trick. So similar to these other solutions but at the Form component level.

mattwendzina avatar Mar 23 '23 14:03 mattwendzina

Don't know it it is still needed but just add a dummy value :

<SelectItem value={"none"}>DDD</SelectItem> and revert back to it.

atirim-jcrew avatar May 05 '23 17:05 atirim-jcrew

The select component assumes it's being uncontrolled when value is undefined, which makes it effectively impossible to introduce an empty state in a controlled way. Ie. if you use some state that is initially undefined and do some validation on callback to onValueChange which may prevent the outside state from changing, it won't work: the select will update itself, even though value prop stays undefined.

Solution for that would be to allow null as value which indicates an empty state in a controlled way.

I believe it's a bug. Workarounds proposed in this issue don't work for me, as changing the key to rerender the entire component does not make sense in my case (and is not an idiomatic way to do things in react and impairs performance).

I tried introducing a dummy value and styling it like a placeholder, but it appears in the dropdown next to real items. If I use styles to hide it, the dropdown cannot position itself correctly.

wnadurski avatar May 19 '23 07:05 wnadurski

I've created a PR which forces the placeholder appears when the selected value doesn't match with any items.

Therefore, passing an empty string or dummy value will work as expected.

fuma-nama avatar May 28 '23 07:05 fuma-nama

The select component assumes it's being uncontrolled when value is undefined, which makes it effectively impossible to introduce an empty state in a controlled way. Ie. if you use some state that is initially undefined and do some validation on callback to onValueChange which may prevent the outside state from changing, it won't work: the select will update itself, even though value prop stays undefined.

Solution for that would be to allow null as value which indicates an empty state in a controlled way.

I believe it's a bug. Workarounds proposed in this issue don't work for me, as changing the key to rerender the entire component does not make sense in my case (and is not an idiomatic way to do things in react and impairs performance).

I tried introducing a dummy value and styling it like a placeholder, but it appears in the dropdown next to real items. If I use styles to hide it, the dropdown cannot position itself correctly.

I'm struggling with same thing right now and found that null works as a charm to clear the field, but typescript doesn't aproves that, have you found another workaround ? Or will wait @SonMooSans 's PR ?

bzenky avatar May 29 '23 18:05 bzenky

If you don't mind about it, just simply add @ts-ignore. (Since you know what you're doing)

The problem is that null doesn't display the placeholder, it might be kinda "hacky" to replace <Select.Value /> with the placeholder when value is null. That's why the PR was created.

fuma-nama avatar May 29 '23 18:05 fuma-nama

If you don't mind about it, just simply add @ts-ignore. (Since you know what you're doing)

The problem is that null doesn't display the placeholder, it might be kinda "hacky" to replace <Select.Value /> with the placeholder when value is null. That's why the PR was created.

Well observed, I got some select's with and without placeholder and tested only the without one's, like you said, it's gonna be really hacky, hope they take on soon your PR

bzenky avatar May 30 '23 11:05 bzenky

Hey all, thank you for all your feedback on this. I have been working on a PR for this: #2174, let me know if you see any objections.

Note this will be technically a major because it could be a breaking change if people have resolved to use a Select.Item with value="".

benoitgrelard avatar May 30 '23 14:05 benoitgrelard

Hi, I encountered a similar problem with react-hook-form and resolved it by creating a custom hook specifically for this issue

function useValueKey(value: string | undefined | null): string | number {
  const [prevValue, setPrevValue] = useState(value)
  const [key, setKey] = useState(0)

  if (value !== prevValue) {
    setPrevValue(value)
    setKey(k => k + 1)
  }

  return key
}

// use case
const key = useValueKey(props.value)
// assign key to the corresponding values.

dskiba avatar Oct 23 '23 14:10 dskiba

Hi, I encountered a similar problem with react-hook-form and resolved it by creating a custom hook specifically for this issue

function useValueKey(value: string | undefined | null): string | number {
  const [prevValue, setPrevValue] = useState(value)
  const [key, setKey] = useState(0)

  if (value !== prevValue) {
    setPrevValue(value)
    setKey(k => k + 1)
  }

  return key
}

// use case
const key = useValueKey(props.value)
// assign key to the corresponding values.

In v2, you can just reset to "" and the placeholder will be displayed

joaom00 avatar Oct 23 '23 14:10 joaom00

@dextermb , thanks for the example https://github.com/radix-ui/primitives/issues/1569#issuecomment-1434801848. Following resets controlled select input to its default value. Using react-hook-form.

const fieldValue = form.watch('fieldName')

<Select.Root
  key={fieldValue}
  ...
>
  {/* rest of select */}
</Select.Root>

HMoen avatar Dec 23 '23 01:12 HMoen

In v2, you can just reset to "" and the placeholder will be displayed

Thank you!

ThePiyushAggarwal avatar Apr 17 '24 13:04 ThePiyushAggarwal

Using react-hook-form works for me:

export interface Props {
  ...
  onChange(value: string): void;
  value?: string;
}

export function SelectInput(props: Props) {
  return (
    <Select.Root
      onValueChange={(value) => {
        props.onChange(value);
      }}
      value={props.value || ""}
    >

In the component:

<SelectInput
...
onChange={(value) => setValue("gender", value)}
value={watch("gender")}
/>
function clearFilter() {
   ...
    setValue("gender", undefined)
  }

Daniflav94 avatar Apr 17 '24 15:04 Daniflav94