Coding without Coffee

Riffing with React 006: Finishing Off

Estimated reading time: 10 minutes

Alright, we’re down to the last two enhancements:

  1. When someone wins, highlight the three squares that caused the win.
  2. When no one wins, display a message about the result being a draw.

Let’s cut to the chase.

Win-win-win

Here’s the calculateWinner function:

function calculateWinner(squares) {
    const lines = [
        [0, 1, 2],
        [3, 4, 5],
        [6, 7, 8],
        [0, 3, 6],
        [1, 4, 7],
        [2, 5, 8],
        [0, 4, 8],
        [2, 4, 6]
    ];
    for (let i = 0; i < lines.length; i++) {
        const [a, b, c] = lines[i];
        if (squares[a] && 
            squares[a] === squares[b] && 
            squares[a] === squares[c]) {
            return squares[a];
        }
    }
    return null;
}

So, the function either returns null or it returns the winner as a string (‘X’ or ‘O’). Based on this string value, we can figure out what squares to highlight - since if, for instance, the winner is ‘X’ then surely all the squares with a value of ‘X’ will constitue the winning move. Not always! One bug coming up.

Which component should take care of the highlighting? It can’t be the Game component since it doesn’t directly render the squares. It could be the Board component, then. The Game component will pass the winner to the Board component and in turn the component will conditionally set the className property of a Square component in its renderSquare function.

Here’s what I have in mind. The Game component’s render function will now pass a winner prop to the Board component:

render() {
    ...
    return (
        <div className="game">
            <div className="game-board">
                <Board
                    squares={current.squares}
                    onClick={i => this.handleClick(i)}
                    winner={winner}
                />
    ...
}

And the Board component’s renderSquare function will change as follows:

renderSquare(i) {
    return (
        <Square
            key={i}
            value={this.props.squares[i]}
            onClick={() => this.props.onClick(i)}
            className={this.isSquareInWinningMove(i)? 
                'winner': 
                ''}
        />
    );
}

isSquareInWinningMove(i) {
    let winner = this.props.winner;

    if(!winner) {
        return false;
    }

    return this.props.squares[i] === winner;
}

Here’s the CSS for the winner class:

.winner .square {
  border: 2px solid green;
}

Is this going to work? Nope! The <Square> tag is not going to be a part of the DOM, so setting the className attribute on it will have no effect. Looks like we’ll need to let each Square know whether it is part of a winning move through the props:

renderSquare(i) {
    return (
        <Square
            key={i}
            value={this.props.squares[i]}
            onClick={() => this.props.onClick(i)}
            isWinner={this.isSquareInWinningMove(i)}
        />
    );
}

And then the Square component will set its class as necessary:

return (
    <button 
        className={props.isWinner? "square winner": "square"} 
        onClick={props.onClick}>
        {props.value}
    </button>
);

Also, our CSS rule needs to change:

.square.winner {
  border: 2px solid green;
}

Does our code work? One way to find out: unit tests!

describe("When the X user makes a winning move", () => {
    let game;

    ...
    it("then the winning squares should get highlighted", () => {
        game.find('button.square')
            .filterWhere(square => square.text() === 'X')
            .forEach(square => 
                assert.isTrue(square.hasClass('winner')));

        game.find('button.square')
            .filterWhere(square => square.text() === 'O')
            .forEach(square => 
                assert.isFalse(square.hasClass('winner')));
    });
});

describe("When the O user makes a winning move", () => {
    let game;
    
    ...
    it("then the winning squares should get highlighted", () => {
        game.find('button.square')
            .filterWhere(square => square.text() === 'O')
            .forEach(square => 
                assert.isTrue(square.hasClass('winner')));

        game.find('button.square')
            .filterWhere(square => square.text() === 'X')
            .forEach(square => 
                assert.isFalse(square.hasClass('winner')));
    });
});

describe("When no user makes a winning move", () => {
    let game;

    ...
    it("then no squares should be highlighted", () => {
        game.find('button.square')
            .forEach(square => 
                assert.isFalse(square.hasClass('winner')));      
    });
});

All our unit tests pass:

And the winning squares get nicely highlighted too:

What if the winning move comes after a prior failed winning move?

Screw gun! This is happening because of the isSquareInWinningMove’s second return statement:

isSquareInWinningMove(i) {
    let winner = this.props.winner;

    if(!winner) {
        return false;
    }

    return this.props.squares[i] === winner;
}

Instead of simply passing the winner to the Board component, we need to pass the winning squares. So we’ll have to go back to the calculateWinner function:

function calculateWinner(squares) {
    ...
    for (let i = 0; i < lines.length; i++) {
        const [a, b, c] = lines[i];
        if (squares[a] && 
            ...
            return {
                winner: squares[a],
                winningSquares: lines[i]
            };
    ...
}

Then update the rest of the code in the Game component:

    const history = state.history;
    const current = state.history[state.stepNumber];
    const winnerInfo = calculateWinner(current.squares);
    ...
    if (winnerInfo && winnerInfo.winner) {
        status = "Winner: " + winnerInfo.winner;
    } else if(!current.squares.includes(null)) {
        status = "Game ended in a draw";
    } else {
        status = "Next player: " + (state.xIsNext ? "X" : "O");
    }
    ...
    return (
    <div className="game">
        <div className="game-board">
            <Board
                squares={current.squares}
                onClick={i => handleClick(i)}
                winnerInfo={winnerInfo}
            />
        </div>
    ...

And finally fix the isSquareInWinningMove function:

isSquareInWinningMove(i) {
    let winnerInfo = this.props.winnerInfo;

    if(!winnerInfo) {
        return false;
    }

    return this.props.squares[i] === winnerInfo.winner &&
        winnerInfo.winningSquares.includes(i);
}

The squares get highlighted correctly now.

And all our unit tests pass too! The final showdown approaches.

There are no winners here

So, all we have to do is display a message saying that the game has ended in a draw. The Game component’s render function looks like a good place. In particular, the else branch here:

let status;
if (winner) {
    status = "Winner: " + winner;
} else {
    status = "Next player: " + (this.state.xIsNext ? "X" : "O");
}

Here’s what I’m thinking: if winner is null and there are no empty squares on the board then declare the game a draw.

if (winner) {
    status = "Winner: " + winner;
} else if(!current.squares.includes(null)) {
    status = "Game ended in a draw";
} else {
    status = "Next player: " + (this.state.xIsNext ? "X" : "O");
}

Oh hello, one of our tests failed!

We simply need to fix the last assert in the assertDrawState function:

let assertDrawState = game => {
    assert.equal(getSquaresAsText(game),
        JSON.stringify([
            'X', 'O', 'X',
            'X', 'O', 'X' ,
            'O', 'X', 'O']));
    assert.equal(getStatusText(game),
        "Game ended in a draw");
};

Whoops, one more test needs fixing:

We could probably store the draw message in a variable and re-use it across the failing tests, but I’m too lazy to do that now. All our tests are green:

And we haven’t compromised on test coverage:

And the game works:

So…we’re done with all the enhancements!