In modern web development, creating intuitive and user-friendly forms is essential. One common requirement is implementing dynamic dependent select boxes, where the options of one select box depend on the selection made in another. This powerful feature enables users to efficiently navigate through complex data structures while providing a seamless user experience.
In this article, we will explore how to build a dynamic dependent select boxes component using the popular React library and the type-safety of TypeScript.
I will start by adding here the entire code, for you to copy paste and then you can read further about the explanation(if you want to).
CustomSelect.tsx
import React, { useState } from 'react';
import styled from 'styled-components';
interface Option {
value: string;
label: string;
}
interface SelectBoxProps {
options: Option[];
defaultOption?: Option;
onSelect?: (selectedValue: string) => void;
}
const SelectContainer = styled.div`
position: relative;
display: inline-block;
`;
const SelectButton = styled.button`
padding: 8px;
border: 1px solid #ccc;
border-radius: 4px;
font-size: 16px;
color: #333;
background-color: #fff;
cursor: pointer;
display: flex;
align-items: center;
width: 200px;
box-sizing: border-box;
`;
const ArrowIcon = styled.span`
margin-left: 8px;
transition: transform 0.2s;
margin-left: auto;
&.isOpen {
transform: rotate(180deg);
}
`;
const OptionsContainer = styled.ul<{ isOpen: boolean }>`
list-style: none;
padding: 0;
display: ${(props) => (props.isOpen ? 'block' : 'none')};
position: absolute;
top: 100%;
left: 0;
z-index: 1;
width: 200px;
`;
const OptionItem = styled.li`
padding: 8px;
cursor: pointer;
transition: background-color 0.2s;
&:hover {
background-color: #f0f0f0;
}
&.selected {
background: #ccc;
}
`;
const SelectBox = ({
options,
defaultOption,
onSelect,
}:SelectBoxProps) => {
const [isOpen, setIsOpen] = useState(false);
const [selectedValue, setSelectedValue] = useState<string | undefined>(
defaultOption ? defaultOption.value : undefined
);
const toggleDropdown = () => {
setIsOpen(!isOpen);
};
const handleSelect = (event: React.MouseEvent<HTMLLIElement>) => {
const target = event.target as HTMLLIElement;
const selectedValue = target.getAttribute('data-value') || '';
setSelectedValue(selectedValue);
setIsOpen(false);
onSelect && onSelect(selectedValue);
};
return (
<SelectContainer>
<SelectButton onClick={toggleDropdown}>
{selectedValue ? options.find((option) => option.value === selectedValue)?.label : defaultOption?.label}
<ArrowIcon className={isOpen ? 'isOpen' : ''}>
▼
</ArrowIcon>
</SelectButton>
<OptionsContainer isOpen={isOpen}>
{options.map((option) => (
<OptionItem
key={option.value}
onClick={handleSelect}
className={option.value === selectedValue ? 'selected' : ''}
data-value={option.value}
>
{option.label}
</OptionItem>
))}
</OptionsContainer>
</SelectContainer>
);
};
export default SelectBox;
CustomSelect.test.tsx
Timport React from 'react';
import { render, fireEvent, screen } from '@testing-library/react';
import CustomSelect from './CustomSelect';
const options = [
{ value: '1', label: 'Option 1' },
{ value: '2', label: 'Option 2' },
{ value: '3', label: 'Option 3' },
];
const childOptions = [
{ value: '1', label: 'Child Option 1' },
{ value: '2', label: 'Child Option 2' },
{ value: '3', label: 'Child Option 3' },
];
describe('CustomSelect', () => {
test('should render CustomSelect component with options', () => {
const onSelectMock = jest.fn();
render(
<CustomSelect
options={options}
onSelect={onSelectMock}
/>
);
const selectElement = screen.getByLabelText('Select a country') as HTMLSelectElement;
expect(selectElement).toBeInTheDocument();
options.forEach((option) => {
const optionElement = screen.getByText(option.label);
expect(optionElement).toBeInTheDocument();
});
fireEvent.change(selectElement, { target: { value: '2' } });
expect(onSelectMock).toHaveBeenCalledTimes(1);
expect(onSelectMock).toHaveBeenCalledWith('2');
const childSelectElement = screen.getByLabelText('Select a city') as HTMLSelectElement;
expect(childSelectElement).toBeInTheDocument();
childOptions.forEach((option) => {
const optionElement = screen.getByText(option.label);
expect(optionElement).toBeInTheDocument();
});
});
});
The usage
So the JSON data should look like this:
const countryOptions = [
{ value: '1', label: 'United States' },
{ value: '2', label: 'United Kingdom' },
{ value: '3', label: 'Canada' },
];
interface cityOptionDefs {
[key: string]: { value: string; label: string; }[]
}
const cityOptions:cityOptionDefs= {
'1': [
{ value: '1', label: 'New York' },
{ value: '2', label: 'Los Angeles' },
{ value: '3', label: 'Chicago' },
],
'2': [
{ value: '4', label: 'London' },
{ value: '5', label: 'Manchester' },
{ value: '6', label: 'Birmingham' },
],
'3': [
{ value: '7', label: 'Toronto' },
{ value: '8', label: 'Vancouver' },
{ value: '9', label: 'Montreal' },
],
};
As you can see the cityOptions is an object and it’s properties are a number in a string that represents actually the value of the clicked option in the first select box. If you click on the first option(United States) the index will be 1 so the city will have the options under the property ‘1’ in the JSON data.
The render of the select box looks like this:
const [selectedCountry, setSelectedCountry] = useState('');
const handleCountrySelect = (selectedValue: string) => {
setSelectedCountry(selectedValue);
};
const childOptions = selectedCountry ? cityOptions[selectedCountry] : [];
return (
<>
<CustomSelect
options={countryOptions}
onSelect={handleCountrySelect}
defaultOption={{ value: '', label: 'Select a country' }}
/>
<CustomSelect
options={childOptions}
defaultOption={{ value: '', label: 'Select a city' }}
/>
</>
);
We create as many dependent(interconnected) select boxes we need. On the first selectbox we add the country options and we pass down for each select a default text for the selectbox(Select a country or Select a city). We could create also 3 or 4 select boxes(another one for zip code and another for county for example).

