May 9, 2022: DataStation 0.10.0 is released! Read more
Blog

Controlled HTML select element in React has weird default UX

Published on by

When you create a controlled input in React with a blank value, the UI will show nothing in the textbox.

function Input({ value, onChange }) {
  return (
    <input type="text" value={value} onChange={e => onChange(e.target.value)} />
  );
}

What about select elements?

function Select({ value, onChange, children }) {
  return (
    <select value={value} onChange={e => onChange(e.target.value)} children={children} />
  );
}

... in use ...

const [value, setValue] = React.useState('');
<Select value={value} onChange={setValue}>
  <option value="ford">Ford</option>
  <option value="toyota">Toyota</option>
</Select>

If you pass this blank value into a Select, the UI will look as if Ford is selected.

And to make things more confusing (if you as a developer know that it only looks like Ford is selected), you open the dropdown and select Ford again, the onChange handler is not going to get called. I am not sure exactly why that happens but it points at the UI and React state being out of sync.

Basically, weird things happen if you pass in a value to select that isn't a value of one of its options.

The right situation for a select element is that it should always have a valid value. There are two ways I can think of dealing with this: 1) hope every caller of Select picks a valid initial value or 2) use an effect to trigger the passed in onChange with a valid default value. Here's what the latter could look like.

function Select({ value, onChange, children }) {
  React.useEffect(() => {
    if (!value || !children) return;

    const values = React.Children.map(children, (i) => i ? i.props.value : null).filter(Boolean);
    if (values.length && !values.includes(value)) {
      onChange(values[0]);
    }
  }, [value]);

  return (
    <select value={value} onChange={e => onChange(e.target.value)} children={children} />
  );
}

This technique is what I ended up using in DataStation. The effect is only ever called once to initialize the value. It gets rid of all the out-of-sync weirdness if a blank value is passed in.

The only thing I'm not sure of is that children is not included in the list of the effect's dependencies. I assume React takes care of re-rendering the entire component if children change but I am not sure.

I'm curious to hear what you think or how you've tackled this.

With questions, criticism or ideas, email or Tweet me.

Also, check out DataStation and dsq.