Let’s create a Generic Select component in React & TypeScript

Miroslav Petrik
6 min readJul 6, 2023

--

Photo by Bodie Pyndus on Unsplash

With React we have a plethora of choices how to get a Select component.
Likely our UI library contains one. If not, we usually install a headless component like react-select or the one in radix-ui and ‘theme’ it with our styles. In the first case, our select can even be integrated with a form API like in ant-design . With the latter we must add the wiring to the form API ourselves.

But sometimes, a dead simple native select is enough. Like the ones in chakra-ui or flowbite-react. No autocomplete, no custom dropdown, no animated arrows etc.

When we decide that it’s ok to use the native select, there is one thing we might need — generic data selection. In this article we will walk step-by-step over the process of creating such “generic native select” component.

What is a generic select?

Having a generic select means that it accepts options which can be of any shape.

Let’s say you want to enable users to choose their favorite fruit:

const fruits = [
{label: “appricot”, emoji: 🍑},
{label: “blueberry”, emoji: 🫐},
{label: “chery”, emoji: 🍒}
];

<Select options={fruits} />

or a person from the contact list:

const contacts = [
{id: 1, name: “Ted”},
{id: 2, name: “Alf”}
// etc
];

<Select options={contacts} />

Note, that the data don’t share any common properties. Our options can even be primitive values:

// Select with 2 options is a terrible UX, but we want this to work
const YesNoSelect = () => <Select options={[true, false]} />

You might wonder how the options will get rendered — there will be more props soon!

Targeting the <select> element

The <Select> component itself will simply attach a hook with all the logic into the markup:

function Select<Option>({
props
}: UseSelectParams<Option>) {
const selectProps = useSelect(props);

return <select {…selectProps}></select>;
}

We will follow the best practice of encapsulating logic into reusable hooks. The hook will be a separate module:

function useSelect<Option>(params: UseSelectParams<Option>): UseSelect<Option>{}

Now, let’s focus on the contract between our hook and the <select> element. Since we are controlling the input, we must define the value and onChange() props inUseSelect which will get returned from the hook. We could try…

type UseSelect<Option> = {
value: Option;
onChange: (event: ChangeEvent<HTMLSelectElement>) => void
}

…but looking into the React types, we find the SelectHTMLAttributes where the value prop is constrained:

interface SelectHTMLAttributes<T> extends HTMLAttributes<T> {
value?: string | ReadonlyArray<string> | number | undefined;
}

This means that we cannot pass our generic value to the native <select>! For example the case of Select<boolean> with options [true, false] from above wouldn’t work as TypeScript will stop us since the boolean type does not overlap with the union string | ReadonlyArray<string> | number | undefined.

In other words the native <select> is not generic. We must serialize or map our options into an acceptable type. Since our option list is an indexable array, we will use the option index as the select value. The return type will change to:

type UseSelect = {
value: number;
onChange: (event: ChangeEvent<HTMLSelectElement>) => void
}

With our contract defined we are halfway there. Now we can define the body of our hook. But before that, let’s render the options!

Rendering the options list

The useSelect() hook will be responsible for controlling the <select>. We will have another hook, responsible for rendering the options. Having single-purpose hooks like this increases their reusability — for example the useSelect() hook can be easily reused to build a <RadioGroup> component, which will render its options as a list of <input type="radio"> instead of <option> .

To render each option, we must turn the generic Option into string which can be used as <option>’s children. For that we will need getLabel() prop:

type UseSelectOptionsParams<Option> = {
options: readonly Option[];
getLabel: (option: Option) => string;
};

To render the options, we simply map the list into <option> elements, while we call the getLabel() with each option:

function useSelectOptions<Option>({
options,
getLabel
}: UseSelectOptionsParams<Option>) {
return (
<>
{options.map((option, index) => (
<option key={index} value={index}>
{getLabel(option)}
</option>
))}
</>
);
}

NOTE: We are using the index as the React key to keep things simple. Even though the best practice would be to use option id (that would be realized with a prop analogical to the getLabel() e.g. getKey()). The index is fine in this case, as our component is stateless and the native <option> will have only plain string children. Or in other words, it won’t render children with local state like <input> which might cause bugs when the options are re-ordered or filtered. You can read more on the dangers of using the index as the React key.

Finally we can use the useSelectOptions() hook inside the <Select> to render the options:

