Why we finally built our own select component

Bill Wohlers
Candid Health — Engineering
14 min readOct 6, 2023

It’s probably been a while since you thought about a select dropdown. Like UX more generally, you don’t tend to notice one unless it’s bad.

But select components are difficult to get right, mainly because they pack a lot of functionality into small controls that handle the worst type of input — user input. Building a good one is a tireless tug-of-war between engineering and UX tradeoffs, but it’s also a fun project whose product has greatly improved the quality of life of users and developers alike at Candid Health. This post will explore the motivation for the project, some of the interesting software and UX design choices we made, and the valuable lessons that our team learned throughout the process.

Why fix what isn’t broken?

For the most part, our old select component was usable, and did what you expected it to do. But functional software isn’t always good software, and if users could peek behind the curtain, they’d have been alarmed at what they saw.

Our select component was hard to understand, use, or maintain. In fact, it wasn’t even a single component — it was a hodgepodge of imports from npm libraries and wrappers around them for added functionality. The various pieces had confusing interactions and intermingled concerns that led to a reluctance to change or even use the components. Attempts to improve them felt like rearranging deck chairs on the Titanic, so eventually, front-end developers at Candid agreed that the writing was on the wall.

Credit: u/vincentdnl on Reddit

In August, I was tasked with improving the user workflow for selecting payer names and IDs when editing medical claims. At the time, users had to manually enter the exact name or ID in a text box, a tedious flow that was prone to user error. Considering the limited bandwidth of our team, we first floated easier, cursory solutions like linking to an external payer list or reusing our existing select.

We knew, however, that we could only achieve the best engineering and UX outcome with a full rewrite of the select component, a project that we weren’t sure our team had the time to complete while negotiating other priorities. Nevertheless, I had a strong feeling that we should invest the time to do it the right way, and the team trusted my instinct. And so the project began.

Considering all use cases

One reason select components are so complex is that they support lots of related but different behaviors. Some load the options from a backend server, like our payer select would. Some allow users to select multiple values. Some have a search box or allow users to enter their own options (i.e., a combobox). Many — maybe even most — don’t support any of these behaviors. Nevertheless, we needed to support all of them, and we hoped to do it with maximal code reuse.

UI libraries have various names for this type of control, and some implement it in multiple components while others use only one. Our project resulted in a family of components that I’ll refer to collectively as Select.

The family of behaviors/components that Select encompasses

Of all the behaviors that our new component had to support, async loading of options seemed likely to be the hardest. Consider a select that loads options from a list of hundreds of thousands of users. We’d like to store the selected value as a user ID, but IDs aren’t user friendly; the select has to show their names. So when it first loads, it doesn’t just have to load the first page of users to pick from — it also has to load the names of the users you’ve already selected. To make it even worse, this data is usually fetched via different APIs that each have loading and error states. How would we design a simple interface for consumers that each have their own fetching strategies?

Why reinvent the wheel?

The challenge of developing a good select component may be daunting, but it’s far from new. Every component library has one, and many have good ones. At one point, we eyed Material UI’s version, finding it to be robust and easy to use for most of the behaviors we needed to support. On the surface, it seemed like it might reduce engineering effort required, but a closer look revealed a few problems:

  1. Lack of extensibility. Styles can be easy to tweak but hard to fully control. Furthermore, any behaviors that the component doesn’t support natively would be prohibitively difficult to add. Material UI seemed OK for the functionality we knew we needed, but what if we wanted to add a new feature later on?
  2. Bundle size. Even with tree shaking, UI library components can be hefty. For example, adding Material UI’s Autocomplete component increased our bundle by more than 2%, a substantial increase for a single component.

So, unsatisfied with existing solutions, we ultimately opted to build our own.

Non-functional requirements

Besides the challenging functional requirements mentioned above, we had the following engineering goals:

  1. Simplicity. The key to good software, and arguably desirable in its own right. This was especially important considering our previous version was anything but.
  2. Transparency. We want to know how it works, and we want to empower an engineer that joins tomorrow to figure out how it works.
  3. Extensibility. When requirements inevitably change, we should be able to add features with the confidence that we won’t break existing use cases. This is especially important for a fast moving startup like Candid.

These goals were challenging to balance. Functional outcomes almost always come at some cost to non-functional ones, but writing fundamentally good software helps achieve all of them.

Making the problem simpler

When I first set out to design Select, I knew that a simple implementation that enabled all the functionality we needed wouldn’t come easily. But other members of my team had already proposed some interesting ideas that promised to make the intimidating project a bit more approachable.

For example, another developer on our team suggested that we put the input element (for searching or adding custom items) inside the dropdown, rather than in the trigger (the button that opens the dropdown). This simple change never occurred to me before then, but it immediately struck me as much better for managing software complexity — and maybe even for UX, too — because we would avoid all the chaos inherent to a component that is sometimes a text field and sometimes not.

Our old select, with the search input inside the trigger

At one point early on in the design process, a senior engineer on my team, Adam Suskin, pulled me aside to discuss the project in more depth, and he said a few things that completely changed how I looked at the problem.

