Search code examples
reactjsselectreact-state

React select dropdowns that depend on each other


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?


Solution

  • 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);