Reputation: 490
I'm learning React and I know this subject has been covered by many questions, but they all are focused on the asynchronous nature of useState
. I'm not sure if that's what's happening here. I also tried a version in combination with useEffect
, and the result was the same.
I have a component where I'm listening to keypresses - user is trying to guess a word. Once the word is guessed, the word
object is supposed to be replaced with another one and a new puzzle begins.
The component renders using the correct state (characters of the new word), but when trying the first guess of the second puzzle, the word
object is still the original state object.
How can I update this word
object correctly?
Steps to reproduce in the readme.md:
const WordFrame = () => {
const [word, setWord] = useState(() => new Word 'apple');
const [renderedCharacters, setRenderedCharacters] = useState(
word.renderedCharacters
);
const keyDownHandler = (e: KeyboardEvent): void => {
console.log(e.key);
if (letters.includes(e.key)) {
const correctGuess = word.processGuess(e.key);
if (correctGuess) {
setRenderedCharacters([...word.renderedCharacters]);
// moving the following if block into useEffect with dependency on word.isGuessed and renderedCharacters doesn't help
if (word.isGuessed) {
const newWord: Word = new Word('banana');
setWord(newWord);
setRenderedCharacters(newWord.renderedCharacters);
}
}
}
};
useEffect(() => {
document.addEventListener('keydown', keyDownHandler);
return () => document.removeEventListener('keydown', keyDownHandler);
}, []);
// useEffect(() => {
// if (word.isGuessed) {
// const newWord = new Word('banana);
// setWord(newWord);
// setRenderedCharacters(newWord.renderedCharacters);
// }
// }, [word.isGuessed, renderedCharacters]);
return (
<div className='word-frame'>
{renderedCharacters.map((c) => (
<LetterFrame characterValue={c.value} key={c.id} />
))}
</div>
);
};
export default WordFrame;
Word.ts
class Word {
private readonly _unguessedCharacter: string = ' ';
private readonly _characters: string[] = [];
private _guessingIndex = 0;
private _renderedCharacters: Character[] = [];
public get renderedCharacters() {
return this._renderedCharacters;
}
private _isGuessed: boolean = false;
public get isGuessed(): boolean {
return this._isGuessed;
}
constructor(wordString: string) {
console.log(`Word.constructor called with parameter: ${wordString}`);
this._characters = wordString.split('');
this.setRenderedCharacters();
}
public processGuess(letter: string): boolean {
const isSuccessfulGuess = (): boolean =>
this._characters[this._guessingIndex].toLowerCase() ===
letter.toLowerCase();
const successful = isSuccessfulGuess();
if (successful) {
this._guessingIndex++;
this.setRenderedCharacters();
}
if (this._guessingIndex > this._characters.length - 1) {
this._isGuessed = true;
}
return successful;
}
private setRenderedCharacters(): void {
this._renderedCharacters = [];
this._characters.forEach((c, i): void => {
if (i >= this._guessingIndex) {
this._renderedCharacters.push(
new Character(this._unguessedCharacter)
);
} else {
this._renderedCharacters.push(new Character(c));
}
});
}
}
export default Word;
Upvotes: 3
Views: 2109
Reputation: 2663
The issue is that keyDownHandler
captures word
from the initial render, then is only attached to keydown
once. You need to remove and re-attach keyDownHandler
every time word
changes (remember that whenever you call setWord
, React will trigger a re-render where word
contains the new word value).
Here's the fix:
const WordFrame = () => {
const [word, setWord] = useState(() => new Word('apple'));
// ...
const keyDownHandler = (e: KeyboardEvent): void => { /* ... */ };
useEffect(() => {
document.addEventListener('keydown', keyDownHandler);
return () => document.removeEventListener('keydown', keyDownHandler);
}, [word]); // <-- add `word` to the dependencies for this effect
// ...
};
Now, every time WordFrame
rerenders with a new word
, it will attach a fresh copy of the keyDownHandler
closure that captures the most recent value of word
.
If you want to read more about this mechanic, I highly recommend Dan Abramov's (comprehensive) guide here. You can also Google "stale closure React useEffect" and find a few more good articles.
Upvotes: 2
Reputation: 1197
It's hard to debug without a codesandbox but I'm guessing since you want the useEffect to trigger when you do
setRenderedCharacters([...word.renderedCharacters]);
You should add renderedCharacters
as the dependency
useEffect(() => {
if (word.isGuessed) {
const newWord = getWord();
setWord(newWord);
setRenderedCharacters(newWord.renderedCharacters);
}
}, [word.isGuessed, renderedCharacters]);
Upvotes: 0