function Select<Option>({
activeOption,
options,
onChange,
getLabel,
}: UseSelectParams<Option> & UseSelectOptionsParams<Option>) {
const selectProps = useSelect({ activeOption, options, onChange });
const selectOptions = useSelectOptions({ options, getLabel });

return <select {…selectProps}>{selectOptions}</select>;
}

Now we have the options rendered inside the select, but the useSelect() isn’t doing anything yet. Let’s fix that.

Providing an initially selected option

Since our <Select> will be controlled, we must have a prop for the currently selected option:

type UseSelectParams<Option> = {
selectedOption: Option;
options: readonly Option[];
}

Now we can get the value simply as computing the index in the options list:

function useSelect<Option>({selectedOption, options}: UseSelectParams<Option>): UseSelect {
return {value: options.indexOf(selectedOption)};
};

Note, that our select cannot be empty. In practice we will likely need an optional select. Before we extend the select with optional functionality, let’s finish the required one.

Handling the ChangeEvent

Now that the parent component can initialize the the select via selectedOption, it also needs to listen for the changes in the value originating from the user events. Thus we need one more prop — the onChange() callback:

type UseSelectParams<Option> = {
selectedOption: Option;
options: readonly Option[];
onChange: (option: Option) => void
}

The callback must be called with an actual Option — a member of the options list. This is the point of having a generic code. To accomplish that, we need the final piece — an event handler where we turn the option index into the actual option:

function useSelect<Option>({selectedOption, options, onChange}: UseSelectParams<Option>) {
const onChangeCallback = useCallback(
(event: ChangeEvent<HTMLSelectElement>) => {
/**
* With the TypeScript noUncheckedIndexedAccess flag enabled
* the type will be “Option | undefined”!
*/
const selectedOption = options[event.currentTarget.selectedIndex];

if (selectedOption !== undefined) {
/**
* Note we must check explicitly for undefined, as falsy values
* like 0, or false can be in the options array!
*/
onChange(selectedOption);
}
},
[options, onChange]
);

return {
value: options.indexOf(selectedOption),
onChange: onChangeCallback
};
};

For simplicity, we are using the selectedIndex property, which requires that our options are rendered in the same order as they are present in the options array. Since we are using the useSelect() hook in tandem with the useSelectOptions() this is guaranteed to be so.

Concrete example with controlled component

To put all of this together, we can now create a concrete ‘business’ component, which will use the generic <Select>.

Going back to our examples, we will use boolean[] options for brevity:

const options = [true, false] as const;
const getLabel = (option: boolean) => (option ? "Satisfied" : "Unsatisfied");

export function SatisfactionSelect() {
const [selectedOption, setSelectedOption] = useState(true);

return (
<label>
<span>How do you feel right now?</span>
<Select
selectedOption={selectedOption}
options={options}
getLabel={getLabel}
onChange={setSelectedOption}
/>
</label>
);
}

This will render:

<select value=”0">
<option value=”0">Satisfied</option>
<option value=”1">Unsatisfied</option>
</select>

For a working example, checkout this CodeSandbox. You can find out where the SelectHTMLAttributes or ChangeEvent interfaces come from.

Where to go next

In this post we’ve created “generic & native <select>” elegantly just by using the array indexes. I call it “native” because we are rendering the base <select> which sadly is not generic in itself. Practically the implementation is quite limited. It can be enough for simple use cases where we style our app with some pure CSS framework.

Given the select <option>shave indexes as values, it’s required for the component to be always controlled. In the example above we’ve only stored the value inside of useState(). In a real application with many inputs, it would be tedious and impractical to control each input manually. What if the <Select> was already controlled & connected to a data store? That’s the aim of theform-atoms-field library. It’s based on the jotai state management library because it makes it easy to realize the best practice of having each form input controlled.

As we have lightly touched in the article, one limitation of our implementation is the optionality of the selectedOption. What would be the necessary code changes, to support it being undefined?

type UseSelectParams<Option> = {
// the "?" enables selectedOption to be undefined:
selectedOption?: Option;
// …
}

The generic <Select> in the form-atoms-field already supports the optionality of value, enabling users to leave the select empty. Perhaps we can implement it step-by-step in the next article…

Thank you for reading.

--

--

Miroslav Petrik
Miroslav Petrik

Written by Miroslav Petrik

Typescript engineer delivering end-to-end solutions.

No responses yet