Why is useState not updating state with the keydown handler?

Viewed 193

I'm trying to create a simple React image slider, where the right/left arrow keys slides through the images.

Problem

When I press the right arrow ONCE, it works as expected. The id updates from 0 to 1, and re-renders the new image.

When I press the right arrow a SECOND time, I see (through console.log) that it registers the keystroke, but doesn't update the state via setstartId.

Why?

Also, I am printing new StartId: 0 in the component function itself. I see that when you first render the page, it prints it 4 times. Why? Is it: 1 for the initial load, 2 for the two useEffects, and a last one when the promises resolve?

The Code

Here is my sandbox: https://codesandbox.io/s/react-image-carousel-yv7njm?file=/src/App.js

export default function App(props) {
  const [pokemonUrls, setPokemonUrls] = useState([]);
  const [startId, setStartId] = useState(0);
  const [endId, setEndId] = useState(0);

  console.log(`new startId: ${startId}`)

  const handleKeyStroke = (e) => {
    switch (e.keyCode) {
      // GO LEFT
      case 37:
        break;
      // GO RIGHT
      case 39:
        console.log("RIGHT", startId);
        setStartId(startId + 1);
        break;
      default:
        break;
    }
  };

  useEffect(() => {
    async function fetchPokemonById(id) {
      const response = await fetch(`${POKE_API_URL}/${id}`);
      const result = await response.json();
      return result.sprites.front_shiny;
    }

    async function fetchNpokemon(n) {
      let pokemon = [];

      for (let i = 0; i < n; i++) {
        const pokemonUrl = await fetchPokemonById(i + 1);
        pokemon.push(pokemonUrl);
      }
      setPokemonUrls(pokemon);
    }

    fetchNpokemon(5);
  }, []);

  useEffect(() => {
    window.addEventListener("keydown", handleKeyStroke);

    return () => {
      window.removeEventListener("keydown", handleKeyStroke);
    };
  }, []);

  return (
    <div className="App">
      <Carousel pokemonUrls={pokemonUrls} startId={startId} />
      <div id="carousel" onKeyDown={handleKeyStroke}>
        <img alt="pokemon" src={pokemonUrls[startId]} />
      </div>
    </div>
  );
}
2 Answers

Inside handleKeyStroke() call setStartId(prev=>prev+1)

Updated handleKeyStroke()

 const handleKeyStroke = (e) => {
    switch (e.keyCode) {
      // GO LEFT
      case 37:
        break;
      // GO RIGHT
      case 39:
        console.log("RIGHT", startId);
        setStartId(prev=>prev + 1);
        break;
      default:
        break;
    }
  };

Explanantion

In the code above, you're adding window.addEventListener inside useEffect with empty dependency array. And inside handleKeyStroke(), you are setting the state this way setStartId(startId + 1);.

Because of the useEffect, when handleKeyStroke() is initialized, it is initialized with values which are available on mount. It doesn't access the updated state.

So, for example, when you call setStartId(startId + 1);, handleKeyStroke() has the value of startId=0 and it adds 1 to it. But next time when you call setStartId(startId + 1);, the startId value is still 0 inside handleKeyStroke. But when we use callback syntax it has access to previous state.

Got it working with @Inder's answer.

I made a few mistakes:

  • Updated the state setter to use a callback instead. Not sure why that works, I will do some research (eg. setStartId(id=>id+1) instead of setStartId(startId+1))
  • Used the div's onKeyDown prop instead of window.addEventListener. That is how react expects you to bind event listeners to elements.
  • onKeyDown doesn't work on divs by default, because it doesn't expect any inputs (unlike <input />). But if you add tabIndex=0 to it, it allows you to focus on the div, and therefore enables the onKeyDown listener on it.

Here is the final code:

const POKE_API_URL = "https://pokeapi.co/api/v2/pokemon";

const Carousel = ({ handleKeyDown, pokemonUrls, startId }) => (
  <div tabIndex="0" onKeyDown={handleKeyDown}>
    <img alt="pokemon" src={pokemonUrls[startId]} />
  </div>
);

export default function App(props) {
  const [pokemonUrls, setPokemonUrls] = useState([]);
  const [startId, setStartId] = useState(0);
  const [endId, setEndId] = useState(0);

  const handleKeyDown = (e) => {
    switch (e.keyCode) {
      // GO LEFT
      case 37:
        if (startId - 1 >= 0) {
          setStartId((prev) => prev - 1);
        }
        break;
      // GO RIGHT
      case 39:
        if (startId < pokemonUrls.length - 1) {
          setStartId((prev) => prev + 1);
        }
        break;
      default:
        break;
    }
  };

  useEffect(() => {
    async function fetchPokemonById(id) {
      const response = await fetch(`${POKE_API_URL}/${id}`);
      const result = await response.json();
      return result.sprites.front_shiny;
    }

    async function fetchNpokemon(n) {
      let pokemon = [];

      for (let i = 0; i < n; i++) {
        const pokemonUrl = await fetchPokemonById(i + 1);
        pokemon.push(pokemonUrl);
      }
      setPokemonUrls(pokemon);
    }

    fetchNpokemon(5);
  }, []);

  return (
    <div className="App">
      <Carousel
        handleKeyDown={handleKeyDown}
        pokemonUrls={pokemonUrls}
        startId={startId}
      />
    </div>
  );
}
Related