Reputation: 173
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
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
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