Logo Questions Linux Laravel Mysql Ubuntu Git Menu
 

useState() is not updating state from event handler?

I'm trying to recreate an old flash game in React. The object of the game is to press a button down for a certain length of time.

This is the old game: http://www.zefrank.com/everysecond/index.html

Here is my new React implementation: https://codesandbox.io/s/github/inspectordanno/every_second

I'm running into a problem. When the mouse is released, I calculate the amount of time between when the button was pressed and when it was released, using the Moment.js time library. If the timeDifference between the onMouseDown and onMouseUp event is within the targetTime, I want the game level to increase and the targetTime to increase as well.

I'm implementing this logic in the handleMouseUp event handler. I'm getting the expected times printed to the screen, but the logic isn't working. In addition, when I console.log() the times, they are different than the ones being printed to the screen. I'm fairly certain timeHeld and timeDifference aren't being updated correctly.

Initially I thought there was a problem with the way I was doing the event handler and I need to use useRef() or useCallback(), but after browsing a few other questions I don't understand these well enough to know if I have to use them in this situation. Since I don't need access to the previous state, I don't think I need to use them, right?

The game logic is in this wrapper component:

import React, { useState } from 'react';
import moment from 'moment';
import Button from './Button';
import Level from './Level';
import TargetTime from './TargetTime';
import TimeIndicator from './TimeIndicator';
import Tries from './Tries';

const TimerApp = () => {
  const [level, setLevel] = useState(1);
  const [targetTime, setTargetTime] = useState(.2);
  const [isPressed, setIsPressed] = useState(false);
  const [whenPressed, setPressed] = useState(moment());
  const [whenReleased, setReleased] = useState(moment());
  const [tries, setTries] = useState(3);
  const [gameStarted, setGameStarted] = useState(false);
  const [gameOver, setGameOver] = useState(false);

  const timeHeld = whenReleased.diff(whenPressed) / 1000;
  let timeDifference = Math.abs(targetTime - timeHeld);
  timeDifference = Math.round(1000 * timeDifference) / 1000; //rounded

  const handleMouseDown = () => {
    !gameStarted && setGameStarted(true); //initialize game on the first click
    setIsPressed(true);
    setPressed(moment());
  };

  const handleMouseUp = () => {
    setIsPressed(false);
    setReleased(moment());

    console.log(timeHeld);
    console.log(timeDifference);

    if (timeDifference <= .1) {
      setLevel(level + 1);
      setTargetTime(targetTime + .2);
    } else if (timeDifference > .1 && tries >= 1) {
      setTries(tries - 1);
    }

    if (tries === 1) {
      setGameOver(true);
    }
  };

  return (
    <div>
      <Level level={level}/>
      <TargetTime targetTime={targetTime} />
      <Button handleMouseDown={handleMouseDown} handleMouseUp={handleMouseUp} isGameOver={gameOver} />
      <TimeIndicator timeHeld={timeHeld} timeDifference={timeDifference} isPressed={isPressed} gameStarted={gameStarted} />
      <Tries tries={tries} />
      {gameOver && <h1>Game Over!</h1>}
    </div>
  )
}

export default TimerApp;

If you want to check the whole app please refer to the sandbox.

like image 235
InspectorDanno Avatar asked Dec 05 '22 09:12

InspectorDanno


1 Answers

If you update some state inside a function, and then try to use that state in the same function, it will not use the updated values. Functions snapshots the values of state when function is called and uses that throughout the function. This was not a case in class component's this.setState, but this is the case in hooks. this.setState also doesn't updates the values eagerly, but it can update while in the same function depending on a few things(which I am not qualified enough to explain).
To use updated values you need a ref. Hence use a useRef hook. [docs]
I have fixed you code you can see it here: https://codesandbox.io/s/everysecond-4uqvv?fontsize=14
It can be written in a better way but that you will have to do yourself.

Adding code in answer too for completion(with some comments to explain stuff, and suggest improvements):

import React, { useRef, useState } from "react";
import moment from "moment";
import Button from "./Button";
import Level from "./Level";
import TargetTime from "./TargetTime";
import TimeIndicator from "./TimeIndicator";
import Tries from "./Tries";

const TimerApp = () => {
  const [level, setLevel] = useState(1);
  const [targetTime, setTargetTime] = useState(0.2);
  const [isPressed, setIsPressed] = useState(false);
  const whenPressed = useRef(moment());
  const whenReleased = useRef(moment());
  const [tries, setTries] = useState(3);
  const [gameStarted, setGameStarted] = useState(false);
  const [gameOver, setGameOver] = useState(false);
  const timeHeld = useRef(null);  // make it a ref instead of just a variable
  const timeDifference = useRef(null);  // make it a ref instead of just a variable

  const handleMouseDown = () => {
    !gameStarted && setGameStarted(true); //initialize game on the first click
    setIsPressed(true);
    whenPressed.current = moment();
  };

  const handleMouseUp = () => {
    setIsPressed(false);
    whenReleased.current = moment();

    timeHeld.current = whenReleased.current.diff(whenPressed.current) / 1000;
    timeDifference.current = Math.abs(targetTime - timeHeld.current);
    timeDifference.current = Math.round(1000 * timeDifference.current) / 1000; //rounded

    console.log(timeHeld.current);
    console.log(timeDifference.current);

    if (timeDifference.current <= 0.1) {
      setLevel(level + 1);
      setTargetTime(targetTime + 0.2);
    } else if (timeDifference.current > 0.1 && tries >= 1) {
      setTries(tries - 1);
      // consider using ref for tries as well to get rid of this weird tries === 1 and use tries.current === 0
      if (tries === 1) {
        setGameOver(true);
      }
    }
  };

  return (
    <div>
      <Level level={level} />
      <TargetTime targetTime={targetTime} />
      <Button
        handleMouseDown={handleMouseDown}
        handleMouseUp={handleMouseUp}
        isGameOver={gameOver}
      />
      <TimeIndicator
        timeHeld={timeHeld.current}
        timeDifference={timeDifference.current}
        isPressed={isPressed}
        gameStarted={gameStarted}
      />
      <Tries tries={tries} />
      {gameOver && <h1>Game Over!</h1>}
    </div>
  );
};

export default TimerApp;

PS: Don't use unnecessary third party libraries, especially big ones like MomentJs. They increase your bundle size significantly. Use can easily get current timestamp using vanilla js. Date.now() will give you current unix timestamp, you can subtract two timestamps to get the duration in ms.

Also you have some unnecessary state like gameOver, you can just check if tries > 0 to decide gameOver.
Similarly instead of targetTime you can just use level * .2, no need to additional state.
Also whenReleased doesn't needs to be a ref or state, it can be just a local variable in mouseup handler.

like image 127
Vaibhav Vishal Avatar answered Jan 12 '23 00:01

Vaibhav Vishal