Skip to main content Accessibility Feedback

Recreating the React tic-tac-toe game with vanilla JS

Last week, I kicked off a series where I recreate popular framework demos with vanilla JS, starting with VueJS.

This week, we’re going to build React’s Tic-Tac-Toe game. And we’re going to do with fewer lines of code and zero dependencies, and make it more accessible. You can see the finished script here.

Let’s get started.

The Starting Markup

The starting markup for this one is an empty div with an ID of #game-board. We’re going to use JavaScript to build the rest of the markup and inject it into that container.

<div id="game-board">Loading...</div>

The board

The React version uses div and button elements to create the tic-tac-toe board.

The buttons are good for accessibility, because they allow people also navigate around the board using a keyboard. However, someone using a screen reader would have no idea which square in the board they’re on. I’m going to use a table element instead.

It will look roughly like this.

<table>
	<tbody>
		<tr>
			<td><button class="game-square" data-id="0"></button></td>
			<td><button class="game-square" data-id="1" aria-pressed="true" disabled>X</button></td>
			<td><button class="game-square" data-id="2" aria-pressed="true" disabled>O</button></td>
		</tr>
		<tr>
			<td><button class="game-square" data-id="3"></button></td>
			<td><button class="game-square" data-id="4" aria-pressed="true" disabled>X</button></td>
			<td><button class="game-square" data-id="5"></button></td>
		</tr>
		<tr>
			<td><button class="game-square" data-id="6"></button></td>
			<td><button class="game-square" data-id="7" aria-pressed="true" disabled>O</button></td>
			<td><button class="game-square" data-id="8"></button></td>
		</tr>
	</tbody>
</table>

I plan to use a [data-id] attribute so that my script can identify which square was pressed. the aria-pressed attribute lets screen readers know the button has already been selected, while the disabled attribute prevents it from being pressed again.

We’ll also add some light styling to the game board.

table {
	border-collapse: collapse;
	border-spacing: 0;
}

.game-square {
	background: none;
	border: 1px solid #808080;
	color: #272727;
	font-size: 2em;
	font-weight: bold;
	height: 1.5em;
	width: 1.5em;
}

Planning out the end state like this makes it easier to actually write the script later.

Tracking States

There are a few pieces of information we need to track for this to work.

First, we need a starting state for the game—as in, what’s the value in each square of our board. This value can be represented as an array of nine items, with each item mapping to the corresponding box in our grid (and [data-id]).

To prevent this default state from being modified, I’m going to make it a function that returns an array to give it immutability. By default, every square is null.

var baseState = function () {
	return [null, null, null, null, null, null, null, null, null];
};

The React version tracks game history, and let’s users show the board in any previous state. To track that, I’ll create an empty array that will hold that data.

var historyState = [];

Finally, I need to store the current state of the board, and who’s turn it is (X or O). I’ll create two placeholder variables for that.

var currentState, turn;

Starting the game

When the game loads for the first time, or if the user clicks a “Play Again” button that we’ll add, I want to reset the state and render the game board.

This will set the currentState to the baseState, reset the historyState to an empty array, and set the current turn to X. Then, it will call an updateBoard() function we’ll use to actually generate the board.

/**
 * Reset the board to it's base state
 */
var resetBoard = function () {
	currentState = baseState();
	historyState = [];
	turn = 'X';
	updateBoard();
};

// Reset the board when the game loads
resetBoard();

In the updateBoard() function, I use querySelector to get the #game-board and update it with innerHTML.

If a game state is passed into it, we can use that. Otherwise, we’ll use the currentState. We’ll create another helper function, buildBoard(), to generate the actual markup string.

/**
 * Update the board based on a state
 * @param  {Array} state The state to update from (optional, defaults to currentState)
 */
var updateBoard = function (state) {
	var board = document.querySelector('#game-board')
	if (!board) return;
	board.innerHTML = buildBoard(state || currentState);
};

Building the board

In the buildBoard() function, I create a rows variable to hold the markup string, and create a table element.

For each square in the state, I need to create a td element, setup in rows of three. To keep this script lean and clean, we’ll handle that in a buildSquares() method (more on that in a second).

Then, I’ll close off the table and add a “Play Again” button. Finally, I’ll return the markup string.

/**
 * Build the game board
 * @param  {Array} state The state to build from
 * @return {String}      The markup based on the state
 */
var buildBoard = function (state) {

	// Setup the table
	var rows = '<table><tbody>';

	// Create each square
	rows += buildSquares(state);
	rows += '</tbody></table><p><button id="play-again">Play Again</button></p>';

	return rows;
};

In the buildSquares() method, I’ll setup an empty string assigned to the rows variable. This will eventually hold the data for each row in our table.

I’ll use forEach() to loop through each item in the state (our array of squares). If it’s not null, I’ll…

  • Use it’s value as our button text (the value variable).
  • Add an aria-pressed="true" attribute (the selected variable).
  • Add a disabled attribute (the disabled variable).

