Skip to main content
voidst.one

Tic-Tac-Toe with React, Redux, Rust - Part 1

Background

This originally started as a toy project that I have created to explore Web Assembly and Rust, with the goal of deepening my understanding of how they can be incorporated into a web application.

I was quite pleased with the final outcome, seeing how I have put into practice the valuable lessons and concepts that I have gained over the years, which would have been greatly beneficial to my younger self when I first started coding.

We’ve all wished for someone to look over our shoulder, point out what could be improved and show us how to do it better, but such guidance is often hard to come by. Hence I have decided to document my thoughts and share my experience in a blog post, and I hope those who are striving to improve will find something helpful to take away from my experience.

Introduction

As Lao Tzu puts it, "A journey of a thousand miles begins with a single step." For many, myself included, Tic-Tac-Toe was that very first step that set everything in motion. It was one of the first games I played on a computer, and one of the first games I wrote when I began my programming journey.

React honours that tradition with its official Tic-Tac-Toe tutorial, introducing beginners to core concepts through building a simple Tic-Tac-Toe game. If you’re new to React, I recommend completing the official tutorial first.

In this blog post, I will build upon that foundation and share ideas on how you can further improve your Tic-Tac-Toe game.

Source Code and Live Demo

The full project source code and live demo are available in the links below:

Why Redux?

I prefer to keep the game state and game logic decoupled from React components, and Redux excels at this by centralising state management.

This approach allows components to access and update state independently, without relying on prop drilling or direct communication between components, which promotes cleaner separation of concerns and makes the codebase easier to maintain and scale, even as the application and game logic becomes more complex.

By managing state and logic outside the UI layer, Redux also simplifies testing and debugging, ensuring that components remain focused on rendering and user interaction while the business logic is handled predictably through actions and reducers.

Setting up a new React and Redux Project

First, let's create a new React project using the official Redux with TypeScript template for Vite, with the following command.

$ npx degit reduxjs/redux-templates/packages/vite-template-redux react-app

Once it's done with cloning the template, you can cd in the new directory and run it with $ npm install && npm start

If you are on macOS with devcontainer, and the page isn't loading in your browser, it might be due to Vite listening on only the IPv6 address (::1) and docker doesn't support IPv6 networking on macOS (at time of writing).

To fix this, you can set the server host address to "127.0.0.1" in vite.config.ts, to force it to listen on the IPv4 localhost address (127.0.0.1).

  server: {
    host: '127.0.0.1'
  },

Or alternatively you can start it with "npm start -- --host" to listen on all addresses, including LAN and public addresses.

Installing Shadcn UI

For this project, I will be using Shadcn UI together with Tailwind CSS for styling. If you want to follow along, you can refer to the Installation Guide for Vite.

Note that I am just sharing some tips and ideas here, so there's no need to follow everything I do exactly. Different strokes for different folks, feel free to adapt or change them as you see fit.

Setting up the pages

There are only 2 pages, one for the main menu and one for the game page. So to keep things simple, I will skip React Router and handle navigation with simple conditional rendering. Here's what the App.tsx looks like:

import type { JSX } from "react"
import { MainMenu } from "@/features/tic-tac-toe/pages/MainMenu"
import { GamePage } from "@/features/tic-tac-toe/pages/GamePage"

export const App = (): JSX.Element => (
  <>{true ? <MainMenu /> : <GamePage />}</>
)

For a quick refresher: I am wrapping the return value in an empty JSX tag <></> (shorthand for Fragment) instead of a <div> to avoid adding an unnecessary parent div element to the DOM.

Within the tag, I use the conditional (ternary) operator to determine which page gets rendered. To check if it's working properly, you can manually change the boolean value and see if the right page gets rendered.

Redux (Classic) vs Redux Toolkit

Before we dive into writing Redux code, I want to clarify that there are two main ways you will see Redux used: the classic Redux approach and the modern Redux Toolkit. As a result, you might encounter different code styles and patterns online.

So don’t be confused — Redux Toolkit is now the officially recommended way to write Redux code, and that's what we are using here.

Classic Redux guides are around, mainly as references to help developers understand the underlying concepts and mechanics of Redux, but for most new projects, Redux Toolkit offers a more streamlined development experience, encapsulating best practices, reducing boilerplate and making code easier to write and maintain.

Implementing page navigation with Redux

Game status definition

We will begin by defining the three possible game status:

Creating the Redux slice

With the game status defined, we can now proceed to create the Redux slice. A "slice" is a collection of Redux reducer logic and actions for a single feature in the app. Here's how the Tic-Tac-Toe slice looks like:

