How to create a decentralized application (DApp) on the Tezos

Nicolas Buchet
-
April 13, 2023

Introduction

In this tutorial, we will explore how to create a decentralized application (DApp) on the Tezos blockchain using ReactJS. ReactJS, is a popular and flexible JavaScript library for building user interfaces.

In this tutorial we are going to build a decentralized TicTacToe based on this smart-contract that we’ve written in Pymich. This is a pretty simple smart-contract that allows user create games, join & leave games and play games. The rule is that each player pays 5 XTZ to play the game and the winner gets 10 XTZ and if it’s a draw then you will get your money back.

You can find a demo here.

By the end of this tutorial, you will have a basic understanding of how to create a DApp on the Tezos blockchain using ReactJS, and be equipped with the necessary skills to continue exploring possibilities of decentralized application development on the Tezos network. Let's get started !

If you get lost in the code during the tuto, you can find a full version of the App on githhub !

Requirements

Before we start, make sure that you check all the following requirements.

Install :

Have a basic knowledge of :

  • ReactJS
  • Javascript/Typescript
  • How blockchain works

Init React App

Now that we’re ready to start, let’s initialize the app with create-react-app , run the following command where you want to store the app on your computer.

# Command
npx create-react-app tictactoe --template typescript
cd tictactoe

Folder Structure

Once you’ve created the app you should have the following folder structure.

├── README.md
├── node_modules
├── package-lock.json
├── package.json
├── public
│	├── favicon.ico
│	├── index.html
│	├── logo192.png
│	├── logo512.png
│	├── manifest.json
│	└── robots.txt
├── src
│	├── App.css
│	├── App.test.tsx
│	├── App.tsx
│	├── index.css
│	├── index.tsx
│	├── logo.svg
│	├── react-app-env.d.ts
│	├── reportWebVitals.ts
│	└── setupTests.ts
└── tsconfig.json

This structure is a default one and we’ll update it to make it more readable and organized. Let’s update the src file structure as followed.

src
├── app # All app related files such as routes, context providers, styles etc...
├── components # All the reusable components
├── context # All the context declarations
├── pages # All the pages of the app
└── utils # All utils features like api connection, helpers etc...

Once you’ve created the new folders, you will have to move some files as the following structure mentions.

src
├── app
│   ├── styles # Create a new folder for styles
│   │   └── App.css # Move App.css into this folder
│   └── App.tsx # Move App.tsx 
├── components
├── context
├── pages
├── utils
├── App.test.tsx
├── index.css
├── index.tsx
├── logo.svg
├── react-app-env.d.ts
├── reportWebVitals.ts
└── setupTests.ts

After make sure that you update the imports of the files when you move them. If you’re on vscode it updates the import automatically but you still have to update some imports is the following files : index.tsx, app.test.tsx, App.tsx.

Now you can run you server with npm start and you should see the default react template running.

Now that we have all the structure set up let’s create a router for the app to handle routes rendering.

Router

Before beginning this step you will have to install the router library that we will use : react-router-dom

npm install react-router-dom

Then you can create a new file under src/app/ called Routes.tsx

// src/app/Routes.tsx

import React from "react";
import { Routes as AppRoutes, Route } from "react-router-dom";

const Routes = () => {
  return (
    
			{/* We will declare new routes here */}
		
  );
};

export default Routes;

Here we are declaring the router with no routes inside. We’ll add routes later !

Now we need to link our router with the app. So let’s jump into the App.tsx file.

You can remove all its content and replace it with this.

// src/app/App.tsx

import React from 'react';
import { BrowserRouter as Router } from "react-router-dom";
import './App.css';
import Routes from './Routes';

function App() {
  return (
    
{/* Add a browser router provider to host the routes */} {/* Add the Routes component declared before */}
); } export default App;

Our app can now handle routes rendering.

Providers

Here we’ll create a file to store the app providers. Providers are components that provide a way to pass data down through the component tree without having to pass props manually at every level. By wrapping components in a provider, any nested child component can access the data passed down through the provider. This allows for a more efficient and convenient way to manage state and share data between components.

So create a new file under src/app called Providers.tsx.

// src/app/Providers.tsx

import React, { FC, ReactNode } from 'react'

// Declare a new type for props
type ProvidersProps = {
    children: ReactNode
}

// Provide props type and add children
const Providers: FC = ({ children }) => {
	// For now we do not have any provider
	// so we'll just wrap children with a div
  return (
    
{children}
) } export default Providers

The Providers component will have a children property that will contain all the children components.

We now need to link the Providers component to the app. Jump into the App.tsx file.

// src/app/App.tsx

import React from 'react';
import { BrowserRouter as Router } from "react-router-dom";
import './styles/App.css';
import Routes from './Routes';
import Providers from './Providers'; // import the providers component

function App() {
  return (
     {/* Replace the existing wrapper div with the Providers */}
      
        
      
    
  );
}

export default App;

The app can now handle context management.

Frontend

In this part we’ll work on integrate the HTML structure and all the corresponding styles. Again you can find a demo of the screens here.

General styling

Before we start integrating screens let’s do some basic css setup.

You can remove all the content of the src/app/styles/App.css file and paste the below code.

/* src/app/styles/App.css */
/* Import font */
@import url('https://fonts.googleapis.com/css2?family=Roboto:ital,wght@0,100;0,300;0,400;0,500;0,700;0,900;1,100;1,300;1,400;1,500;1,700;1,900&display=swap');

body {
  margin: 0;
  font-family: 'Roboto', sans-serif; /* apply font */
}

/* Other css setup */
html, body {
  box-sizing: border-box;
  height: 100%;
  width: 100%;
}

#root {
  height: 100%;
  width: 100%;
}