I also need to check if the current square is the first in a row (and if so create a new tr), or the last in the row (and if so close the current tr). For now, I’ll move the functionality for that to isFirstInRow() and isLastInRow() methods.

Finally, I’ll return my rows string so that it can be concatenated in the buildBoard() function.

var buildSquares = function (state) {

	// Setup rows
	var rows = '';

	// Loop through each square in the state
	state.forEach(function (square, id) {

		// Variables
		var value = square ? square : '';
		var selected = square ? ' aria-pressed="true"' : '';
		var disabled = square ? ' disabled' : '';

		// Check if it's a new row
		if (isFirstInRow(id)) {
			rows += '<tr>';
		}
		rows += '<td><button class="game-square" data-id="' + id + '"' + selected + disabled + '>' + value + '</button></td>';

		// Check if it's the last column in a row
		if (isLastInRow(id)) {
			rows += '</tr>';
		}

	});

	return rows;

};

So, how do we check if it’s the first or last square in a row?

We can use a modulo operator (%) to divide the square’s index by the number of items in our row (3) and get the remainder.

The math is easier when you start at 1, so we’ll add 1 to the index before doing this. Squares 1, 4, and 7 (after adding 1) are the first in each row. Squares 3, 6, and, 9 (after adding 1) are the last.

1 / 3, 4 / 3, and 7 / 3 have a remainder of 1. 3 / 3, 6 / 3, and 9 / 3 have a remainder of 0. Using the modulo operator to get the remainder will tell us if the square is first or last in a row. We’ll check the remainder and return a boolean.

var isFirstInRow = function (id) {
	return (id + 1) % 3 === 1;
};

var isLastInRow = function (id) {
	return (id + 1) % 3 === 0;
};

Playing the game

Now we’re ready to actually play!

Let’s use event bubbling to listen for click events on the document.

// Listen for selections
document.addEventListener('click', function (event) {
	// Do something...
}, false);

If the #play-again button was clicked, we can call the resetBoard() method to wipe out the current states and render a clean board.

If the clicked element was our .game-square buttons and it doesn’t have a disabled attribute, we’ll update the state and render the board again. We’ll handle that in a renderTurn() helper function, passing in the button.

// Listen for selections
document.addEventListener('click', function (event) {

	// If #play-again was clicked
	if (event.target.matches('#play-again')) {
		resetBoard();
	}

	// If a .game-square was clicked
	if (event.target.matches('.game-square') && !event.target.hasAttribute('disabled')) {
		renderTurn(event.target);
	}

}, false);

First, we’ll get the square’s ID from the [data-id] attribute.

/**
 * Render the board again based on the current user's turn
 * @param  {Node} square The square that was selected
 */
var renderTurn = function (square) {

	// Get selected value
	var selected = square.getAttribute('data-id');
	if (!selected) return;

};

We’ll update that square in the currentState array to the turn value. This starts with X, and, as you’ll see further in this function, switches between X and O with each turn.

/**
 * Render the board again based on the current user's turn
 * @param  {Node} square The square that was selected
 */
var renderTurn = function (square) {

	// Get selected value
	var selected = square.getAttribute('data-id');
	if (!selected) return;

	// Update state
	currentState[selected] = turn;

};

Next, we want to add the current state of the board to the historyState array. We’ll do this using the .push() method, and pass in a fresh copy of the currentState by using the .slice() method on it.

/**
 * Render the board again based on the current user's turn
 * @param  {Node} square The square that was selected
 */
var renderTurn = function (square) {

	// Get selected value
	var selected = square.getAttribute('data-id');
	if (!selected) return;

	// Update state
	currentState[selected] = turn;

	// Add a historical state
	historyState.push(currentState.slice());

};

Now, we can update the board markup by calling our updateBoard() method. We’ll also update the turn variable. If it’s currently X we’ll switch it to O and vice-versa.

/**
 * Render the board again based on the current user's turn
 * @param  {Node} square The square that was selected
 */
var renderTurn = function (square) {

	// Get selected value
	var selected = square.getAttribute('data-id');
	if (!selected) return;

	// Update state
	currentState[selected] = turn;

	// Add a historical state
	historyState.push(currentState.slice());

	// Render with new state
	updateBoard();

	// Update turn
	turn = turn === 'X' ? 'O' : 'X';

};

What about the history state?

We’re tracking the turn history, but not doing anything with it just yet. Let’s fix that!

In the buildBoard() method, let’s concatenate a buildHistory() method at the end of our rows variable. We’ll use this method to build the markup string.

/**
 * Build the game board
 * @param  {Array} state The state to build from
 * @return {String}      The markup based on the state
 */
var buildBoard = function (state) {

	// Setup the board
	var rows = '<table><tbody>';

	// Create each square
	rows += buildSquares(state, winner);
	rows += '</tbody></table><p><button id="play-again">Play Again</button></p>';

	// Create game history
	rows += buildHistory();

	return rows;
};

In the buildHistory() method, I’ll setup a history variable to hold the markup.

If the historyState array has any items in it, we’ll add a “Game History” heading and setup an ordered list. Then, we’ll loop through each state and create a list item and button.

