DEV Community

Zachary Shifrel
Zachary Shifrel

Posted on

React clicker game: useState vs redux toolkit

Let's make the skeleton of a clicker (idle) game in React. To manage our game's state, I'll start off by using the useState hook, and later upgrade it to redux toolkit. I came across this clicker vanilla javascript tutorial and thought it'd be helpful to expand upon it using React and its hooks.

useState Clicker

Create a new react project using create-react-app or Vite. I'm using Vite with typescript:

npm create vite@latest .
Enter fullscreen mode Exit fullscreen mode

Create a "components" folder and a file named "Game.tsx" if you're using typescript. In that file, I use the "tsrafce" snippet to generate the boilerplate:

// Game.tsx
import React from 'react'

type Props = {}

const Game = (props: Props) => {
  return (
    <div>Game</div>
  )
}

export default Game
Enter fullscreen mode Exit fullscreen mode

I then import Game from components into App.tsx:

// App.tsx
import Game from "./components/Game"

function App() {
  return (
    <div className="App">
      <Game />
    </div>
  )
}
Enter fullscreen mode Exit fullscreen mode

Setup

To set up the clicker game, we'll need to store some state: how much currency the user has, how powerful their clicks are (in generating currency), how much currency they generate idly, etc. For now, store the state in a useState object:

// Game.tsx
import React, { useState } from 'react'

type Props = {}

const Game = (props: Props) => {
  // store game state
  const [gameState, setGameState] = useState({
        currency: 0,
        clickPower: 10,
        currencyPerMs: .001
    })

  return (
    <div>Game</div>
  )
}

export default Game

Enter fullscreen mode Exit fullscreen mode

Give the div inside the Game component a style so that users have some area to click in, and make sure that your App itself has some width and height.

.game__container {
  width: 200px;
  height: 200px;
  border: 1px solid black;
}

Enter fullscreen mode Exit fullscreen mode
// Game.tsx
import React, { useState } from 'react'

import './styles.css'

type Props = {}

const Game = (props: Props) => {
  // store game state
  const [gameState, setGameState] = useState({
        currency: 0,
        clickPower: 10,
        currencyPerMs: .001
    })

  return (
    <div className="game__container">Game</div>
  )
}

export default Game
Enter fullscreen mode Exit fullscreen mode

User Clicks

To update the game on each user click, add a click handler to mutate the currency inside gameState. You could do this with a button or svg, whereas I'm simply allowing users to click inside the game div to increment their currency.

// Game.tsx
const Game = (props: Props) => {
  // store game state
  const [gameState, setGameState] = useState({
        currency: 0,
        clickPower: 10,
        currencyPerMs: .001
    })

   const handleClick = () => {
        setGameState(prevState => ({
            ...prevState, // spread prevState values
            currency: parseFloat((prevState.currency + prevState.clickPower).toFixed(2)) // update currency
        }))

        console.log("clicked")
    }

  return (
    <div
      className="game__container"
      onClick={() => handleClick()}
    >
      <h3>Currency: {gameState.currency}</h3>
    </div>
  )
}

export default Game
Enter fullscreen mode Exit fullscreen mode

The parseFloat((prevState.currency + prevState.clickPower).toFixed(2)) code uses toFixed(2) to round the number to two places just in case our values are decimals later on, while parseFloat converts the string returned by toFixed(2) to a number.

Now, when the user clicks inside the box, the currency value should be updated inside state.

AutoClicker

Let's move on to autoclicking: the user will gain some amount of currency every millisecond (or some other interval). We'll have to use useEffect and setInterval.

// Game.tsx
const Game = (props: Props) => {
  // store game state
  const [gameState, setGameState] = useState({
        currency: 0,
        clickPower: 10,
        currencyPerMs: .001
    })

   const handleClick = () => {
        setGameState(prevState => ({
            ...prevState, // spread prevState values
            currency: parseFloat((prevState.currency + prevState.clickPower).toFixed(2)) // update currency
        }))

        console.log("clicked")
    }

   const updateGame = (delta_time: number) => {
         setGameState(prevState => ({
                  ...gameState,
                  currency: parseFloat((prevState.currency + prevState.currencyPerMs * delta_time).toFixed(2))
              }))
   }

   // game loop
   useEffect(() => {
        let last_time: null | number = null // previous current time
        const loop = setInterval(() => {
            const time_now = performance.now() // current time
            if (last_time === null) {
                last_time = time_now
            }
            const delta_time = time_now - last_time // change in time
            last_time = time_now 

            updateGame(delta_time)
        }, 1000 / 60) // frequency

        return () => clearInterval(loop) // clean up the interval
    }, [])

  return (
    <div
      className="game__container"
      onClick={() => handleClick()}
    >
      <h3>Currency: {gameState.currency}</h3>
    </div>
  )
}

export default Game
Enter fullscreen mode Exit fullscreen mode

We add an updateGame function in order to update the currency in the effect, every time the interval runs. In the interval inside the effect, we first initialize a variable to store the previous time, last_time, in order to later calculate the change in time const delta_time = time_now - last_time between then and const time_now = performance.now().

Here's a sandbox with the current code so far without typescript.

Redux Clicker

To implement the clicker using redux toolkit instead of useState, we'll need a store, a slice with some reducers, and a few custom hooks to grab the data from the store (useSelector) and to update the data in the store (useDispatch).