*, *:before, *:after {
  box-sizing: inherit;
}

Styling Library

We will handle the app styling with a famous react styling library called styled-components

npm install --save-dev @types/styled-components

Setup a theme

Now that we have installed the package,  let’s create a theme for handling colors, spacing etc..

In order to proceed, we need to create two files. One for defining the default theme interface and the other for declaring the theme values.

// src/app/styles/styled.d.ts

import "styled-components";

// Define the default theme interface
declare module "styled-components" {
  export interface DefaultTheme {
    colors: {
        primary: string;
        secondary: string;
        systemGrey: string;
				systemYellow: string;
	      systemGreen: string;
    };
  }
}

// src/app/styles/theme.styled.ts

import { DefaultTheme } from "styled-components";

// Define here the default theme values
export const main: DefaultTheme = {
  colors: {
    primary: "rgba(128, 207, 224, 1)",
    secondary: "rgba(217, 100, 99, 1)",
    systemGrey: "#e0e0e0",
		systemYellow: "#E8A029",
    systemGreen: "#3CC27A"
  },
};

We also need to wrap our app inside the theme provider in order the share the theme context to the whole application.

// src/app/Providers.tsx

import React, { FC, ReactNode } from 'react'
import { ThemeProvider } from 'styled-components' // import the ThemeProvider
import { main } from './styles/theme.styled' // import the default theme

type ProvidersProps = {
    children: ReactNode
}

const Providers: FC = ({ children }) => {
  return (
		{/* Wrap up the app with the default theme */}
    {children}
  )
}

export default Providers

Create a new page

We will now create a new page to render the home view.

Sounder src/pages/  create a new folder called HomePage.

The first one called HomePage.tsx which will contain the home page component declaration.

// src/pages/HomePage/HomePage.tsx

import React from 'react'
import { HomePageWrapper } from './HomePage.styled'

const HomePage = () => {
  return (
    HomePage
  )
}

export default HomePage

And the second one called HomePage.styled.ts which will contain all the style declarations for the home page.

// src/pages/HomePage/HomePage.styled.ts

import styled from "styled-components"

// Here we are declaring a new styled div
export const HomePageWrapper = styled.div`
	// css will be declared here !
`;

This will be the structure that we will follow for all the pages and components of the App.

Let’s now link this new page with the router.

// src/app/Routes.tsx

import React from "react";
import { Routes as AppRoutes, Route } from "react-router-dom";
import HomePage from "../pages/HomePage/HomePage";

const Routes = () => {
  return (
    
      } /> {/* Add a new route here */}
    
  );
};

export default Routes;

Create the App header

We will now create the first component of the app: the Header. In order to do that, you can create a new component folder under src/components.

Here are the files for the header component and its corresponding style.

// src/components/Header/Header.tsx

import React from 'react'
import {
    AppTitle,
    ConnectButton,
    HeaderWrapper,
    WalletBalanceLabel,
    WalletInfo
} from './Header.styled'

const Header = () => {
  return (
    
        TicTacToe
        
            Balance: 1.2 xtz
            Connect Wallet
        
    
  )
}

export default Header;

// src/components/Header/Header.styled.ts

import styled from "styled-components";

export const HeaderWrapper = styled.div`
    position: absolute;
    top: 0;
    left: 0;
    width: 100%;
    height: 70px;
    display: flex;
    align-items: center;
    justify-content: space-between;
    padding: 1rem;
`;

export const AppTitle = styled.h1`
    margin: auto 0;
`;

export const WalletInfo = styled.div`
    display: flex;
    align-items: center;
    gap: 1rem;
`;

export const WalletBalanceLabel = styled.div`
    font-size: 14px;
    font-weight: 600;
`;

export const ConnectButton = styled.button`
    padding: 0.8rem 2rem;
    border: none;
		/* Here we are getting the color from the theme */
    background-color: ${(props) => props.theme.colors.primary};
    border-radius: 10px;
    cursor: pointer;
    font-family: "Roboto", sans-serif;
    font-weight: 700;
    color: white;
    font-size: 18px;
`;

In order to render the header component, let’s add it in the App.tsx file.

// src/app/App.tsx
// imports...

function App() {
  return (
    
      
        
{/* Add the Header here */} ); } export default App;

Home Page

We’ll now fill the home page that we created before.

// src/pages/HomePage/HomePage.tsx

import React from 'react'
import { main } from '../../app/styles/theme.styled'
import Games from './Games/Games'
import {
  ButtonsWrapper,
  HomePageWrapper,
  ActionButton
} from './HomePage.styled'
import Statistics from './Statistics/Statistics'

const HomePage = () => {
  return (
    
       {/* Sub component here */}
      
        My Games
        Available Games
        New Game
      
       {/* Sub component here */}
    
  )
}

export default HomePage

// src/pages/HomePage/HomePage.styled.ts

import styled from "styled-components"

export const HomePageWrapper = styled.div`
    position: relative;
    top: 70px;
    left: 0;
    height: 100%;
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
`;

export const ButtonsWrapper = styled.div`
    display: flex;
    align-items: center;
    gap: 1rem;
`;

export const ActionButton = styled.button<{
    backgroundColor?: string;
}>`
    margin: 2rem 0 1rem 0;
    padding: 0.5rem 1rem;
    cursor: pointer;
    border: none;
    background-color: ${(props) => props.backgroundColor || props.theme.colors.systemGrey};
    font-size: 20px;
    font-family: inherit;
    border-radius: 10px;
    color: ${(props) => props.backgroundColor ? "white" : "black"};
`;

Declare sub components

We can see that there are several subcomponents used inside the page component that we just  created. Those subcomponents are categorized as unsharable, this means that they are supposed to be used once. That’s why they will be declared in a subfolder inside the page folder.

