Now we’re changing the game (literally). Here’s what we want to happen:
- The user clicks on a cell
- That click updates the game state
- The board reflects the new state
- We switch turns from X to O, and vice versa
When we first built the board for our Tic-Tac-Toe game using Lightning Web Components (LWC), everything was static. The board displayed nine cells with IDs, but there was no interaction. It was like looking at a chessboard behind glass - you couldn’t touch it. Now, it’s time to breathe life into our components and make them interactive by introducing click handling and state management.
This is the chapter where we turn our plain HTML board into an actual game. So buckle up - we’re diving into clicks, events, reactivity, and managing which player moves next.
Before we jump into the details, let’s take a quick look back. In the previous article, the CellComponent
simply displayed a static number (its ID), and clicking on a cell didn’t do anything. The BoardComponent
just passed a list of cells to render.
Now we’re changing the game (literally). Here’s what we want to happen:
Let’s explore how we do this in Lightning Web Components.
Previously, the cell component only showed a number. Now, it shows a player’s move (X or O), and only if that cell is empty can it be clicked. That way, players can't overwrite each other's moves.
<template>
<div class="slds-m-around_xxx-small cell" onclick={handleClick}>
<span>{value}</span> <!-- ✅ UPDATED: displays value instead of ID -->
</div>
</template>
import { LightningElement, api } from 'lwc';
export default class CellComponent extends LightningElement {
@api cellId;
@api value;
// ✅ UPDATED: Only fires click event if cell is empty
handleClick() {
if (!this.value) {
this.dispatchEvent(
new CustomEvent('click', {
bubbles: true, // ✅ NEW: allows the event to propagate to boardComponent
composed: true
})
);
}
}
}
Now our cells only respond to clicks if they haven’t been played yet. We also bubble the event so it can be caught higher up in the hierarchy.
The BoardComponent
is no longer just a passive presenter of rows and cells. It's now an active intermediary - listening for click events from each cell and bubbling them up to the container that manages the game state.
<template>
<template for:each={rows} for:item="row">
<lightning-layout key={row.id}>
<template for:each={row.cells} for:item="cell">
<lightning-layout-item key={cell.id} size="4" flexibility="auto">
<!-- ✅ UPDATED: passing value and click handler -->
<c-cell-component
cell-id={cell.id}
value={cell.value}
onclick={handleCellClick}>
</c-cell-component>
</lightning-layout-item>
</template>
</lightning-layout>
</template>
</template>
import { LightningElement, api } from 'lwc';
export default class BoardComponent extends LightningElement {
@api cells = [];
get rows() {
const rows = [];
for (let i = 0; i < this.cells.length; i += 3) {
rows.push({
id: `row-${i / 3}`,
cells: this.cells.slice(i, i + 3)
});
}
return rows;
}
// ✅ NEW: handleCellClick forwards the event from child cells to the game container
handleCellClick(event) {
const cellId = event.target.cellId;
this.dispatchEvent(new CustomEvent('cellclick', { detail: { cellId } }));
}
}
This pattern - where one component listens to a child's event and emits its own - keeps responsibilities separated and code clean.
Here’s where the magic happens. GameContainer
manages the game state:
<template>
<lightning-card title="Tic-Tac-Toe">
<div class="slds-p-around_medium slds-text-align_center">
<p class="slds-text-heading_small">{message}</p> <!-- ✅ NEW: shows current player -->
</div>
<div class="slds-p-horizontal_x-small">
<!-- ✅ UPDATED: listens for cellclick event -->
<c-board-component cells={cells} oncellclick={handleCellClick}></c-board-component>
</div>
</lightning-card>
</template>
import { LightningElement, track } from 'lwc';
export default class GameContainer extends LightningElement {
@track cells = this.generateInitialBoard(); // ✅ UPDATED: creates reactive board
currentPlayer = 'X';
@track message = 'Current Player: X'; // ✅ NEW: displays game state
generateInitialBoard() {
return Array.from({ length: 9 }, (_, index) => ({
id: index,
value: ''
}));
}
// ✅ NEW: handles clicks from board, updates state immutably
handleCellClick(event) {
const { cellId } = event.detail;
const targetCell = this.cells[cellId];
if (targetCell.value) return; // Ignore if already filled
// Immutable update of the board
this.cells = this.cells.map(cell =>
cell.id === cellId ? { ...cell, value: this.currentPlayer } : cell
);
// Switch turn
this.currentPlayer = this.currentPlayer === 'X' ? 'O' : 'X';
this.message = `Current Player: ${this.currentPlayer}`;
}
}
Notice how we’re using the spread operator (...
) to create a new version of the cell object instead of mutating it directly. This helps LWC’s reactivity system know that something changed.
By adding event handling and reactive state updates, we’ve taken a huge step forward in making our Tic-Tac-Toe game actually playable. We’ve introduced:
This might feel like a lot, but think of it as the foundational plumbing. Now that our board reacts to user input, we’re ready to take on the next challenge: detecting a winner and enforcing game rules. Spoiler alert - someone’s going to win (or tie) soon.
Are you ready to make your game smart enough to recognize a win? Let’s do it in the next chapter.