butthash3030
butthash3030

Reputation: 173

State not persisting between functions with useState

I am practicing React useState hooks to make a quiz with a ten second timer per question.

Currently I am able to get the quiz questions from an API, set them to state, and render them. If a user clicks an answer, the question is removed from the array in state, the seconds in state is reset to 10 and the next question renders.

I am trying to get the timer to clear when there is nothing left in the array of questions in state. When I console.log(questions) in the startTimer function, it is an empty array, despite the same console.log showing the data in the userAnswer function? What am I missing here?

I've removed the referenced shuffle function to save space

function App() {

  // State for trivia questions, time left, right/wrong count
  const [questions, setQuestions] = useState([])
  const [seconds, setSeconds] = useState(10);
  const [correct, setCorrect] = useState(0);
  const [incorrect, setIncorrect] = useState(0);

  // Get data, set questions to state, start timer
  const getQuestions = async () => {
    let trivia = await axios.get("https://opentdb.com/api.php?amount=10&category=9&difficulty=easy&type=multiple")
    trivia = trivia.data.results
    trivia.forEach(result => {
      result.answers = shuffle([...result.incorrect_answers, result.correct_answer])
    })
    setQuestions(trivia)
    startTimer()
  }

  const startTimer = () => {

    // Empty array here, but data at beginning of userAnswer
    console.log(questions)

    const interval = setInterval(() => {

      // If less than 1 second, increment incorrect, splice current question from state, clear interval and start timer back at 10
      setSeconds(time => {
        if (time < 1) {
          setIncorrect(wrong => wrong + 1)
          setQuestions(q => {
            q.splice(0,1)
            return q;
          })
          clearInterval(interval);
          startTimer()
          return 10;
        }
        // I want this to hold the question data, but it is not
        if (questions.length === 0) {
          console.log("test")
        }
        // Else decrement seconds 
        return time - 1;
      });
    }, 1000);
  }

  // If answer is right, increment correct, else increment incorrect
  // Splice current question from const questions
  const userAnswer = (index) => {

    // Shows array of questions here
    console.log(questions)

    if (questions[0].answers[index] === questions[0].correct_answer) {
      setCorrect(correct => correct + 1)
    } else {
      setIncorrect(incorrect => incorrect + 1)
    }
    setQuestions(q => {
      q.splice(0,1);
      return q;
    })
    
    // Shows same array here despite splice in setQuestions above
    console.log(questions)
    setSeconds(10);
  }

  return (
    <div>
      <button onClick={() => getQuestions()}></button>
      {questions.length > 0 &&
        <div>
          <Question question={questions[0].question} />
          {questions[0].answers.map((answer, index) => (
            <Answer
              key={index}
              answer={answer}
              onClick={() => userAnswer(index)} />
          ))}
        </div>
      }
      <p>{seconds}</p>
      <p>Correct: {correct}</p>
      <p>Incorrect: {incorrect}</p>
    </div>

  )
}

export default App;

Upvotes: 2

Views: 1379

Answers (2)

dalmo
dalmo

Reputation: 443

This is because of closure.

When getQuestions executes this part:

    setQuestions(trivia)
    startTimer()

Both functions run in the same render cycle. In this cycle questions is still [] (or whatever previous value it held). So when you call console.log(questions) inside startTimer, you get an empty array.

You can fix that by invoking startTimer in the next render cycle with useEffect

  const [ start, setStart ] = useState(false)
 
  const getQuestions = async () => {
    let trivia = await axios.get("https://opentdb.com/api.php?amount=10&category=9&difficulty=easy&type=multiple")
    trivia = trivia.data.results
    trivia.forEach(result => {
      result.answers = shuffle([...result.incorrect_answers, result.correct_answer])
    })
    setQuestions(trivia)
    setStart(true)
  }

  useEffect(()=>{
   if (start) {
    startTimer();
    setStart(false)
   }
  },[start])

Upvotes: 2

CertainPerformance
CertainPerformance

Reputation: 370819

Each render of your App will create new bindings of its inner functions and variables (like questions and startTimer).

When the button is clicked and getQuestions runs, getQuestions then calls the startTimer function. At that point in time, the startTimer function closes over the questions variable that was defined at the time the button was clicked - but it's empty at that point. Inside the interval, although the function has re-rendered, the interval callback is still referencing the questions in the old closure.

Rather than depending on old closures, I'd initiate a setTimeout on render (inside a useLayoutEffect so the timeout can be cleared if needed) if the questions array is populated.

Another issue is that splice should not be used with React, since that mutates the existing array - use non-mutating methods instead, like slice.

Try something like the following:

// Get data, set questions to state, start timer
const getQuestions = async () => {
  let trivia = await axios.get("https://opentdb.com/api.php?amount=10&category=9&difficulty=easy&type=multiple")
  trivia = trivia.data.results
  trivia.forEach(result => {
    result.answers = shuffle([...result.incorrect_answers, result.correct_answer])
  })
  setQuestions(trivia);
  setSeconds(10);
}
useLayoutEffect(() => {
  if (!questions.length) return;
  // Run "timer" if the quiz is active:
  const timeoutId = setTimeout(() => {
    setSeconds(time => time - 1);
  }, 1000);
  return () => clearTimeout(timeoutId);
});
// If quiz is active and time has run out, mark this question as wrong
// and go to next question:
if (questions.length && time < 1) {
  setIncorrect(wrong => wrong + 1)
  setQuestions(q => q.slice(1));
  setSeconds(10);
}
if ((correct || incorrect) && !questions.length) {
  // Quiz done
  console.log("test")
}

Upvotes: 2

Related Questions