ship
SalesForce Simplified

Your Go-To Resource for Streamlined Solutions and Expert Guidance

mountains
Empower Your Business
Dive deep into the world of CRM excellence, where innovation meets practicality, and transform your Salesforce experience with Forceshark's comprehensive resources

Click Handling and State Management in a Tic-Tac-Toe LWC App

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.

From Display to Interaction

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:

  • 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

Let’s explore how we do this in Lightning Web Components.

 The CellComponent: Clickable, But Smart About It

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: A Middleman With Purpose

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.

 GameContainer: The Brain Behind the Board

Here’s where the magic happens. GameContainer manages the game state:

  • Keeps track of whose turn it is
  • Updates the board on each move
  • Prevents clicking the same cell twice
<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.

Wrapping Up

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:

  • Smart event bubbling from cells up to the container
  • Immutably updating the board state
  • Switching players after each move

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.