DEV Community

Cover image for Tic-Tac-Toe Hard Mode
James Conlon
James Conlon

Posted on

Tic-Tac-Toe Hard Mode

Anyone who knows React is probably familiar with the famous tic-tac-toe example from the training docs. It covers most of the basic priciples of React in one single project. It also includes a handful of challenges at the end to help you expand on what you've learned. I distinctly remember not being able to do any of them and quitting immediately. Well who's laughing now? (Me)

Anyway, we do it because it's there. Keep reading and I guarantee you'll find something you disagree with.

1. Change button to text

For the current move only, show “You are at move #…” instead of a button.

Full Solution on Codesandbox

Nice easy one to start. The final button in the list does nothing so we replace it with a plain text message.

Solution for problem 1

This is simple enough to do with a ternary inside the component. We already map each move to a list item <li>. Now inside each list item we check to see if it's the final element (remember -1 because zero index). Then we return either a string or a button.

<li key={move}>
  {move === history.length - 1 ? (
    `You are at move #${move}`
  ) : (
    <button onClick={() => jumpTo(move)}>{description}</button>
  )}
</li>
Enter fullscreen mode Exit fullscreen mode

2. Refactor Board

Rewrite Board to use two loops to make the squares instead of hardcoding them.

Full Solution on Codesandbox

This is purely a refactor. Only the code will change. The actual rendered app and functionality will remain exactly the same.

First we'll try the nice solution. The one that I want to work. I want it to work so bad!

const boardLayout = [...Array(9).keys()].map((i) => (
  <Square value={squares[i]} onSquareClick={() => handleClick(i)} />
));
Enter fullscreen mode Exit fullscreen mode

Look how neat that is! They key bit here is [...Array(9).keys()]. This is a time-honoured javascript hack that acts as our equivalent of a range function in any other language Basically, create an array of 9 elements, get the keys of those elements (0, 1, 2, ...) and then spread those keys out into an array. This lets me nicely map out the same element nine times using a single line of code. But... there's a catch!

Board rendered incorrectly

Yep, that's not right! We have forgotten to add the row container. This means that instead of using my catchy one-liner from above we are going to have to perform an unholy combination of logic and layout. So we end up with the code below. It's a loop within a loop, as specified. Three rows, three columns. It's definitely not as pretty, but pretty wasn't the challenge.

const boardLayout = [...Array(3).keys()].map((row) => (
  <div className="board-row">
    {[...Array(3).keys()].map((col) => {
      const i = row * 3 + col;
      return <Square value={squares[i]} onSquareClick={() => handleClick(i)} />;
    })}
  </div>
));
Enter fullscreen mode Exit fullscreen mode

Replacing the original static layout with a new dynamic one has pros and cons. Pro: we reduce repeated code and make it more maintainable. Con: We replace a simple layout with much more convoluted logic, making the code much less readable. In this case I would always prefer the static layout but dynamic layouts are very common and are much more maintainable in larger apps.

3. Toggle

Add a toggle button that lets you sort the moves in either ascending or descending order.

Full Solution on Codesandbox

This one is super easy!

Solution for problem 3
Solution for problem 3

The layout for our moves currently looks like this: <ol>{moves}</ol>.
Here moves is nothing but an array of JSX elements. Why not swap them around like so <ol>{moves.reverse()}</ol>?

This is perfectly fine to do because moves isn't state. It is derived from state, i.e. moves = history.map(). Crucially, the map function creates a copy of the array that we can mutate as much as we like. No state problems here officer!

Obviously, that's not exactly everything. I'll add a single piece of boolean state, toggle it with a button and check it before reversing.

return (
  <div className="game">
    <div className="game-board">
      <Board xIsNext={xIsNext} squares={currentSquares} onPlay={handlePlay} />
    </div>
    <div className="game-info">
      <ol reversed={isMovesReversed}>
        {isMovesReversed ? moves.reverse() : moves}
        <br />
        <button onClick={() => setIsMovesReversed(!isMovesReversed)}>
          {isMovesReversed
            ? "Show moves in ascending order"
            : "Show moves in descending order"}
        </button>
      </ol>
    </div>
  </div>
);
Enter fullscreen mode Exit fullscreen mode

And bingo! Note that isMovesReversed is used to control the order of the moves elements and the text on the button. Using ternarys for simple logic like this is perfectly fine.

One side note that I hadn't thought about is that this is an ordered list. It doesn't care what order the array is in. It just counts elements starting from 1. Luckily HTML came pre-bundled with a solution for that. <ol> in native HTML has a reversed attribute. We can just tie that to isMovesReversed. Beautiful native HTML!

4. Winning streak

Full Solution on Codesandbox

When someone wins, highlight the three squares that caused the win (and when no one wins, display a message about the result being a draw).

Solution for problem 4

The good news with this one is that all the heavy lifting of calculating the winner has already been done. The original solution contains the following.

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],
];
Enter fullscreen mode Exit fullscreen mode