// src/pages/HomePage/HomePage/Statistics/Statistics.tsx

import React from 'react'
import { main } from '../../../app/styles/theme.styled'
import { 
    StatFigure,
    StatisticsWrapper,
    StatTitle,
    StatWrapper
} from './Statistics.styled'


const Statistics = () => {
  return (
    
        
            Games
            200
        
        
            Won
            100
        
        
            Draw
            100
        
        
            Lost
            100
        
    
  )
}

export default Statistics

// src/pages/HomePage/HomePage/Statistics/Statistics.styled.ts

import styled from "styled-components"

export const StatisticsWrapper = styled.div`
    display: flex;
    align-items: center;
    justify-content: center;
`;

export const StatWrapper = styled.div<{
    borderRight?: boolean;
}>`
    display: flex;
    flex-direction: column;
    align-items: center;
    justify-content: center;
    gap: 0.7rem;
    min-width: 100px;
    padding: 0.5rem 0;
    // Here we are using the borderRight prop to conditionally add a border to the right of the StatWrapper
    border-right: ${(props) => props.borderRight && ("1px solid " + props.theme.colors.systemGrey)};
`;

export const StatTitle = styled.h3`
    margin: 0;
`;

export const StatFigure = styled.div<{
    color?: string;
}>`
    color: ${(props) => props.color || "black"};
    font-size: 25px;
    font-weight: bold;
`;

We also have the Games subcomponent to declare

// src/pages/HomePage/HomePage/Games/Games.tsx

import React from 'react'
import { useNavigate } from 'react-router-dom'
import { GameCard } from '../../../components/GameCard/GameCard';
import { GamesWrapper } from './Games.styled'

const Games = () => {
		// use the navigation hook to make Game Cards clickable
    const navigate = useNavigate();

    return (
        
            {/* Here we are creating two placeholders for the game items*/}
             navigate("/game/1")} />
             navigate("/game/2")} />
        
    )
}

export default Games

// src/pages/HomePage/HomePage/Games/Games.styled.ts

import styled from "styled-components";

export const GamesWrapper = styled.div`
    display: flex;
    flex-direction: column;
    align-items: center;
    gap: 1rem;
    width: 30%;
    margin-top: 2rem;
`;

And finally we will create another component that is the game card. This one will be shared so let’s declare it inside the components folder.

// src/components/GameCard/GameCard.tsx

import React, { FC } from 'react'
import { main } from '../../app/styles/theme.styled';
import {
    GameStatusLabel,
    GameTitle,
    GameWrapper
} from './GameCard.styled';

type GameCardProps = {
    onClick: () => void;
}

// extend the FC (Function Component) class with the props generic type
export const GameCard: FC = ({ onClick }) => {
  return (
    
        
Game ID: 12
Opponent: 0x123...
LOST
) }

Game Page

Once we created the Home page we will now create the second page that our app will render : the GamePage. Let’s proceed.

// src/pages/GamePage/GamePage.tsx

import React from 'react'
import { useParams } from 'react-router-dom'
import { main } from '../../app/styles/theme.styled'
import { 
    GamePageWrapper,
    PlayerLabel,
    PlayersWrapper,
    StatusLabel,
    VSLabel
} from './GamePage.styled'
import Grid from './Grid/Grid'

// Declaring type for Location Params
type LocationParams = {
    id: string
}

const GamePage = () => {
		// retrieve id from location params
    const { id } = useParams();

    return (
        
            
                tz1....
                VS
                tz1....
            
            
            YOU WON
        
    )
}

export default GamePage

// src/pages/GamePage/GamePage.styled.ts

import styled from "styled-components"

export const GamePageWrapper = styled.div`
    position: relative;
    top: 70px;
    left: 0;
    height: 100%;
    width: 100%;
    display: flex;
    flex-direction: column;
    align-items: center;
    margin: 2rem 0;
`;

export const PlayersWrapper = styled.div`
    display: flex;
    align-items: center;
    justify-content: center;
    gap: 1rem;
`;

export const PlayerLabel = styled.div<{
    align?: string;
}>`
    font-size: 26px;
    text-align: ${(props) => props.align || "left"};
    font-weight: 600;
`;

export const VSLabel = styled.div`
    font-size: 26px;
    font-weight: 700;
`;

export const StatusLabel = styled.div<{
    color?: string;
}>`
    font-size: 36px;
    font-weight: bold;
    color: ${(props) => props.color || "black"};
`;

Grid Component

We need also to declare the TicTacToe Grid as a subcomponent.

// src/pages/GamePage/Grid/Grid.tsx

import React, { FC } from 'react'
import { Cell, GridWrapper, MovesLabel, Row } from './Grid.styled'

type GridProps = {
    game_id: number;
}

const Grid: FC = ({ game_id }) => {
  return (
    
        Moves: 2
        
{[...Array(3)].map((_, j: number) => { return ( {[...Array(3)].map((_, i: number) => ( ))} ) })}
) } export default Grid

// src/pages/GamePage/Grid/Grid.tsx

import React, { FC } from 'react'
import { Cell, GridWrapper, MovesLabel, Row } from './Grid.styled'

type GridProps = {
    game_id: number;
}

const Grid: FC = ({ game_id }) => {
  return (
    
        Moves: 2
        
{[...Array(3)].map((_, j: number) => { return ( {[...Array(3)].map((_, i: number) => ( ))} ) })}
) } export default Grid

Link the page to the App

There is one little thing that left to do, link the page to the app.

// src/app/Routes.tsx
// imports...

const Routes = () => {
  return (
    
      } />
			{/* Link the game page with a location param called 'id' */}
      } />
    
  );
};