Store Setup

Download react-redux and redux toolkit:

npm i react-redux @reduxjs/toolkit
Enter fullscreen mode Exit fullscreen mode

Create a State folder, with the files store.ts and gameSlice.ts. In store.ts, do some initial setup:

// store.ts
import { configureStore } from '@reduxjs/toolkit'

export const store = configureStore({
    reducer: {
    }
})

// some typing
export type RootState = ReturnType<typeof store.getState> 
export type AppDispatch = typeof store.dispatch

Enter fullscreen mode Exit fullscreen mode

At the moment we don't have any reducers, so we'll need to create them in gameSlice, import them into store.tsx, and place them inside the store.

First, wrap the App in a store provider so we can access and update the store wherever we want in the app:

// main.tsx

import React from 'react'
import ReactDOM from 'react-dom/client'
import { Provider } from 'react-redux'

import './index.css'
import App from './App'
import { store } from './State/store'

ReactDOM.createRoot(document.getElementById('root') as HTMLElement).render(
  <React.StrictMode>
    <Provider store={store}>
      <App />
    </Provider>
  </React.StrictMode>
)

Enter fullscreen mode Exit fullscreen mode

Now setup the gameSlice. We'll use the same state data structure as we did in the useState version.

// gameSlice.ts
import { createSlice } from "@reduxjs/toolkit";

const initialState = {
    currency: 0,
    clickPower: 10,
    currencyPerMs: .001
}

const gameSlice = createSlice({
    name: "game",
    initialState,
    reducers: {}
})

// export reducer
export default gameSlice.reducer

Enter fullscreen mode Exit fullscreen mode

Inside the reducers: {} in gameSlice is where we'll store the logic to update the game state. For now, we export the gameSlice.reducer to import it into store.tsx:

// store.ts
import { configureStore } from '@reduxjs/toolkit'
import { useDispatch, useSelector } from 'react-redux'
import type { TypedUseSelectorHook } from 'react-redux'

import gameReducer from './gameSlice'

export const store = configureStore({
    reducer: {
      game: gameReducer // here's where your reducers live if you want to add more
    }
})

// store typing
export type RootState = ReturnType<typeof store.getState> 
export type AppDispatch = typeof store.dispatch

// hooks typing
export const useAppDispatch: () => AppDispatch = useDispatch
export const useAppSelector: TypedUseSelectorHook<RootState> = useSelector

Enter fullscreen mode Exit fullscreen mode

Instead of using the base useSelector and useDispatch hooks, we'll use gently typed hooks exported from the store.tsx file above.

Game Logic

As an alternative to the updateGame function used in the useState clicker's game loop, we'll dispatch actions to update state. We need to create those actions inside the game slice. For now, we'll make two: increment and auto increment.

// gameSlice.ts
const gameSlice = createSlice({
    name: "game",
    initialState,
    reducers: {
        // auto increment currency 
        autoIncrement: (state, action) => {
            const newCurrency = parseFloat((state.currency + state.currencyPerMs * action.payload).toFixed(2))
            state.currency = newCurrency
        },

        // increment currency by click
        increment: (state) => {
            const newCurrency = parseFloat((state.currency + state.clickPower).toFixed(2))
            state.currency = newCurrency
        },
    }
})

// export actions
export const {
    increment,
    autoIncrement
} = gameSlice.actions

Enter fullscreen mode Exit fullscreen mode

The actions use the same logic as the useState Clicker, except the autoIncrement action receives a payload, specifically the delta_time information that was passed to updateGame. The increment action simply adds the state's clickPower to the state's currency, so it doesn't need to receive any data through the action.payload.

Selecting and Dispatching

We now need to retrieve the state data and update it. Since we exported the useAppSelector and useAppDispatch hooks (the slightly modified useSelector and useDispatch hooks that redux provides) from store, and the incremenent and autoIncrement actions from gameSlice, we import them to be used in our main game file.

We'll use the same useEffect and setInterval structure as before for the game loop.

// Game.tsx
import { useAppDispatch, useAppSelector } from '../../State/stateHooks'
import { increment, click, upgrade } from '../../State/gameSlice'

const GameLoop = () => {
  // initialize useAppDispatch and useAppSelector
  const currency = useAppSelector((state) => state.game.currency)
  const dispatch = useAppDispatch()

  // redux user click handler
  const handleClick = () => {
    dispatch(click())
  }

  // redux game loop
  useEffect(() => {
    let last_time: null | number = null
    const loop = setInterval(() => {
      const time_now = performance.now()
      if (last_time === null) {
        last_time = time_now
      }
      const delta_time = time_now - last_time
      setTotalTime(prevTotalTime => prevTotalTime + delta_time)
      last_time = time_now 

      dispatch(increment(delta_time))
    }, 1000 / 60) // fps

    return () => clearInterval(loop)
   }, [])

  return (
    <div
      className="game__container"
      onClick={() => handleClick()}
    >
      <h3>Currency: {currency}</h3>
    </div>
  )
}

Enter fullscreen mode Exit fullscreen mode

Going Farther

Of course, this is barely a skeleton of an actual clicker game. We'll need to add upgrades to the autoClicker, to the user's clickPower, some sort of mechanism to save the user's progress, styling, and other optional features.

Oldest comments (0)