lines defines every possible combination that could be considered a straight line on the board. calculateWinner checks if all three have the same value and then returns that value. If we adapt calculateWinner we can have it also return the specific combination that won. Sounds super useful!

return [squares[a], lines[i]];
Enter fullscreen mode Exit fullscreen mode

This is what that looks like. The function now returns two values. So it's important we remember to destructure those two values everywhere we use it.

const [winner, winnerLine] = calculateWinner(squares);
Enter fullscreen mode Exit fullscreen mode

Like so. The first bit winner is used for it's original purpose. The second bit winnerLine will go on to do great things.

But first we have to consider the actual square component. How do we make it turn green? In react we can pass inline CSS as a simple object to the style prop.

function Square({ value, onSquareClick }) {
  return (
    <button
      style={{ background: "green" }}
      className="square"
      onClick={onSquareClick}
    >
      {value}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

You will see every square has now turned green. That's what we wanted right? Damn! Ok, let's make it conditional on some prop isWinner. We'll figure out what that is later.

function Square({ value, onSquareClick, isWinner }) {
  return (
    <button
      style={isWinner ? { background: "green" } : {}}
      className="square"
      onClick={onSquareClick}
    >
      {value}
    </button>
  );
}
Enter fullscreen mode Exit fullscreen mode

Uh-oh! It's later already. How are we going to tell this square if it's a winner or not? Remember when I said winnerLine would go on to do great things? Turns out we can just check the square number, and if it's in winnerLine, it's a winner!

<Square
  value={squares[i]}
  onSquareClick={() => handleClick(i)}
  isWinner={winnerLine && winnerLine.includes(i)}
/>
Enter fullscreen mode Exit fullscreen mode

It's that easy! Note, we check if it exists first. winnerLine only exists once a winner has been determined.

One more thing. We need a stalemate message. Stalemate occurs when we reach the final move and there is no winner. I think the easiest way to achieve this is to give the board an extra prop currentMove and pass this down from App. Nothing super clever here just a few mutually exclusive if statements to cover all our cases.

if (winner) {
  status = "Winner: " + winner;
}
if (!winner && currentMove === 9) {
  status = "Stalemate!";
}
if (!winner && currentMove !== 9) {
  status = "Next player: " + (xIsNext ? "X" : "O");
}
Enter fullscreen mode Exit fullscreen mode

It's not perfect but it will cover every case we will encounter here.

5. Move Locations

Display the location for each move in the format (row, col) in the move history list.

Full Solution on Codesandbox

Like the prompt said. Display the board coordinates of each move.

Solution for problem 5

Currently state is used to track two things

  • history
  • currentMove

We could add an extra piece of state. i.e. for each move we store a number that represents the location of that move. That's cool, but we would have to ensure we keep that new state in sync with the history at all times. Not so easy, especially in a larger app. We could also augment history into an object so it could store more detailed information. That sounds like a better option but can we go even better? Can we use what we already have?

By comparing the board between any two given moves we can determine the location of the move. All this information already exists in state, we just calculate what we want on the render. It's definitely putting some computation onto each render but I think here we will get away with it.

function getMoveIndex(oldSquare, newSquare) {
  if (oldSquare && newSquare) {
    for (let i = 0; i < oldSquare.length; i++) {
      if (oldSquare[i] !== newSquare[i]) {
        return i;
      }
    }
  }

  return -1;
}
Enter fullscreen mode Exit fullscreen mode

Basically, give me two squares: old and new. Run through the each element of the squares until you find two values that aren't the same. That's the index that changed! The default case (-1) is to cover the first move where a previous move does not exist.

function indexToCoords(i) {
  if (i >= 0 && i <= 8) {
    return `[${Math.floor(i / 3) + 1}, ${(i % 3) + 1}]`;
  }
  return "";
}
Enter fullscreen mode Exit fullscreen mode

It's trivial to go from an index value to a row/column value but it looks terrible. All that logic is neatly wrapped in indexToCoords.

const coords = indexToCoords(getMoveIndex(history[move - 1], history[move]));
Enter fullscreen mode Exit fullscreen mode

Finally, for each move in history we just calculate coords (as in coordinates). Now you can use coords wherever you want to display the string. Note that on the first pass history[move - 1] actually becomes history[-1] which is undefined. This is why we setup getMoveIndex to handle undefined input.

Epilogue

I think these were useful challenges. Refactoring and retooling code forces you to make a million small design decisions every day. These exercises do a nice job of exposing them at a small scale. Hopefully all the little decisions I've made here are defensible. If not, fight me in the comments. What would you do differently?

Top comments (1)

Collapse
 
anmolbaranwal profile image
Anmol Baranwal

You have certainly improved upon the usual one, and that's very good :)

I'm not sure if you're aware, but there's another more challenging version. In it, you play nine individual games in a 3 by 3 grid. (Each grid has 1 game with 9 blocks)

The next box is decided based on the previous move. haha!
It has very innovative rules; you should definitely check it out.

You'll surely love it.