Wallet Connection

In this part we’ll focus on handling the wallet connection.

In order to do this we will use beacon wallet through the taquito library. So we’ll need to install the two following libraries but before we need to downgrade react-scripts version to avoid some breaking changes due to webpack 5 included in the react-scripts v5. Run the three following commands.


npm install react-scripts@4.0.3

npm install @taquito/taquito

npm install @taquito/beacon-wallet

Create a new context

The first step to proceed is to create a context for getting and storing wallet informations.

// src/context/Wallet.context.tsx

import React, { createContext, ReactNode } from "react";
import useWallet from "../utils/hooks/wallet/useWallet";

type WalletContextProps = {
	 // Here we'll add all the property that the context retrieves
};

type WalletProviderProps = {
  children: ReactNode;
};
// Here we are creating our context
export const WalletContext = createContext({} as WalletContextProps);

const WalletProvider = ({ children }: WalletProviderProps) => {
	// Here we'll declare all the of the context
  return (
    
      {children}
    
  );
};
// We are exporting the provider to again wrap up the app with this provider
export default WalletProvider;

Let’s wrap up the app with the new context.

// src/app/Providers.tsx
// imports...

const Providers: FC = ({ children }) => {
  return (
    
      
        {children} {/* Nest the wallet provider inside the Theme Provider */}
      
    
  )
}

Create an env file

One of the best practices of creating an app with react is to create an .envfile. So let’s do this and add the 3 following variables. The first two are quite linked because they are defining the network that the app will use to run (Ghostnet here). If those 2 variables are unconsistent this will result as an error. And the last one is defining the contract address that we’ll use to handle games.

REACT_APP_RPC_ADDRESS=https://rpc.ghostnet.teztnets.xyz/
REACT_APP_NETWORK=ghostnet
REACT_APP_CONTRACT_ADDRESS=KT1QjC7m4UDHcWihiQWcChX2UCN5FUXNenZw

In a more scalable app we would rather create one file per environment, for example one for the ghostnet (dev) and the 2 others for the mainnet (staging & production).

Create a taquito wallet handler file

In this file we will declare all the functions that will help us to connect, disconnect or getting the user balance etc…

// src/utils/taquito/main.taquito.ts

import { BeaconWallet } from "@taquito/beacon-wallet";
import { TezosToolkit } from "@taquito/taquito";

// create a TezosToolkit Object base on the RPC URL
export const tezos = new TezosToolkit(
  process.env.REACT_APP_RPC_ADDRESS as string
);
// configure options for the Beacon wallet object and the request permission
const options = {
  name: "TicTacToe",
  preferredNetwork: process.env.REACT_APP_NETWORK as string,
};

const network = {
  type: process.env.REACT_APP_NETWORK as string,
  name: "TicTacToe",
  rpcUrl: process.env.REACT_APP_RPC_ADDRESS as string,
};

// @ts-ignore
export const wallet = new BeaconWallet(options);

/**
 * Request user permissions to connect to wallet
 * @returns BeaconWallet object
 */
export const setProvider = async () => {
  const activeAccount = await wallet.client.getActiveAccount();

	// checking if there are any connected wallet
  if (activeAccount) {
    tezos.setProvider({ wallet });
    return wallet;
  }
	// requesting user permissions
  // @ts-ignore
  await wallet.requestPermissions({ network });
  tezos.setProvider({ wallet });
  return wallet;
};

/**
 * Check if user's wallet is connected
 * @returns boolean
 */
export const isConnected = async () => {
  const activeAccount = await wallet.client.getActiveAccount();
  if (activeAccount) {
    tezos.setProvider({ wallet });
    return true;
  }
  return false;
};

/**
 * This will disconnect user's wallet
 * and clear all active accounts
 * @returns void
 */
export const disconnectWallet = async () => {
  try {
    await wallet.clearActiveAccount();
    tezos.setWalletProvider(wallet);
    return true;
  } catch (error) {
    throw new Error("Error : " + error);
  }
};

/**
 * Get user XTZ Balance from wallet
 * @param from address of the user to get balance from
 * @returns Promise
 */
export const getBalance = (from: string) => {
  try {
    return tezos.tz.getBalance(from);
  } catch (error) {
    throw new Error("Error : " + error);
  }
};

Create a wallet connection hook

Now that we have created our taquito helpers, we will create a hook to integrate those helpers with the context.

// src/utils/hooks/useWallet.ts

import { useState, useEffect } from "react";
import {
  disconnectWallet,
  getBalance,
  isConnected,
  setProvider,
} from "../../utils/taquito/main.taquito";

const useWallet = () => {
		// Declare the state variables
    const [currentUserBalance, setCurrentUserBalance] = useState();
    const [currentWalletAddress, setCurrentWalletAddress] = useState();
    const [isWalletConnected, setIsWalletConnected] = useState(false);

    const initWallet = async () => {
        try {
						// requesting permission
            const wallet = await setProvider();
						// getting user wallet address
            const userAddress = await wallet.getPKH();
						// set state variables
            setIsWalletConnected(true);
            setCurrentWalletAddress(userAddress);
						// getting user balance
            const balance = getBalance(userAddress);
            balance.then((res) => {
                setCurrentUserBalance(res.toNumber() * 10 ** -6);
            });
        } catch (error) {
            throw new Error("Error : " + error);
        }
    };

		const disconnect = () => {
        disconnectWallet();
        setIsWalletConnected(false);
        setCurrentWalletAddress(undefined);
        setCurrentUserBalance(undefined);
    }

    useEffect(() => {
				// checking if the connected user has been cached
				// if it's the case, connect him by default
        isConnected().then((res: boolean) => {
            res && initWallet();
        });
    }, []);
		
		// return hook state + functions
    return {
        currentUserBalance,
        currentWalletAddress,
        disconnectWallet,
        initWallet,
        isWalletConnected,
    };
};