First, he noted that it would be a serious mistake to build a component that didn’t support asynchronous fetching of options as a first-class use case.

Then he made an interesting observation: a select that enables this use case also enables the normal, non-asynchronous use case, because a normal select is just a special case of an asynchronous select.

This second point was revelatory to me, and I was so interested by it that I tuned out much of the rest of the conversation (sorry, Suskin!).

Not long after that meeting, I recalled a similar observation I’d made while thinking about this problem weeks earlier: a single select (where the user can only pick one option) is also a special case of a multi select. And so I wondered, was every variation of Select that we needed just a special case of another?

But before I could answer that question, we had some design problems to solve.

Design problems are engineering problems

Many companies think of design and engineering as separate concerns. You’ll often see this philosophy reflected in the organization of a typical software team — designers determine how the product should look and behave, and engineers find the best way to build it.

In reality, design and engineering are two types of tradeoffs in software development, which both affect the quality of the final product. Engineering tradeoffs are often less visible but more impactful, even to the end user. For this reason, design and engineering decisions are fundamentally inseparable: one is best made with a deep understanding of the other.

When designing Select, we had to make choices that entailed significant tradeoffs in both domains. For example, a trigger that doubles as a search input seems like natural UX. However — as our old select demonstrated — the engineering tradeoffs were ultimately unjustifiable.

This time around we moved the input to inside the dropdown, thereby accepting the minor detriment to UX in exchange for a significant reduction in engineering complexity. We may not have made this choice if the UX pitfalls were greater, or the engineering benefits were smaller. Good collaboration between designers and engineers on our team ensured that we weighed different priorities equally and ultimately made the right call.

Our new select, with the input inside the dropdown

Another design (and engineering) problem

When I said the old trigger served two purposes, I oversimplified — it actually served three. Besides showing a label for the select (e.g., “Payer names”), it could also show the actual values that were selected (e.g., ”Aetna, United Healthcare”). Of course, the limited width of the trigger meant that you could only ever show one or two values, with a lazy “+3 more” tacked onto the end.

The old multi-select UI

We wanted to enable the user to easily view and remove options they had already selected, without having to scroll through the entire list and, in the worst case, asynchronously load more options.

The natural solution was to move all the selected options to the top of the list, but this presented another problem: if the user has lots of options selected, they would have to scroll down to view search results. This would have been a significant nuisance to users, so to address it, we accepted a similarly significant engineering tradeoff in the form of adding additional state to the component. By default, we render three selected options; if more are selected, the user can click “Show more” to view all of them, or “Clear” to deselect all of them.

The new, improved multi-select UI

Implementation

With the major UX problems resolved, I was ready to start building components. But I first needed to answer the question I posed earlier: is every variation of Select just a special case of another?

After some thinking, I realized that we could combine the two dimensions of special cases:

  • a non-async multi-select is a special case of an async multi-select
  • a single async select is a special case of a multi async select
Hierarchy of composition of the Select component family. An arrow means “is a special case of”.

But where would I start? Fortunately, this hierarchy points directly to the answer. Select is a special case of multi-select, so I would have to build multi-select first. For the same reason, I’d have to build an async select before non-async. The only component that doesn’t inherit from any other is the asynchronous multi-select, so that would be the base component.

As a result, this component would be the least opinionated of the family. Options might be loaded from a server, but they could also be provided immediately. Multiple options might be selected, but we can limit the user to just picking one. There may be a search input, or maybe not. Components further up in the hierarchy would have more opinions and enable more functionality by default.

Let’s examine the actual implementation. I called the base component AsyncMultiSelect, but that’s something of a misnomer because it doesn’t have to load options asynchronously from a server — it just enables that possibility.

Here are the props of this component:

type AsyncMultiSelectProps<TValue extends string | number> = {
value: TValue[];
onChange: (value: TValue[]) => void;
// callback when the search query changes
onSearchChange?: (query: string) => void;
// list of items, including ones already selected
items: {
value: TValue;
label: string;
}[];
loadProps: LoadProps; // more on this soon
maxSelection?: 1 | "unlimited";
allowCustomValues?: string extends TValue ? boolean : false;
}

This interface puts the consumer (the parent component) in control of the following concerns:

onSearchChange: What happens when the search value changes

onSearchChange?: (query: string) => void;

In an async select, the consumer can use this to query a server for search results. For a non-async select, the consumer uses this to filter the items using a client-side text search.

items: Which items to show

For a non-async select, the responsibility falls on AsyncMultiSelect to decide whether to render each option item as selected by the user or in the list of available options.

But for an async select, we might not have all the options at any given time, so AsyncMultiSelect makes no assumption about the completeness of items — it just renders them.

loadProps: What happens when the user wants to load more

For an async select, the consumer will need to know when the user asks to load more items by clicking a button at the bottom of the list of search results.

UI for loading more search results

Initially, I tried to make what to render the consumer’s concern — e.g., it could pass in a ReactNode that would contain the “Load more” button and handle its click event. This would have been a more extensible interface, but the base component wouldn’t have known when more items were being fetched, which presents some subtle but significant UX problems.

