I am trying to make 3 select dropdowns automatically change based on the selection. First dropdown has no dependencies, 2nd depends on first, and 3rd depends on 2nd.
This is a very simplified version of my code:
// record, onSave, planets, countries, cities passed as props
const [selectedPlanet, setSelectedPlanet] = useState(record.planet);
const [selectedCountry, setSelectedCountry] = useState(record.country);
const [selectedCity, setSelectedCity] = useState(record.city);
const filteredCountries = countries.filter(c => c.planet === selectedPlanet.id);
const filteredCities = cities.filter(c => c.country === selectedCountry.id);
return (
<div>
<select value={selectedPlanet.id} onChange={(e) => setSelectedPlanet(e.target.value)}>
{planets.map(p => (
<option key={p.id} value={p.id} name={p.name} />
)}
</select>
<select value={selectedCountry.id} onChange={(e) => setSelectedCountry(e.target.value)}>
{filteredCountries.map(c => (
<option key={c.id} value={c.id} name={c.name} />
)}
</select>
<select value={selectedCity.id} onChange={(e) => setSelectedCity(e.target.value)}>
{filteredCities.map(c => (
<option key={c.id} value={c.id} name={c.name} />
)}
</select>
<button onClick={() => onSave({planet: selectedPlanet, country: selectedCountry, city: selectedCity ) }
</div>
);
The select options will update accordingly, but onSave() will receive outdated values for country and city if I select a planet and click the save button.
That is because setSelectedCountry and setSelectedCity are not called on planet change. I know I can just call them in that event, but it would make the code much uglier because I would have to duplicate the country and city filtering. Is there a better way around this?
Update
I updated the code example for the useReducer
approach. It is functionally equivalent to the original example, but hopefully with cleaner logic and structure.
Live demo of updated example: stackblitz
Although it takes some extra wiring up, useReducer
could be considered for this use case since it might be easier to maintain the update logic in one place, instead of tracing multiple events or hook blocks.
// Updated to use a common structure for reducer
// Also kept the reducer pure so it can be moved out of the component
const selectsReducer = (state, action) => {
const { type, payload } = action;
switch (type) {
case "update_planet": {
const newCountry = payload.countries.find(
(c) => c.planet === payload.value
).id;
return {
planet: payload.value,
country: newCountry,
city: payload.cities.find((c) => c.country === newCountry).id,
};
}
case "update_country": {
return {
...state,
country: payload.value,
city: payload.cities.find((c) => c.country === payload.value).id,
};
}
case "update_city": {
return {
...state,
city: payload.value,
};
}
default:
return { ...state };
}
};
// Inside the component
const [selects, dispatchSelects] = useReducer(selectsReducer, record);
Original
While uglier or not is rather opinion-based, perhaps a potentially organized solution to handle the states dependent on each other is to use useReducer
.
Live demo of below example: stackblitz
Here is a basic example that updates dependent values, such as if country
is updated, then city
will also change to the first available city
in the new country
to match it.
This keeps the value in the select lists updated, and it ensures onSave
to always receive the updated values from the state.
const selectsReducer = (state, action) => {
const { type, planet, country, city } = action;
let newPlanet,
newCountry,
newCity = "";
switch (type) {
// 👇 Here to update all select values (the next 2 cases also run)
case "update_planet": {
newPlanet = planet;
}
// 👇 Here to update country and city (the next case also run)
case "update_country": {
newCountry = newPlanet
? countries.find((c) => c.planet === newPlanet).id
: country;
}
// 👇 Here to update only city
case "update_city": {
newCity = newCountry
? cities.find((c) => c.country === newCountry).id
: city;
return {
planet: newPlanet || state.planet,
country: newCountry || state.country,
city: newCity || state.city,
};
}
default:
return record;
}
};
const [selects, dispatchSelects] = useReducer(selectsReducer, record);