export default useWallet;

Then we need to link the hook with the context.

// src/context/wallet.context.tsx

import React, { createContext, ReactNode } from "react";
import useWallet from "../utils/hooks/useWallet";

type WalletContextProps = {
	// Add the variables to the context props
  currentUserBalance: number | undefined;
  currentWalletAddress: string | undefined;
  disconnect: () => void;
  initWallet: () => void;
  isWalletConnected: boolean;
};

type WalletProviderProps = {
  children: ReactNode;
};

export const WalletContext = createContext({} as WalletContextProps);

const WalletProvider = ({ children }: WalletProviderProps) => {
	// Retrieve variables from the useWallet hook
  const {
    currentUserBalance,
    currentWalletAddress,
    disconnect,
    isWalletConnected,
    initWallet,
  } = useWallet();

  return (
    
      {children}
    
  );
};

export default WalletProvider;

Link the frontend with the wallet connection

Now that we have all the wallet data that we want we are going to display them in the frontend.

First of all we’ll create a helper to format a wallet address into a shorter version.

// src/utils/helpers/Wallet.helpers.ts

export const getFormattedWalletAddress = (address: string) => {
    return `${address.slice(0, 6)}...${address.slice(-5)}`;
};

Then jump into the header component where connect wallet buttons are declared.

// src/components/Header/Header.tsx

import React, { useContext } from 'react'
import { WalletContext } from '../../context/Wallet.context'
import { getFormattedWalletAddress } from '../../utils/helpers/Wallet.helpers';
import {
    AppTitle,
    ConnectButton,
    HeaderWrapper,
    WalletBalanceLabel,
    WalletInfo
} from './Header.styled'

const Header = () => {
	// get data from the Wallet context
  const { 
    currentUserBalance,
    currentWalletAddress,
    disconnect,
    initWallet,
    isWalletConnected,
  } = useContext(WalletContext);

  return (
    
        TicTacToe
        
						{/* Display balance only if user is connected */}
            {isWalletConnected && (
              {`Balance: ${currentUserBalance?.toFixed(2) || "--"} xtz`}
            )}
						{/* Change button display depending on the wallet connection status */}
            
                {isWalletConnected ? 
                  getFormattedWalletAddress(currentWalletAddress || "") : 
                  "Connect Wallet"
                }
            
        
    
  )
}

export default Header;

We also need to change the display to connect button.

// src/components/Header/Header.styled.ts
// styles....

// Change display depending on connection
export const ConnectButton = styled.button<{
    isConnected: boolean;
}>`
    padding: 0.8rem 2rem;
    border: none;
    background-color: ${(props) => props.isConnected ? props.theme.colors.primary : props.theme.colors.secondary};
    border-radius: 10px;
    cursor: pointer;
    font-family: "Roboto", sans-serif;
    font-weight: 700;
    color: white;
    font-size: 18px;
`;

Transactions

The next step is to add the interactions with smart-contract. Our contract has 5 entrypoints but we’ll use only 3 of them for this app. build_game, join_game and play.

Let’s create a file for declaring the required transactions.

// src/utils/taquito/transactions.taquito.ts

import { tezos } from "./main.taquito";

const CONTRACT_ADDRESS = process.env.REACT_APP_CONTRACT_ADDRESS as string;
/**
 * Calls the entrypoint build_board from the smart contract
 * @returns Promise
 */
export const build_board = async () => {
    try {
        // Get the contract abstraction
        const contract = await tezos.wallet.at(CONTRACT_ADDRESS);
        // Call the method build_board from the abstraction
        return contract.methods
            .build_board()
            // The entrypoints requires us to send an amount of 5 XTZ
            .send({ amount: 5, mutez: false })
            .then((res: any) => res.confirmation(1));
    } catch (error) {
        throw error;
    }
}

/**
 * Calls the entrypoint join_game from the smart contract
 * @param game_id number
 * @returns Promise
 */
export const join_game = async (game_id: number) => {
    try {
        const contract = await tezos.wallet.at(CONTRACT_ADDRESS);
        
        return contract.methods
            .join_game(game_id)
            .send({ amount: 5, mutez: false })
            .then((res: any) => res.confirmation(1));
    } catch (error) {
        throw error;
    }
}

/**
 * Calls the entrypoint play from the smart contract
 * @param x number - x coordinate
 * @param y number - y coordinate
 * @returns Promise
 */
export const play = async (x: number, y: number, game_id: number) => {
    try {
        const contract = await tezos.wallet.at(CONTRACT_ADDRESS);
        
        return contract.methods
            .play(game_id, x, y)
            // Here no need to send an amount of XTZ
            // because the entrypoint play doesn't require it
            .send()
            .then((res: any) => res.confirmation(1));
    } catch (error) {
        throw error;
    }
}

Link the entrypoints to the frontend

First we’ll link the entry points build_game and play to the frontend. We’ll handle the third one later.

First download the following library to handle transaction toasts. This library will help us to render the transaction status to the user.

npm install react-toastify@8.2.0

But in order to do that, we have to add a toast container with the following props and also we need to add the toastify css file import at the root of the App.

// src/app/App.tsx

import React from 'react';
import { BrowserRouter as Router } from "react-router-dom";
import './styles/App.css';
import Routes from './Routes';
import Providers from './Providers';
import Header from '../components/Header/Header';
import { ToastContainer } from 'react-toastify';
// add the toastify css here
import "react-toastify/dist/ReactToastify.css";

