voxtm
voxtm

Reputation: 490

React useState not updating

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?

CodeSandbox

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

Answers (2)

superhawk610
superhawk610

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

Imanpal Singh
Imanpal Singh

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

Related Questions