The second select box get’s the options(childOptions) based on the option selected in the country selector.

When a country is selected, the number value is passed up to the parent element and used for selecting a set of city names. When there is no country selected yet, the city selector will be empty.
The onSelect prop is not mandatory, only if you want to get some information from the child component and update another child or grandchild or grand grandchild component and so on.
What about the rest of the code?
Well the rest the code is only for creating a custom select box in react and typescript.
Regarding the props we get the options to populate the select box, the defaultOption to have a default text before selecting an option and the optional onSelect to be able to pass up to the parent element the value of the selected option.

Then we need a useState hook for when the Selectbox is expanded or collapsed and the selectedValue state to store the value of the selected option to update the selectbox with the new selected label and also to pass the value up to the parent component.

First we have a button, that has also an arrow icon that changes direction when clicked(from pointing downwards to pointing upwards).

How about the select items?

The select is populated with the data from the props(options). We emphasize the item that is selected with a grey background(the selected class holds the style) and since we are using <li> tags we can’t use the value attribute, but we will use the data-value attribute.

When interacting with the selectobox we call the handleSelect function and do the following:
- set the current selected value in the state, so the button’s text also can change to the new value
- toggle the value of the isOpen state, to show or hide the options
- send up to the parent the selected value to update any dependent select boxes(city select box)
Anything to mention about the unit test?
What I check here is :
- is the select button and the options present in the document(.toBeInTheDocument())
- if we click on the option with value ‘2’, check if the mock was called 1 time and if the mock with value ‘2’ has been called as expected. Then we also check the child selectbox.
That’s all, pretty simple, piece of cake for a react ninja like you. Enjoy and of course comment, like, rate and subscribe.