function App() {
  return (
    
			{/* Add the toast container here */}
      
      
        
); } export default App;

Build Board entrypoint

Jump into the HomePage component, where the new game button is declared.

// src/pages/HomePage/HomePage.tsx

import React from 'react'
import { toast } from 'react-toastify'
import { main } from '../../app/styles/theme.styled'
import { build_board } from '../../utils/taquito/transactions.taquito'
import Games from './Games/Games'
import {
  ButtonsWrapper,
  HomePageWrapper,
  ActionButton
} from './HomePage.styled'
import Statistics from './Statistics/Statistics'

const HomePage = () => {
  return (
    
      
      
        My Games
        Available Games
         
						// Create a toast that follows build_board promise state
            toast.promise(
              build_board,
              {
								// add promise state labels
                pending: 'Creating new game...',
                success: 'Game created!',
                error: 'Oups! Error creating game'
              }
            )
          } 
        >
          New Game
        
      
      
    
  )
}

export default HomePage

Play entrypoint

The play entrypoint is used in the Grid component, so let’s update it.

// src/pages/GamePage/Grid/Grid.tsx

import React, { FC } from 'react'
import { toast } from 'react-toastify';
import { play } from '../../../utils/taquito/transactions.taquito';
import { Cell, GridWrapper, MovesLabel, Row } from './Grid.styled'

type GridProps = {
    game_id: number;
}

const Grid: FC = ({ game_id }) => {
    console.log(game_id);
    return (
        
            Moves: 2
            
{[...Array(3)].map((_, j: number) => { return ( {[...Array(3)].map((_, i: number) => ( ( toast.promise( () => play(i + 1, j + 1, game_id), { pending: 'Playing...', success: 'Played!', error: 'Oups! Error playing' } ) )} /> ))} ) })}
) } export default Grid

Retrieve games data

Retrieving data from the blockchain is necessary to make the app working. But the problem with this is that it needs to involve a third part into to the loop. They are several ways to do that like creating an indexer, getting data from the storage with an API or with taquito. In this tutorial we are going to use an external API to do that, this is the easier and quicker way to proceed. We’ll use the API from TZKT.

Create Data types

As we are using typescript we definitely want to have typed data and avoid using the anytype as much as possible. That’s why we are creating types for the data that we will recieve from the API.

// src/utils/types/game.types.ts

export type BoardRow = {
    row_id: string;
    cells: String[];
}

export type Game = {
    board_id: number;
    player1: string;
    player2: string;
    moves_number: number;
    winner: string;
    draw: boolean;
    started: boolean;
    finished: boolean;
    board: BoardRow[];
    next_player: number;
}

Create a Game context

In order to retrieve those datas we’ll need to create a new context for handling stuff related to games.

// src/context/Game.context.tsx

import React, { createContext, ReactNode } from "react";

type GameContextProps = {
	// Empty props for now
};

type GameProviderProps = {
  children: ReactNode;
};

export const GameContext = createContext({} as GameContextProps);

const GameProvider = ({ children }: GameProviderProps) => {
  return (
    
      {children}
    
  );
};

export default GameProvider;

Link it in the providers.

// src/app/Providers.tsx
// imports...

const Providers: FC = ({ children }) => {
  return (
    
      
        
          {children}
        
      
    
  )
}

Get all games from the contract storage

We are going to use axios to get storage data from TZKT API. So let’s install it.

npm install axios

Create a new file to create an helpers to get all the games from the storage.

// src/utils/taquito/games.taquito.ts

import axios from "axios";

export const getAllGames = async () => {
    try {
        // retrieve keys of the game bigmap (id 245527)
        const url = "https://api.ghostnet.tzkt.io/v1/bigmaps/245527/keys/";
        const response = await axios.get(url);

        return response.data;
    } catch (error) {
        throw error;
    }
}

Now jump into the GameContext.

// src/context/Game.context.tsx

import React, { createContext, ReactNode, useEffect, useState } from "react";
import { getAllGames } from "../utils/taquito/games.taquito";
import { Game } from "../utils/types/game.types";

type GameContextProps = {
  games: Game[];
};

type GameProviderProps = {
  children: ReactNode;
};

export const GameContext = createContext({} as GameContextProps);

const GameProvider = ({ children }: GameProviderProps) => {
  const [games, setGames] = useState([]);
  
	// get games on load of the page and store them into the context
  useEffect(() => {
    getAllGames()
			.then((res) => setGames(res.map((item: any) => item.value as Game)));
  }, []);

  return (
    
      {children}
    
  );
};

export default GameProvider;

Add games statistics

We are now going to render games statistics in the HomePage.

Create a games helpers file to export data calculation outside of the component.

// src/utils/helpers/game.helpers.ts

import { main } from "../../app/styles/theme.styled";
import { Game } from "../types/game.types";

export const getGameById = (games: Game[], id: number) => {
    return games.find((game: Game) => game.board_id.toString() === id.toString());
};

export const getMyGames = (games: Game[], wallet_address: string) => {
    return games.filter((game: Game) => (
        game.player1 === wallet_address || game.player2 === wallet_address
    ));
}

export const getGamesWon = (games: Game[], wallet_address: string) => {
    const myGames = getMyGames(games, wallet_address);

    return myGames.filter((game: Game) => (
        game.finished && game.winner === wallet_address && !game.draw
    )).length;
}

export const getGamesDraw = (games: Game[], wallet_address: string) => {
    const myGames = getMyGames(games, wallet_address);

    return myGames.filter((game: Game) => (
        game.finished && game.draw
    )).length;
}

export const getGamesLost = (games: Game[], wallet_address: string) => {
    const myGames = getMyGames(games, wallet_address);

    return myGames.filter((game: Game) => (
        game.finished && game.winner !== wallet_address
    )).length;
};

