Skip to main content

React select element with dynamic option list

Mark Lynskey
Front-end developer at BNZ

The select element is a trusty staple of web experiences. It’s a powerfully efficient way of allowing users to make a selection, be it a small handful of options, or a great many. However, it’s not without its quirks, and there’s one scenario you need to be aware of when using it with React.

A controlled select component

When using React, it’s common to create “controlled” form controls. This is where instead of relying on the browser, its value is dictated by a value you explicitly pass to it, which is often stored as state. You then give it a change handler, which sets your state to the new value when the user interacts with it.

For a select element, we can do this by passing itvalue and onChange props.

More info

Read more on controlled select elements: Controlling a select box with a state variable

Most of time, the list of options to choose from wouldn’t change. But there are cases where this may not be true, and the list is dynamic – for example if the list depends on previously entered form data, or if the list is fetched from a data source subject to change.

Example

Let’s look at an example:

In our example we can add and remove fruits to and from the list, and we can choose one to be ✨the chosen one✨. We are logging out the state of the chosen one at the bottom for our reference.

For the most part, it works well! But by playing around and experimenting, you may notice some funky behaviour:

  • Scenario 1: what happens if we select a fruit, but then remove it from the list?
  • Scenario 2: what happens if we remove all items from the list?

Scenario 1

In the default state of our example, “Apples” is selected. But if we then remove “Apples” from the list, we see that the select element displays “Oranges”.

We may presume that that is a reasonable thing to do – just selecting the next item in the list – but we can see from our reference that the state is actually still “Apples”! And if we then open the select and select “Oranges”, it still hasn’t changed anything!

However, we can see that we can successfully change the state to “Pears” and “Strawberries”, and then back to “Oranges”. 🤔

Scenario 2

Moving on to the second scenario, if we reset our example and remove all fruits from the list, we see that there is nothing to choose from in the select dropdown – makes sense.

But the state however, is still “Apples”. This is definitely not what we want – we want the selection to always be a valid one – that is, one from the list. And if there is no list, we’d want the select to be in an perhaps an unselected state, or a disabled state.

So what’s happening?

Let’s have a look at what’s causing this behaviour to happen.

Scenario 1

In the first scenario:

  1. We are removing the currently selected fruit from the list – “Apples”. This change in state triggers a re-render of the App.
  2. Our select element no longer has “Apples” as an option, as the options have updated in the re-render.
  3. But our selected state is still “Apples”, as nothing has caused this state to change – no handler has been called to do so.
  4. Even though “Apples” is still being passed as the value, the browser falls back to the first available option from the option list, “Oranges”, as “Apples” is not an option. This however is only visual – and is a default behaviour of browsers.
  5. If we then select another option however, “Pears” or “Strawberries”, this triggers the onChange handler to select a valid option – so it’s back to business as usual.
Pitfall

This is just the way browsers handle the scenario when the value passed does not match an option – it falls back to display the first <option> in its list. This causes what we see on the screen to be out-of-sync with the state of things internally.

Scenario 2

Let’s now look at the second scenario:

  1. One-by-one, the user removes all of the options in the list. Each removal triggers a re-render, resulting in the same behaviour as the previous scenario.
  2. Once there are no options left in the list, the select element with then render with no <option> elements. “Apples” is still being passed as the value, but does not do anything.
  3. The select element renders, but has no display text. No dropdown display when it is clicked.
  4. So, we have a select element that looks empty and broken, set to a value that no longer exists and that the screen doesn’t reflect anymore.

But wait there’s more! 🥲 With our empty list, if we add a fruit, say “Blueberries”, the select element visually shows “Blueberries”, even though the value is still “Apples”! As before, this is because the browser falls back to the first available option, which is “Blueberries”, currently the only option.

And as there is only one option, onChange cannot be triggered here either, meaning our handler cannot be called to update our selected state to it. If we add another option though, and select that, the onChange handler will be fired, and our selected state updated. Whew.

How to fix it

This is not a bug with the select element, browsers, or React. It is just the way they behave, and we are expected to account for it. When we are working with controlled components, React expects us to know what we are doing, and account for any scenarios that may arise.

So let’s update our example to be more robust, and to provide a nicer user experience. Here’s what we’ll do:

Step 1: Define a deselected state

In our example, “Apples” is selected by default. Having a default selection can work in many cases where the list is set-in-stone, and especially if one option is much more likely to be set than others.

It is quite common for select elements to have a deselected state, that they often default to, with display text such as “Select” or “Choose”. This implementation would be much better for our use case, so that we can default back to it when the selected option is removed from the list.

This is a safer user experience too – we don’t want it to reassign itself to a value without the user explicily doing so. By deselecting it, client-side validation can then alert the user that they need to select an option again.

We will use undefined as our unselected state, as it provides good semantics to say that the user’s selection is not defined, and is much easier to use it with TypeScript and validators that using an empty string, or null.

const [selected, setSelected] = useState(undefined);

Step 2: Default select component to deselected

Next, we’ll make our select component deselected by default, and have it display “Choose …”.

<select
className="selectFruit"
value={selected ?? ""}
onChange={(event) => setSelected(event.target.value)}
>
<option value="" disabled>
Choose …
</option>
{options.map((option) => (
<option key={option} value={option}>
{option}
</option>
))}
</select>
Pitfall

Note how we are passing in selected ?? "" as the value of the select element. This is because passing in undefined to the value prop of a controlled element is illegal and would imply that the element is uncontrolled, rather than controlled. Components are not allowed to switched between controlled and uncontrolled, and React will render a warning. So we are using an empty string as the value, when no option is selected.

We are rendering in an <option> to display the default, descelected state. The value of this is "" to match that which we receive from the <select> value when selected is undefined. We have also disabled it – this means that a user cannot explicitly select it.

Step 3: Add an effect to check that the state is valid

Next, we want to check that whenever the options list changes, that the selected state is still valid. If it is not, we set the state back to the deselected state.

useEffect(() => {
if (!options.includes(selected)) {
setSelected(undefined);
}
}, [options, selected]);

Step 4: Handle case where there is no options

When there are no options to choose from, theres’s a few approaches we could take:

  • Disable the select element
  • Only render the select element if there is two or more options
  • Change the text “Choose …” to notify that there are no options available.

A simple and accessibile solution here would be to change the text content of our default option to be “No options available”. This lets the user know upfront that there are no options to select, and is accessible to screen readers that would be reading its value.

<option value="" disabled>
{options.length > 0 ? "Choose …" : "No options available"}
</option>
Accessibility of disabled and conditional form fields

It is generally advised to avoid disabled form fields and buttons where possible. Default disabled styles from browsers are often quite dim, and this causes contrast issues in regards to accessiblity. There is also the question of if a control is not interactible, then why is it there.

Conditional form fields, ones that may render or not depending on what other form fields are set to, are also best avoided where possible. There have been studies that reveal that users are not always aware when new fields pop up on a screen, and may not be sure if they are relevant, especially for users of screen readers.

Read more:

Final solution

And that’s it! Here is our updated solution.

Now we have a component that:

  • Has a deslected state by default, prompting the user to choose
  • Checks that the state is valid each time the list changes, and if it is not valid, sets it back to the deselected state
  • Lets the user know if there are no options currently to choose from.

Let me know if you’ve ever been caught out by this quirk! Happy coding! 🤓