On the button, let’s add a [data-history] attribute with a stringified version of the state for that turn using the Array.toString() method.

/**
 * Build the history state buttons markup
 * @return {String} The markup
 */
var buildHistory = function () {

	// Setup history markup
	var history = '';

	// If there's a history state, loop through each state and build a button
	if (historyState.length > 0) {
		history += '<h2>Game History</h2><ol>';
		historyState.forEach(function (move, index) {
			history += '<li><button data-history="' + move.toString() + '">Go to move # ' + (index + 1) + '</button></li>';
		});
		history += '</ol>';
	}

	return history;

};

Now we’re showing the history buttons, but we need to do something when someone clicks one.

In the event listener, if the clicked element has a [data-history] attribute, we can convert it’s value back into an array with the String.split() method. Then we’ll pass that state into the updateBoard() method, which will render it for us.

document.addEventListener('click', function (event) {

	// If a .game-square was clicked
	if (event.target.matches('.game-square') && !event.target.hasAttribute('disabled')) {
		renderTurn(event.target);
	}

	// If #play-again was clicked
	if (event.target.matches('#play-again')) {
		resetBoard();
	}

	// If a historical button was clicked
	if (event.target.matches('[data-history]')) {
		updateBoard(event.target.getAttribute('data-history').split(','));
	}

}, false);

What about when someone wins?

The one thing we haven’t done yet is handle what happens when someone wins.

In the buildBoard() function, let’s add a variable and helper method to check if there’s a winner. If there is, we’ll add a “You’ve won!” message to the top of the board indicating who won.

We’ll also pass that information into our buildSquares() method, and use it to disable the entire board.

/**
 * Build the game board
 * @param  {Array} state The state to build from
 * @return {String}      The markup based on the state
 */
var buildBoard = function (state) {

	// Check if there's a winner
	var winner = isWinner();

	// Setup the board
	var rows = winner ? '<p><strong>' + winner + ' is the winner!</string></p>' : '';
	rows += '<table><tbody>';

	// Create each square
	rows += buildSquares(state, winner);
	rows += '</tbody></table><p><button id="play-again">Play Again</button></p>';

	// Create game history
	rows += buildHistory();

	return rows;
};

In the buildSquares() method under the disabled variable, if the square has a value or there’s a winner, we’ll add the disabled attribute.

/**
 * Build each square of the game board
 * @param  {Array}   state  The board state
 * @param  {Boolean} winner If true, someone won the game
 * @return {String}         The markup
 */
var buildSquares = function (state, winner) {

	// Setup rows
	var rows = '';

	// Loop through each square in the state
	state.forEach(function (square, id) {

		// Variables
		var value = square ? square : '';
		var selected = square ? ' aria-pressed="true"' : '';
		var disabled = square || winner ? ' disabled' : '';

		// Check if it's a new row
		if (isFirstInRow(id)) {
			rows += '<tr>';
		}
		rows += '<td><button class="game-square" data-id="' + id + '"' + selected + disabled + '>' + value + '</button></td>';

		// Check if it's the last column in a row
		if (isLastInRow(id)) {
			rows += '</tr>';
		}

	});

	return rows;

};

Checking if there’s a winner

Ok, but… how do we check if someone won?

There are eight possible winning patterns. I created an array of wins, with each item containing an array with the square indexes for those winning patterns.

For example, having the same value in squares 0, 1, and 2 (the first row), or squares 0, 3, and 6 (the first column) is a winning pattern.

/**
 * Check if there's a winner
 */
var isWinner = function () {

	// Possible winning combinations
	var wins = [
		[0,1,2],
		[3,4,5],
		[6,7,8],
		[0,3,6],
		[1,4,7],
		[2,5,8],
		[0,4,8],
		[2,4,6]
	];

};

We can use Array.filter() to find any of those matching patterns in the currentState array.

The Array.filter() method let’s you check each item in an array against some criteria, and creates a new array containing only the items that match.

We’ll call it on the wins array, and check if indexes in the currentState for each winning pattern all match. If they do, that’s a winner.

If the new isWinner array has an items in it, there was a winner. We’ll return the value of the first item in the winning pattern (either an X or O). Otherwise, we’ll return false.

/**
 * Check if there's a winner
 */
var isWinner = function () {

	// Possible winning combinations
	var wins = [
		[0,1,2],
		[3,4,5],
		[6,7,8],
		[0,3,6],
		[1,4,7],
		[2,5,8],
		[0,4,8],
		[2,4,6]
	];

	// Check if there's a winning combo
	var isWinner = wins.filter(function (win) {
		return (currentState[win[0]] && currentState[win[0]] === currentState[win[1]] && currentState[win[0]] === currentState[win[2]]);
	});

	// Return the winner, or false if there isn't one
	return (isWinner.length > 0 ? currentState[isWinner[0][0]] : false);

};

Wrapping up

This was a lot more complex than the VueJS example from last week.

That said, the finished result is both more accessible than the version in the React docs, and fewer lines of code. That feels like a win to me!

You can view the full working project on JSFiddle.