export const getAvailableGames = (games: Game[], wallet_address: string) => {
    return games.filter((game: Game) => (
        !game.started && game.player1 === game.player2 && game.player1 !== wallet_address
    ));
}

export const getMyOpponent = (game: Game, wallet_address: string) => {
    return game.player1 === wallet_address ? game.player2 : game.player1;
}

export const getGameStatus = (game: Game, wallet_address: string) => {
    if (game.finished) {
        if (game.draw) {
            return "draw";
        } else if (game.winner === wallet_address) {
            return "won";
        } else {
            return "lost"
        }
    } else if (game.started) {
        return "started";
    } else {
        return "waiting";
    }
}

export const getGameStatusColor = (game: Game, wallet_address: string) => {
    const status = getGameStatus(game, wallet_address);

    switch (status) {
        case "won":
            return main.colors.primary;
        case "lost":
            return main.colors.secondary;
        case "waiting":
            return main.colors.systemYellow;
        case "started":
            return main.colors.systemGreen;
        default:
            return "black";
    }
}

Then jump into the Statistics component to render all those stats.

// src/pages/HomePage/Statistics/Statistics.tsx

import React, { useContext, useMemo } from 'react'
import { main } from '../../../app/styles/theme.styled'
import { GameContext } from '../../../context/Game.context'
import { WalletContext } from '../../../context/Wallet.context'
import {
    getGamesDraw,
    getGamesLost,
    getGamesWon,
    getMyGames
} from '../../../utils/helpers/game.helpers'
import { 
    StatFigure,
    StatisticsWrapper,
    StatTitle,
    StatWrapper
} from './Statistics.styled'


const Statistics = () => {
    const { currentWalletAddress } = useContext(WalletContext);
    const { games } = useContext(GameContext);
		
		// retrieve calculation results
    const gamesPlayed = useMemo(() => getMyGames(games, currentWalletAddress || "").length, [games, currentWalletAddress]);
    const gamesWon = useMemo(() => getGamesWon(games, currentWalletAddress || ""), [games, currentWalletAddress]);
    const gamesDraw = useMemo(() => getGamesDraw(games, currentWalletAddress || ""), [games, currentWalletAddress]);
    const gamesLost = useMemo(() => getGamesLost(games, currentWalletAddress || ""), [games, currentWalletAddress]);

    return (
        
            
                Games
                
									{/* Retrieve data only if the user is connected */}
									{currentWalletAddress ? gamesPlayed : "--"}
								
            
            
                Won
                
                    {currentWalletAddress ? gamesWon : "--"}
                
            
            
                Draw
                
                    {currentWalletAddress ? gamesDraw : "--"}
                
            
            
                Lost
                
                    {currentWalletAddress ? gamesLost : "--"}
                
            
        
    )
}

export default Statistics

Home Page update

Now we need to update the home page to dynamise the display when the user chose to see his own game or wants to join a new game.

// src/pages/HomePage/HomePage.tsx
// imports ...

const HomePage = () => {
  const [gamesView, setGamesView] = useState<1 | 2>(1);

  return (
    
      
      
         setGamesView(1)}
        >
          My Games
        
         setGamesView(2)}>Available Games
         
            toast.promise(
              build_board,
              {
                pending: 'Creating new game...',
                success: 'Game created!',
                error: 'Oups! Error creating game'
              }
            )
          } 
        >
          New Game
        
      
      
    
  )
}

export default HomePage

We also need to update the display of the games list nested in the home page.

// src/pages/HomePage/Games/Games.tsx
// imports ...

type GamesProps = {
    gamesView: 1 | 2;
}

const Games: FC = ({ gamesView }) => {
    const [gamesList, setGamesList] = useState([]);

    const { currentWalletAddress } = useContext(WalletContext);
    const { games } = useContext(GameContext);

    const myGames = useMemo(
        () => getMyGames(games, currentWalletAddress || ""), 
        [games, currentWalletAddress]
    );
    const availableGames = useMemo(
        () => getAvailableGames(games, currentWalletAddress || ""),
        [games, currentWalletAddress]
    );
    
    const navigate = useNavigate();

    useEffect(() => {
        setGamesList(gamesView === 1 ? myGames : availableGames)
    }, [gamesView, myGames, availableGames]);

    return (
        
            {gamesList.map((game: Game, key: number) => (
                 navigate(`game/${game.board_id}`)} game={game} />
            ))}
        
    )
}

export default Games

And finally we need to update the GameCard component behavior.

// src/components/GameCard/GameCard.tsx

import React, { FC, useContext, useMemo } from 'react'
import { WalletContext } from '../../context/Wallet.context';
import { getGameStatus, getGameStatusColor, getMyOpponent } from '../../utils/helpers/game.helpers';
import { getFormattedWalletAddress } from '../../utils/helpers/Wallet.helpers';
import { Game } from '../../utils/types/game.types';
import {
    GameStatusLabel,
    GameTitle,
    GameWrapper,
    JoinGameButton
} from './GameCard.styled';
import { toast } from "react-toastify";
import { join_game } from '../../utils/taquito/transactions.taquito';

type GameCardProps = {
    onClick: () => void;
    game: Game;
}