import { createAppSlice } from "@/app/createAppSlice"

export enum GameStatus {
  MainMenu,
  GameStarted,
  GameEnded,
}

export type TicTacToeSliceState = {
  gameStatus: GameStatus
}

const initialState: TicTacToeSliceState = {
  gameStatus: GameStatus.MainMenu,
}

export const ticTacToeSlice = createAppSlice({
  name: "ticTacToe",
  initialState,
  reducers: create => ({
    startGame: create.reducer(state => {
      state.gameStatus = GameStatus.GameStarted
    }),
    exitGame: create.reducer(() => {
      return initialState
    }),
  }),
  selectors: {
    selectGameStatus: state => state.gameStatus,
  },
})

export const { startGame, exitGame } = ticTacToeSlice.actions
export const { selectGameStatus } = ticTacToeSlice.selectors

Starting from the top, the code defines an enum called GameStatus, which represents the possible status of the game that was defined earlier.

Next, the type declaration that outlines the structure of the slice state, specifying the properties and it's types.

The initial state is then defined as a constant, providing default values for each property in the state.

Following that, within the app slice, you’ll find both reducers and selectors functions:

Actions (reducers) and selectors are then exported to make them accessible to React components.

With the new slice created, the next step is to add it to the root reducer in the app store configuration file (src/app/store.ts).

I kept the original template slices in place for reference, but feel free to remove them if you don't need them.

const rootReducer = combineSlices(counterSlice, quotesApiSlice, ticTacToeSlice)

Using the selectors and reducers

Now let's take a look at how the selectors and reducers can be used in the components to transition between pages.

Here's the updated App.tsx, using the selector to access state data:

...
import { useAppSelector } from "@/app/hooks"
import {
  GameStatus,
  selectGameStatus,
} from "@/features/tic-tac-toe/ticTacToeSlice"

export const App = (): JSX.Element => {
  const gameStatus = useAppSelector(selectGameStatus)
  return <>{gameStatus === GameStatus.MainMenu ? <MainMenu /> : <GamePage />}</>
}

And here's the main menu page, using the dispatch function to trigger the reducer to update the state data:

import type { JSX } from "react"
import { useAppDispatch } from "@/app/hooks"
import { Button } from "@/components/ui/button"
import { startGame } from "../ticTacToeSlice"

export const MainMenu = (): JSX.Element => {
  const dispatch = useAppDispatch()
  return (
    <div className="flex flex-col items-center justify-center min-h-svh gap-y-2">
      <Button onClick={() => dispatch(startGame())}>Practice with Bot</Button>
      <Button onClick={() => dispatch(startGame())}>Local Two Player Mode</Button>
    </div>
  )
}

As you can see, adopting Redux is quite straightforward. Centralised state management not only simplifies your workflow but also keeps the codebase organised and maintainable.

Passing arguments to reducers

Let's see how we can pass arguments to reducers, to finish up the main menu buttons. We will start by introducing player type (human and bot) to the slice state.

export enum PlayerType {
  Human,
  Bot,
}

export type Player = {
  id: number
  name: string
  playerType: PlayerType
}

export type TicTacToeSliceState = {
  gameStatus: GameStatus
  players: Player[]
}

const initialState: TicTacToeSliceState = {
  gameStatus: GameStatus.MainMenu,
  players: [],
}

In Redux Toolkit, reducers receive parameters through the action.payload property. The payload can be any data type — such as a string, number, object, or array — depending on what your application needs.

For the updated startGame reducer, I am using Player[] as the payload.

startGame: create.reducer(
  (state, action: PayloadAction<Player[]>) => {
    state.gameStatus = GameStatus.GameStarted
    state.players = action.payload
  },
),

If you need to pass multiple parameters to your reducer, you can bundle them together by creating a custom payload object.

After updating the reducer, let’s modify the main menu buttons to pass in the required parameters. In the example below, I am using hardcoded values for simplicity. You can, of course, create additional UI elements to let players customise these options, but for this blog post, I will keep it simple.

const handlePracticeWithBotClick = () => {
  dispatch(startGame([
    {
      id: 0,
      name: 'You',
      playerType: PlayerType.Human,
    },
    {
      id: 1,
      name: 'Bot',
      playerType: PlayerType.Bot,
    },
  ]))
}

Looks good so far, but how can we verify that the state is updating correctly without introducing additional code?

Redux DevTools Browser Extension

We can use Redux DevTools to inspect the application state in real-time. Refer to the official installation guide for instructions.

Here's a screenshot of it in action:

Redux DevTools in action

I have covered quite a bit so far, and I will pause here and continue with the next steps in the following post.