tbd_
tbd_

Reputation: 1248

Why is useState not updating state with the keydown handler?

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

Upvotes: 4

Views: 2354

Answers (2)

Inder
Inder

Reputation: 2078

You can solve this by following either approach:

  1. Add handleKeyStroke in the dependency array of useEffect. Wrap handleKeyStroke() with useCallback hook and add startId inside dependency array of useCallback. So, whenever startId changes, useCallback will recreate handleKeyStroke and useEffect will get a new version of handleKeyStroke

  2. Inside handleKeyStroke() call setStartId(prev=>prev+1) (recommended)

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 with empty dependency array, 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() because it's value has been saved in useEffect because of empty dependency array. But when we use callback syntax it has access to previous state. And we won't need to anything inside useEffect's dependency array.

Upvotes: 4

tbd_
tbd_

Reputation: 1248

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

Upvotes: 0

Related Questions