export const GameCard: FC = ({ onClick, game }) => {
    const { currentWalletAddress } = useContext(WalletContext);
    
    const myOpponent = useMemo(() => (
            getMyOpponent(game, currentWalletAddress || "")
        ),
        [game, currentWalletAddress]
    );
    const status = useMemo(
        () => getGameStatus(game, currentWalletAddress || ""),
        [game, currentWalletAddress]
    );
    const statusColor = useMemo(
        () => getGameStatusColor(game, currentWalletAddress || ""),
        [game, currentWalletAddress]
    );

    return (
        
            
Game ID: {game.board_id}
Opponent: {" "} {myOpponent === currentWalletAddress ? "--" : getFormattedWalletAddress(myOpponent)}
{status === "waiting" && game.player1 !== currentWalletAddress ? ( toast.promise( () => join_game(game.board_id), { pending: "Joining the game...", success: "Game joined!", error: "Failed to join the game", } ) } > Join Game ) : ( {status} ) }
) }

// src/components/GameCard/GameCard.styled.ts

import styled from "styled-components"

export const GameWrapper = styled.div`
    display: flex;
    align-items: center;
    justify-content: space-between;
    width: 100%;
    background-color: ${(props) => props.theme.colors.systemGrey};
    padding: 1rem;
    border-radius: 10px;
    cursor: pointer;
`;

export const GameTitle = styled.div`
    font-size: 20px;
    font-weight: bold;
    margin-bottom: 1rem;
`;

export const GameStatusLabel = styled.div<{
    color?: string;
}>`
    font-size: 28px;
    text-transform: uppercase;
    font-weight: bold;
    color: ${(props) => props.color || "black"};
`;

export const JoinGameButton = styled.button`
    padding: 0.5rem 1rem;
    border: none;
    background: ${(props) => props.theme.colors.systemYellow};
    font-family: inherit;
    font-weight: 600;
    font-size: 20px;
    border-radius: 10px;
    cursor: pointer;
    color: white;

    &:hover {
        opacity: 0.8;
    }
`;

Game Page update

The final part of the update is about updating the GamePage with a dynamic version where the user can see all the game details.

// src/pages/GamePage/Grid/Grid.styled.ts

import React, { useContext, useEffect, useMemo, useState } from 'react'
import { useParams } from 'react-router-dom'
import { GameContext } from '../../context/Game.context'
import { WalletContext } from '../../context/Wallet.context'
import { getGameById, getGameStatus, getGameStatusColor } from '../../utils/helpers/game.helpers'
import { getFormattedWalletAddress } from '../../utils/helpers/Wallet.helpers'
import { Game } from '../../utils/types/game.types'
import { 
    GamePageWrapper,
    PlayerLabel,
    PlayersWrapper,
    StatusLabel,
    VSLabel
} from './GamePage.styled'
import Grid from './Grid/Grid'

type LocationParams = {
    id: string
}

const GamePage = () => {
    const { id } = useParams();

    const [game, setGame] = useState();

    const { games } = useContext(GameContext);
    const { currentWalletAddress } = useContext(WalletContext);

    useEffect(() => {
        const game = getGameById(games, Number(id));
        game && setGame(game);
    }, [id, games]);

    const status = useMemo(
        () => game && getGameStatus(game, currentWalletAddress || ""),
        [game, currentWalletAddress]
    );
    const statusColor = useMemo(
        () => game && getGameStatusColor(game, currentWalletAddress || ""),
        [game, currentWalletAddress]
    )

    return (
        
            
                
                    {game ? getFormattedWalletAddress(game.player1) : "--"}
                
                VS
                
                    {game ? getFormattedWalletAddress(game.player2) : "--"}
                
            
            
            {game?.finished && (
                
                    {
                        status === "won" ? 
                            "You won" :
                            status === "lost" ?
                                "You lost" :
                                "Draw"
                    }
                
            )}
        
    )
}

export default GamePage

Update also the Grid display.

// src/pages/GamePage/Grid/Grid.tsx

import React, { FC, useContext } from 'react'
import { toast } from 'react-toastify';
import { WalletContext } from '../../../context/Wallet.context';
import { getBoardCellStatus } from '../../../utils/helpers/game.helpers';
import { play } from '../../../utils/taquito/transactions.taquito';
import { Game } from '../../../utils/types/game.types';
import { Cell, GridWrapper, MovesLabel, Row } from './Grid.styled'

type GridProps = {
    game?: Game;
}

const Grid: FC = ({ game }) => {
    const { currentWalletAddress } = useContext(WalletContext);

    return (
        
            Moves: {game?.moves_number || "--"}
            
{[...Array(3)].map((_, j: number) => { return ( {[...Array(3)].map((_, i: number) => { const cellStatus = getBoardCellStatus(i + 1, j + 1, game); return ( toast.promise( () => play(i + 1, j + 1, game?.board_id || 0), { pending: 'Playing...', success: 'Played!', error: 'Oups! Error playing' } ) )} /> })} ) })}
) } export default Grid

Conclusion

In conclusion, building a decentralized application on the Tezos blockchain can be an exciting and rewarding experience. By leveraging the unique features of the blockchain, such as its on-chain governance and formal verification capabilities, developers can create secure and reliable applications that offer a high degree of transparency and user empowerment. From creating a smart contract to building a front-end interface with React or other web development tools, there are many steps involved in developing a successful DApp on Tezos. However, by following the best practices outlined in this tutorial and continuing to explore the possibilities of the Tezos ecosystem, developers can unlock a wealth of new opportunities and possibilities in the world of decentralized finance and applications.

Of course this application can be improved, here are several options that you can explore to develop your skill with this app.

  • Make a real-time app with live data (user does not have to refresh the page to see new data), try to use react-query library to fetch data at a determined interval.
  • Create an option for leaving the game. Interact with the leave_game entrypoint.
  • Improve the UI/UX to make it more modern and user friendly

It’s now up to you !

As I said earlier, the whole project is hosted on github. There might be some little bugs or improvements to make so feel free to open issues (and/or make PR) on the repo. Also if you have any question you can open issues and we’ll have a talk about your problem !