React select dropdowns that depend on each other
Asked Answered
A

5

5

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?

Apologue answered 12/12, 2022 at 18:46 Comment(2)
why are the countries and cities not being filtered again each time on render? – Braynard
using useMemo hook will resolve your problem. you can find the code in my post – Grocery
P
3

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);
Pilose answered 14/12, 2022 at 23:59 Comment(0)
B
2

Since the dependency relationship is just in one direction, a simple approach would be just to clear everything that depends on the one that changed when it changes. So:

  • clear country and city when planet is selected
  • clear city when country is selected
    <select value={selectedPlanet.id} onChange={(e) => setPlanetAndClearCountryAndCity(e)}>
      {planets.map(p => (
        <option key={p.id} value={p.id} name={p.name} />
      )}
    </select>

    <select value={selectedCountry.id} onChange={(e) => setCountryAndClearCity(e)}>
      {filteredCountries.map(c => (
        <option key={c.id} value={c.id} name={c.name} />
      )}
    </select>

And put some validation that requires all 3 before save button is enabled.

Also you could consider not clearing the country or city if they actually still have valid options. But that seems like more work than it's worht.

Bulla answered 15/12, 2022 at 4:2 Comment(1)
I think that some other answers are way overcomplicating this! Your suggestion to clear state through the onChange handler is a good one. – Seka
C
1

You could use useEffect hook to handle select box updates. So that, you don't miss any update in values.

demo


export const SelectList = ({ record, onSave, planets, countries, cities }) => {
  const [selectedPlanet, setSelectedPlanet] = useState(record.planet);
  const [selectedCountry, setSelectedCountry] = useState(record.country);
  const [selectedCity, setSelectedCity] = useState(record.city);

  /** handle filteredValues in state */
  const [filteredCountries, setFilteredCountries] = useState([]);
  const [filteredCities, setFilteredCities] = useState([]);

  /** use useEffect to update values */
  useEffect(() => {
    const filteredCountries = countries.filter((c) => c.planet === selectedPlanet);
    setFilteredCountries(filteredCountries);
    setSelectedCountry(filteredCountries[0].id);
  }, [selectedPlanet]);

  useEffect(() => {
    const filteredCities = cities.filter((c) => c.country === selectedCountry);
    setFilteredCities(filteredCities);
    setSelectedCity(filteredCities[0].id);
  }, [selectedCountry]);

  return (
    <div>
      <select value={selectedPlanet}
        onChange={(e) => setSelectedPlanet(e.target.value)}>
        {planets.map((p) => (
          <option key={p.id} value={p.id}>{p.name}</option>
        ))}
      </select>
      <select value={selectedCountry}
        onChange={(e) => setSelectedCountry(e.target.value)}>
        {filteredCountries.map((c) => (
          <option key={c.id} value={c.id}>{c.name}</option>
        ))}
      </select>
      <select
        value={selectedCity}
        onChange={(e) => setSelectedCity(e.target.value)}>
        {filteredCities.map((c) => (
          <option key={c.id} value={c.id}>{c.name}</option>
        ))}
      </select>
      <button onClick={() => onSave({planet: selectedPlanet, country: selectedCountry, city: selectedCity })}>Save</button>
    </div>
  );
};


Cauterant answered 15/12, 2022 at 12:11 Comment(0)
G
1

I think using React.useMemo hook for two variables: filteredCountries and filteredCities would be the best solution for you. It will not make your code ugly and you don't have to filter twice. Here is the updated code.

import * as React from 'react';

// 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 = React.useMemo(() => {
  const _countries = countries.filter(c => c.planet === selectedPlanet.id);
  setSelectedCountry(_countries[0]);
  return _countries;
}, [selectedPlanet]);

const filteredCities = React.useMemo(()=> {
  const _cities = cities.filter(c => c.country === selectedCountry.id);
  setSelectedCity(_cities[0]);
  return _cities;
}, [setSelectedCountry])

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>
);
Grocery answered 17/12, 2022 at 13:51 Comment(0)
A
0

One way is to handle it by useEffect hook. This solution is simple logic and obviously u can handle it with more details and conditions.

import React, { useEffect, useState } from "react";

const planets = [
  { id: 1, name: "earth", value: "earth" },
  { id: 2, name: "mars", value: "mars" },
  { id: 3, name: "jupiter", value: "jupiter" }
];
const countries = [
  { id: 1, name: "USA", value: "USA", planet: "earth" },
  { id: 2, name: "UAE", value: "UAE", planet: "mars" },
  { id: 3, name: "Canada", value: "Canada", planet: "mars" },
  { id: 4, name: "England", value: "England", planet: "earth" }
];
const cities = [
  { id: 1, name: "Los Angeles", value: "Los Angeles", country: "USA" },
  { id: 2, name: "Dubai", value: "Dubai", country: "UAE" },
  { id: 3, name: "Torento", value: "Torento", country: "Canada" },
  { id: 4, name: "Washington", value: "Washington", country: "USA" },
  { id: 5, name: "London", value: "London", country: "England" }
];

export default function MaterialUIPickers() {
  const [selectedPlanet, setSelectedPlanet] = useState(planets[0].name);
  const [selectedCountry, setSelectedCountry] = useState("");
  const [selectedCity, setSelectedCity] = useState("");

  const [countryItems, setCountryItems] = useState([]);
  const [cityItems, setCityItems] = useState([]);

  useEffect(() => {
    setCountryItems([]); // reset select
    setCityItems([]); // reset select
    setSelectedCountry(""); // reset select
    setSelectedCity(""); // reset select
    selectedPlanet &&
      setCountryItems(countries.filter((c) => c.planet === selectedPlanet));
  }, [selectedPlanet]);

  useEffect(() => {
    setCityItems([]); // reset select
    setSelectedCity(""); // reset select

    selectedCountry &&
      setCityItems(cities.filter((c) => c.country === selectedCountry));
  }, [selectedCountry]);

  return (
    <div style={{ display: "flex-box", width: "vh" }}>
      <select
        value={selectedPlanet.id}
        onChange={(e) => setSelectedPlanet(e.target.value)}
      >
        {planets.map((p) => (
          <option key={p.id} value={p.name} name={p.name}>
            {p.name}
          </option>
        ))}
      </select>

      <select
        value={selectedCountry?.id}
        onChange={(e) => setSelectedCountry(e.target.value)}
      >
        {countryItems.map((c) => (
          <option key={c.id} value={c.name} name={c.name}>
            {c.name}
          </option>
        ))}
      </select>

      <select
        value={selectedCity?.id}
        onChange={(e) => setSelectedCity(e.target.value)}
      >
        {cityItems.map((c) => (
          <option key={c.id} value={c.name} name={c.name}>
            {c.name}
          </option>
        ))}
      </select>

      <button
        onClick={() =>
          console.log({
            planet: selectedPlanet,
            country: selectedCountry,
            city: selectedCity
          })
        }
      >
        log selcets
      </button>
    </div>
  );
}

https://codesandbox.io/s/kind-wilson-zpenc?file=/src/App.js this is sandbox link

Advowson answered 18/12, 2022 at 12:34 Comment(0)

© 2022 - 2024 β€” McMap. All rights reserved.