Ultimately, I settled on an interface that places much of the control in the consumer’s hands, but enough in AsyncMultiSelect that it knows when the parent is loading:

export type LoadProps =
| {
isLoading: true;
isError: false;
}
| {
isError: true;
isLoading: false;
}
| {
isLoading: false;
isError: false;
canLoadMore: boolean;
onLoadMore: () => void;
};

maxSelection: maximum number of items the user can select

maxSelection?: 1 | "unlimited";

On the surface, this property seems straightforward: multi selects use the default value "unlimited", which allows the user to select as many options as they want; single selects use the value 1, which limits their selection to a single item.

But there’s a trickier part to fully solving the single/multi select reuse problem: types. In AsyncMultiSelect, the type of value is an array of TValue, as is the parameter of onChange. This is usable, but we would really like the props of a single select to be more “singular”, like this:

type SelectProps<TValue extends string | number> = {
value: TValue | undefined;
onChange: (value: TValue) => void;
}

The difference between single select and multi select props leads naturally to the thin wrapper that is the Select component:

function Select<T extends string | number>({ value, onChange, ...rest }: SelectProps<T>) {
const multivalue = value ? [value] : [];
return (
<NewMultiSelect
value={multivalue}
onChange={val => onChange?.(val[0])}
maxSelection={1}
{...rest}
/>
);
}

allowCustomValues: whether to allow the user to enter custom values

allowCustomValues?: string extends TValue ? boolean : false;

The behavioral implications of this prop are straightforward: when enabled, selecting an unselected item adds **********it to the list of selected items, rather than replacing the current value.

What’s more interesting about this prop is its enigmatic type. To better understand it, we have to think of types as sets. In fact, at a high level, that’s all a type really is: a set of possible values. For example, string represents the set of all possible strings, and string | number represents the union of all strings and all numbers.

This mental model helps us reason about some more advanced TypeScript concepts. For instance, when we impose the constraint TValue extends string | number — as we do for Select — we’re saying that TValue must be a subset of (i.e., extends) the union of all strings and numbers.

Aside. It may seem counterintuitive that extends means “is a subset of”, but that’s because extends refers to the size of the interface (e.g. the number of properties on an object), not the size of the set of values it represents. As the interface extends, the set of possible values satisfying that interface shrinks.

Custom values entered in the select’s input can be any string value, so a Select should only be able to allow custom values if its TValue contains all strings — i.e., string extends TValue. With some help from TypeScript’s conditional type operator, we get our final type: string extends TValue ? boolean : false.

In practice, how does this make it safer to use the component? Imagine we have a Select for picking a Color, which is an enum with three values: RED, GREEN, and BLUE. When allowCustomValues is off, TS will infer the type of the value and onChange props, such that when we receive a change event, we know the values are Colors.

But once we turn on allowCustomValues, this tells TS that TValue cannot be Color, because strings aren’t a subset of colors. As a result, the type passed to the change handler loosens to string — which is correct, since the values could now be any arbitrary string.

The last piece of the puzzle

An unopinionated base component is great for code reuse, but all it does is shift responsibilities onto the consumer. For example, if we want to fetch options from a server, the component offers no help since it can’t know how the data is actually fetched. Since I was specifically tasked with solving this problem, I couldn’t accept the difficulty of use of AsyncMultiSelect out of the box.

Fortunately, the solution is simple. AsyncMultiSelect must remain unopinionated, but a utility layer between the consumer and component need not be. So, I built the useAsyncMultiSelect hook, which consumes the value and two fetching strategies (one for selected values, and one for searching options) to compute the props of the component. We were even able to wrap this hook to create a useAsyncSelect hook for single selects, using the same pattern that wraps multi select to make single select.

Relationship between the consumer, utility hook, and Select component

Design limitations

The design we ultimately chose is easy to consume and minimally repetitive, but DRY code can sometimes make software harder to change. For example, if we want to tweak behavior just for async selects, we would have to consider how those changes might affect non-async selects. A change to any of the Select components could, in some cases, entail a change to all of them.

Our hope is that this coupling will be more helpful as a single source of truth than harmful in prohibiting change, and that the transparency and readability of the components will help us avoid creating new bugs when making updates. Only time will tell.

Was it worth it?

Functionally, the component is a clear success. It enables all the behaviors that we need through a simple but flexible API that developers are excited to adopt going forward.

In addition, the new Select seems to satisfy most of our non-functional requirements, though it’s too soon to say with absolute confidence. The component hierarchy is simple, code duplication is minimized, and the implementation is transparent and easy to understand. But changes and new feature requests will strain the rigid coupling between the components and ultimately test the non-functional goal of extensibility.

So, has the outcome justified the laborious effort of refactoring Select? It’s too early to say for sure, but front-end developers look forward to eliminating existing tech debt and implementing the new component in parts of the app where we previously might have thrown up our hands. Most importantly, we expect users to enjoy an overall smoother and more robust UX.

Although it’ll be a while before we know whether we made the right call, we hope that this project will ultimately reaffirm why — even for a startup that needs to move fast and ship even faster — refactoring debt-ridden code is often worth the time.

--

--