Home > front end >  How do I prevent illegal state in renders when useEffect only fires after render?
How do I prevent illegal state in renders when useEffect only fires after render?

Time:05-01

I have a React component that takes a prop and holds state via useState. When that prop changes, I sometimes need to update the state in response, so I added a useEffect. But props changes cause a render, useEffect fires after that render, and setting state causes another render, and between those two renders, my component is in an illegal state, and I don't know how to prevent it.


Here's a trivial example. This component displays a list of radio buttons each representing a city. Only the radio buttons for the cities within a specific country are enabled at one time. When the country changes, it updates which radio buttons are enabled and it also changes the user's selection to be a valid city.

import { useEffect, useState } from 'react';

const CITIES_BY_COUNTRY = {
  Spain: ['Madrid', 'Barcelona', 'Valencia'],
  France: ['Paris', 'Lyon', 'Marseille'],
};

export function CityPicker({ currentCountry }) {
  const [selectedCity, setSelectedCity] = useState('');

  // When the country changes, make sure the selected city is valid.
  useEffect(() => {
    if (!CITIES_BY_COUNTRY[currentCountry].includes(selectedCity)) {
      setSelectedCity(CITIES_BY_COUNTRY[currentCountry][0]);
    }
  }, [currentCountry, selectedCity]);

  // Log the country/city pair.
  console.log({ currentCountry, selectedCity });

  return (
    <div>
      {Object.keys(CITIES_BY_COUNTRY).map(country => (
        <div key={`country-${country}`}>
          {Object.keys(CITIES_BY_COUNTRY[country]).map(city => (
            <label key={`city-${city}`}>
              <input
                type="radio"
                name="city"
                value={city}
                disabled={country !== currentCountry}
                checked={city === selectedCity}
                onChange={() => setSelectedCity(city)}
              />
              {city}
            </label>
          ))}
        </div>
      ))}
    </div>
  );
}
  1. User arrives with currentCountry === "Spain". Only Spanish cities are enabled. Log says { currentCountry: "Spain", selectedCity: "Madrid" }

  2. User clicks “Barcelona”. Log says { currentCountry: "Spain", selectedCity: "Barcelona" }. All good up to this point.

  3. Something in the parent component changes and the currentCountry changes to France. This component gets passed the new prop and re-renders. Log says { currentCountry: "France", selectedCity: "Barcelona" }. THEN, the useEffect fires and we get another render. Log says { currentCountry: "France", selectedCity: "Paris" }.

As you can see, we got two renders in step 3, and one of them had an illegal pair (France Barcelona).

This is a trivial example, and my app is much more complicated. There's many ways that both the country and city can change and I need to perform validation on the pair each time and sometimes prompt the user or otherwise react in certain circumstances. Given that, it's really important to prevent illegal pairs.


Given that useEffect only ever fires after a render, it seems like it will always be too late to make the change I need. Is there an elegant way to solve this?

CodePudding user response:

Here's my best attempt at a solution, although I don't find it elegant. I wonder if some of this can be abstracted into a custom hook somehow.

export function EffectiveCityPicker({ currentCountry }) {
  const [selectedCity, setSelectedCity] = useState(CITIES_BY_COUNTRY[currentCountry][0]);

  // Based on the country, make sure we have a valid city.
  let effectiveSelectedCity = selectedCity;
  if (!CITIES_BY_COUNTRY[currentCountry].includes(selectedCity)) {
    effectiveSelectedCity = CITIES_BY_COUNTRY[currentCountry][0];
  }

  // If the effectiveSelectedCity changes, save it back to state.
  useEffect(() => {
    if (selectedCity !== effectiveSelectedCity) {
      setSelectedCity(effectiveSelectedCity);
    }
  }, [selectedCity, effectiveSelectedCity]);

  // Log the country/city pair.
  console.log({ currentCountry, effectiveSelectedCity });

  return (
    <div>
      {Object.keys(CITIES_BY_COUNTRY).map(country => (
        <div key={`country-${country}`}>
          {Object.keys(CITIES_BY_COUNTRY[country]).map(city => (
            <label key={`city-${city}`}>
              <input
                type="radio"
                name="city"
                value={city}
                disabled={country !== currentCountry}
                checked={city === effectiveSelectedCity}
                onChange={() => setSelectedCity(city)}
              />
              {city}
            </label>
          ))}
        </div>
      ))}
    </div>
  );
}

In this version, we always combine the current props and state to form a valid effectiveSelectedCity, and then save it back to state afterwards if needed. It prevents the illegal pair, but it results in an extra render that produces the same markup. It also gets way more complicated if there's more state and props -- perhaps there's a similar solution that uses a reducer.

CodePudding user response:

You don't need useEffect at all in this case. Just check validity in handler. And even better, don't allow user to select invalid options (disable them maybe, or even don't show at all?).

import { useState } from 'react';

const CITIES_BY_COUNTRY = {
  Spain: ['Madrid', 'Barcelona', 'Valencia'],
  France: ['Paris', 'Lyon', 'Marseille'],
};

export function CityPicker({ currentCountry }) {
  const [selectedCity, setSelectedCity] = useState('');

  const handleSelectCity = (city) => {
     const defaultCity = CITIES_BY_COUNTRY[currentCountry][0];
     const isCityValid = CITIES_BY_COUNTRY[currentCountry].includes(city);
     setSelectedCity(isCityValid ? city : defaultCity)
  }

  return (
    <div>
      {Object.keys(CITIES_BY_COUNTRY).map(country => (
        <div key={`country-${country}`}>
          {Object.keys(CITIES_BY_COUNTRY[country]).map(city => (
            <label key={`city-${city}`}>
              <input
                type="radio"
                name="city"
                value={city}
                disabled={country !== currentCountry}
                checked={city === selectedCity}
                onChange={() => handleSelectCity(city)}
              />
              {city}
            </label>
          ))}
        </div>
      ))}
    </div>
  );
}
  • Related