Click here to Skip to main content
65,938 articles
CodeProject is changing. Read more.
Articles / web

Build NFT Collection Web3 Application with Hardhat and React Typescript

5.00/5 (17 votes)
13 Sep 2022CPOL15 min read 28.6K   242  
Use React and hardhat typescript to build a NFT contract web3 application from scratch
This article creates a NFT contract and also builds a React client to load NFT collection, mint NFT and withdraw.

Image 1

Introduction

In this article, I'll show you how to create a NFT contract, and how to build a React Web3 application to load NFT collection, mint NFT and withdrawal functionalities.

What is Ethereum?

Ethereum is a big name in the world of blockchain. It is the second-largest blockchain platform which serves as the first choice for developing blockchain-based decentralized applications. Ethereum redefined the appeal of blockchain technology and showed the world that it can be much more than being just a peer-to-peer cash system. While newcomers mostly associate bitcoins with blockchain technology, cryptocurrencies are only one aspect of the technology. But Ethereum is programmable blockchain platform and open-source. So, the users can develop different applications of their choice. Ethereum innovated a plethora of advanced concepts, such as — decentralized applications, smart contracts, virtual machines, ERC tokens, etc.

What is ERC?

ERC basically means Ethereum Request for Comments, and its basic role is offering functionality for Ethereum. It features a standard set of rules for creating tokens on Ethereum. The instructions in the ERC tokens outline the sales, purchases, unit limits, and existence of tokens.

ERC-20 and ERC721 tokens are the initial types of ERC token standards that serve an important role in defining the functionality of the Ethereum ecosystem. You can think of them as the standards for the creation and publication of smart contracts on the Ethereum blockchain. It is also important to note that people could invest in tokenized assets or smart properties created with the help of smart contracts. ERC is more of a template or format which all developers should follow in developing smart contracts. 

The difference ERC20 vs. ERC721 is the differences between fungibility and non-fungibility. Fungible assets are the assets that you can swap with another similar entity. On the other hand, non-fungible assets are the opposite and cannot be swapped for one another. For example, a house could be easily considered a non-fungible asset as it would have some unique properties. When it comes to the crypto world, representation of assets in the digital form would definitely have to consider the aspects of fungibility and non-fungibility. 

What is NFT Smart Contract?

NFT Smart Contract is ERC721 token. It's a non-fungible token, referred to as an NFT, is a digital asset that represents real-world objects such as art, music, and videos on a blockchain. NFTs use identification codes and metadata that is logged and authenticated on cryptocurrency blockchains, which renders each NFT represented on the blockchain a unique asset. Unlike cryptocurrencies, which are also logged on blockchains, NFTs cannot be traded or exchanged at equivalency, hence they are non-fungible. A smart contract is programming that exists within the blockchain. This enables the network to store the information that is indicated in an NFT transaction. Smart contracts are self-executing and can check that the contract terms have been satisfied, as well as execute the terms without the need for an intermediary or central authority.

What's Solidity?

Solidity is an object-oriented, high-level language for implementing smart contracts. Smart contracts are programs which govern the behaviour of accounts within the Ethereum state.

Solidity is statically typed, supports inheritance, libraries and complex user-defined types among other features. With Solidity you can create contracts for uses such as voting, crowdfunding, blind auctions, and multi-signature wallets.

What's Hardhat?

Hardhat is Ethereum development environment. Easily deploy your contracts, run tests and debug Solidity code without dealing with live environments. Hardhat Network is a local Ethereum network designed for development.

Prerequisites

NFT token has meta-data (image and properties). It can be stored on IPFS (InterPlanetary File System) or on chain. We store our NFT meta data on IPFS as SVG format. SVG image format is supported by Opensea's storefront and you can view the NFT in it after the contract is deployed to ethereum blockchain mainnet, testnet.

Before we create NFT contract, we need to upload SVG images to IPFS. Thanks to Pinata website, it makes this work pretty easy. Go to the Pinata website and create an account, It’s free if you’re uploading up to 1 GB of data. Once you have signed up, you will be taken to the Pin Manager window. Upload your folder using the interface. Once you’ve uploaded your folder, you will get a CID associated with it. It should look something like this:

Image 2

For my folder, the CID is QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg. Therefore, the IPFS URL for this folder is ipfs://QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg.

Image 3

This URL will not open in a browser. In order to do that, you can use a HTTP URL of an IPFS gateway. Try visiting this link: https://ipfs.io/ipfs/QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg/0001.svg. It will display an image that I named 00001.png and uploaded to my folder.

Create a React Typescript Web3 Application

When building smart contracts, you need a development environment to deploy contracts, run tests, and debug Solidity code without dealing with live environments.

React app is a perfect application to compile Solidity code into code that can be run in a client-side application.

Hardhat is an Ethereum development environment and framework designed for full stack development.

ethers.js is a complete and compact library for interacting with the Ethereum Blockchain and its ecosystem from client-side applications like React, Vue, Angular.

MetaMask helps to handle account management and connecting the current user to the blockchain. Once connected their MetaMask wallet, you can interact with the globally available Ethereum API (window.ethereum) that identifies the users of web3-compatible browsers (like MetaMask users).

First, we create a typescript React app.

Shell
npx create-react-app react-web3-ts --template typescript

Next, change into the new directory and install ethers.js and hardhat.

Shell
npm install ethers hardhat chai @nomiclabs/hardhat-ethers

Delete README.md and tsconfig.json from the React application folder first, otherwise you will get a conflict. Run the below command:

Shell
npx hardhat

Image 4

Then select “Create a TypeScript project”.

It will prompt you to install some dependencies.

Image 5

Hardhat is already installed. We just need to install hardhat-toolbox.

Shell
npm install --save-dev @nomicfoundation/hardhat-toolbox@^1.0.1

Now open “react-web3-ts” folder in VS Code, it should be like the below:

Image 6

There is a sample contract Lock.sol.

From our React app, the way that we will interact with the smart contract is using a combination of the ethers.js library, the contract address, and the ABI that will be created from the contract by hardhat.

What is an ABI? ABI stands for application binary interface. You can think of it as the interface between your client-side application and the Ethereum blockchain where the smart contract you are going to be interacting with is deployed.

ABIs are typically compiled from Solidity smart contracts by a development framework like HardHat. You can also often find the ABIs for a smart contract on Etherscan.

Hardhat-toolbox includes type-chain. By default, typechain generates typings in typechain-types under root folder. That will cause a problem in our React client coding. React will complain that we should only import types from src folder. So we make changes in hardhat.config.ts.

TypeScript
import { HardhatUserConfig } from "hardhat/config";
import "@nomicfoundation/hardhat-toolbox";

const config: HardhatUserConfig = {
  solidity: "0.8.9",
  networks: {
    hardhat: {
      chainId: 1337
    }
  },
  typechain: {
    outDir: 'src/typechain-types',
    target: 'ethers-v5',
    alwaysGenerateOverloads: false, // should overloads with full signatures 
                                    // like deposit(uint256) be generated always, 
                                    // even if there are no overloads?
    externalArtifacts: ['externalArtifacts/*.json'], // optional array of glob 
    // patterns with external artifacts to process (for example external libs 
                                    // from node_modules)
    dontOverrideCompile: false      // defaults to false
  }
};

export default config;

In hardhat.config.ts, I add a local network, please note you have to set chain Id as 1337, if you want to connect with MetaMask. Also, we add a customized typechain configuration, where out folder is src/typechain-types.

Compile the ABI

Run the below command in VS Code terminal.

Shell
npx hardhat compile

Now, you should see a new folder named typechain-types in the src directory. You can find all types and interfaces for the sample contract Lock.sol.

Image 7

Deploying and Using a Local Network / Blockchain

To deploy to the local network, you first need to start the local test node.

Shell
npx hardhat node

You should see a list of addresses and private keys.

Image 8

These are 20 test accounts and addresses created for us that we can use to deploy and test our smart contracts. Each account is also loaded up with 10,000 fake Ether. In a moment, we'll learn how to import the test account into MetaMask so that we can use it.

Now we can run the deploy script and give a flag to the CLI that we would like to deploy to our local network. In VS Code, open another terminal to run the below command:

Shell
npx hardhat run scripts/deploy.ts --network localhost

Lock contract is deployed to 0x5FbDB2315678afecb367f032d93F642f64180aa3.

Image 9

Ok. The sample contract is compiled and deployed. That verified our web3 development environment is setup. Now we run “hardhat clean” to clean the sample contract and start to write our own NFT Collectible smart contract.

Shell
npx hardhat clean

Write NFT Collectible Smart Contract

Let us now install the OpenZeppelin contracts package. This will give us access to the ERC721 contracts (the standard for NFTs) as well as a few helper libraries that we will encounter later.

Shell
npm install @openzeppelin/contracts

Delete lock.sol from contracts folder. Create a new file called NFTCollectible.sol.

We will be using Solidity v8.4. Our contract will inherit from OpenZeppelin’s ERC721Enumerable and Ownable contracts. The former has a default implementation of the ERC721 (NFT) standard in addition to a few helper functions that are useful when dealing with NFT collections. The latter allows us to add administrative privileges to certain aspects of our contract.

In addition to the above, we will also use OpenZeppelin’s SafeMath and Counters libraries to safely deal with unsigned integer arithmetic (by preventing overflows) and token IDs respectively.

This is what our contract looks like:

TypeScript
// contracts/NFT.sol
// SPDX-License-Identifier: MIT
pragma solidity ^0.8.9;

import "@openzeppelin/contracts/utils/Counters.sol";
import "@openzeppelin/contracts/access/Ownable.sol";
import "@openzeppelin/contracts/utils/math/SafeMath.sol";
import "@openzeppelin/contracts/token/ERC721/extensions/ERC721Enumerable.sol";

contract NFTCollectible is ERC721Enumerable, Ownable {
    using SafeMath for uint256;
    using Counters for Counters.Counter;

    Counters.Counter private _tokenIds;

    uint256 public constant MAX_SUPPLY = 100;
    uint256 public constant PRICE = 0.01 ether;
    uint256 public constant MAX_PER_MINT = 5;

    string public baseTokenURI;

    constructor(string memory baseURI) ERC721("NFT Collectible", "NFTC") {
        setBaseURI(baseURI);
    }

    function setBaseURI(string memory _baseTokenURI) public onlyOwner {
        baseTokenURI = _baseTokenURI;
    }

    function _baseURI() internal view virtual override returns (string memory) {
        return baseTokenURI;
    }

    function reserveNFTs() public onlyOwner {
        uint256 totalMinted = _tokenIds.current();
        require(totalMinted.add(10) < MAX_SUPPLY, "Not enough NFTs");
        for (uint256 i = 0; i < 10; i++) {
            _mintSingleNFT();
        }
    }

    function mintNFTs(uint _count) public payable {
        uint totalMinted = _tokenIds.current();

        require(totalMinted.add(_count) <= MAX_SUPPLY, "Not enough NFTs left!");
        require(_count >0 && _count <= MAX_PER_MINT, 
        "Cannot mint specified number of NFTs.");
        require(msg.value >= PRICE.mul(_count), "Not enough ether to purchase NFTs.");

        for (uint i = 0; i < _count; i++) {
            _mintSingleNFT();
        }
    }

    function _mintSingleNFT() private {
        uint256 newTokenID = _tokenIds.current();
        _safeMint(msg.sender, newTokenID);
        _tokenIds.increment();
    }

    function tokensOfOwner(address _owner) external view returns (uint[] memory) {

        uint tokenCount = balanceOf(_owner);
        uint[] memory tokensId = new uint256[](tokenCount);

        for (uint i = 0; i < tokenCount; i++) {
            tokensId[i] = tokenOfOwnerByIndex(_owner, i);
        }
        return tokensId;
    }

    function withdraw() public payable onlyOwner {
        uint balance = address(this).balance;
        require(balance > 0, "No ether left to withdraw");

        (bool success, ) = (msg.sender).call{value: balance}("");
        require(success, "Transfer failed.");
    }
}

We set the baseTokenURI in our constructor call. We also call the parent constructor and set the name and symbol for our NFT collection.

Our NFT JSON metadata is available at this IPFS URL mentioned at the beginning of the article.

When we set this as the base URI, OpenZeppelin’s implementation automatically deduces the URI for each token. It assumes that token 1’s metadata will be available at ipfs://QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg/1, token 2’s metadata will be available at ipfs://QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg/2, and so on.

However, we need to tell our contract that the baseTokenURI variable that we defined is the base URI that the contract must use. To do this, we override an empty function called _baseURI() and make it return baseTokenURI.

Mint NFTs Function

Let us now turn our attention to the main mint NFTs function. Our users and customers will call this function when they want to purchase and mint NFTs from our collection.

Anyone to mint a certain number of NFTs by paying the required amount of ether + gas, since they’re sending ether to this function, we have to mark it as payable.

We need to make three checks before we allow the mint to take place:

  1. There are enough NFTs left in the collection for the caller to mint the requested amount.
  2. The caller has requested to mint more than 0 and less than the maximum number of NFTs allowed per transaction.
  3. The caller has sent enough ether to mint the requested number of NFTs.

Withdraw Balance Function

All the effort we’ve put in so far would go to waste if we are not able to withdraw the ether that has been sent to the contract.

Let us write a function that allows us to withdraw the contract’s entire balance. This will obviously be marked as onlyOwner.

Compile Contract

First, compile our new smart contract.

Shell
npx hardhat compile

After compile finish, types of the new contract are generated at src/typechain-types folder.

Image 10

You can find ABI of the new contact at NFTCollectible__factory.ts.

Test Contract

Delete Lock.ts and add NFTCollectible.ts in test folder. Let's start with the code below.

TypeScript
import { expect } from "chai";
import { ethers } from "hardhat";
import { NFTCollectible } from "../src/typechain-types/contracts/NFTCollectible";
import type { SignerWithAddress   } from "@nomiclabs/hardhat-ethers/signers";

describe("NFTCollectible", function () {
  let contract : NFTCollectible;
  let owner : SignerWithAddress;
  let addr1 : SignerWithAddress;
}

Because every test case needs the contract get deployed. So we write deployment as beforeEach.

TypeScript
beforeEach(async function () {
    // Get the ContractFactory and Signers here.
    [owner, addr1] = await ethers.getSigners();
    const contractFactory = await ethers.getContractFactory("NFTCollectible");
    contract = await contractFactory.deploy("baseTokenURI");
    await contract.deployed();
  }); beforeEach(async function () {
    // Get the ContractFactory and Signers here.
    [owner, addr1] = await ethers.getSigners();
    const contractFactory = await ethers.getContractFactory("NFTCollectible");
    contract = await contractFactory.deploy("baseTokenURI");
    await contract.deployed();
});

Now we add transaction test cases.

  • reserveNFTs reserves 10 NFTs for the owner.
    TypeScript
    it("Reserve NFTs should 10 NFTs reserved", async function () {
          let txn = await contract.reserveNFTs();
          await txn.wait();
          expect(await contract.balanceOf(owner.address)).to.equal(10);
    });
  • The price of NFT is 0.01ETH, so need pay 0.03ETH to mint 3 NFTs.
    TypeScript
    it("Sending 0.03 ether should mint 3 NFTs", async function () {
          let txn = await contract.mintNFTs(3, 
                    { value: ethers.utils.parseEther('0.03') });
          await txn.wait();
          expect(await contract.balanceOf(owner.address)).to.equal(3);
    });
  • When minting an NFT, the minter pays the smart contract and gas fees. The gas fees go to miners, but the cryptocurrency goes to the contract and not the owner.

    TypeScript
    it("Withdrawal should withdraw the entire balance", async function () {
          let provider = ethers.provider
          const ethBalanceOriginal = await provider.getBalance(owner.address);
          console.log("original eth balanace %f", ethBalanceOriginal);
          let txn = await contract.connect(addr1).mintNFTs(1, 
                    { value: ethers.utils.parseEther('0.01') });
          await txn.wait();
          
          const ethBalanceBeforeWithdrawal = await provider.getBalance(owner.address);
          console.log("eth balanace before withdrawal %f", ethBalanceBeforeWithdrawal);
          txn = await contract.connect(owner).withdraw();
          await txn.wait();
          const ethBalanceAfterWithdrawal = await provider.getBalance(owner.address);
          console.log("eth balanace after withdrawal %f", ethBalanceAfterWithdrawal);
          expect(ethBalanceOriginal.eq(ethBalanceBeforeWithdrawal)).to.equal(true);
          expect(ethBalanceAfterWithdrawal.gt
                (ethBalanceBeforeWithdrawal)).to.equal(true);
    });

Run test.

Shell
npx hardhat test

Image 11

Deploying the Contract Locally

Change main function in scripts\deplot.ts file.

Replace lock contract deployment with our NTFCollectible contract deployment in main function. Please note we need pass our NFT Collection base URL to the constructor of contract.

TypeScript
const baseTokenURI = "ipfs://QmPz4f9RY2pwgiQ34UrQ8ZtLf31QTTS8FSSJ9GCWvktXtg/"; 
// Get contract that we want to deploy
const contractFactory = await ethers.getContractFactory("NFTCollectible");
// Deploy contract with the correct constructor arguments
const contract = await contractFactory.deploy(baseTokenURI);

// Wait for this transaction to be mined
await contract.deployed();

console.log("NFTCollectible deployed to:", contract.address);

Open terminal in VS Code to run:

Shell
npx hardhat node

Open another terminal to run deploy command.

Shell
npx hardhat run scripts/deploy.ts --network localhost

Our contract is deployed to address 0x5FbDB2315678afecb367f032d93F642f64180aa3.

Image 12

React Client

We have deployed our contract. Next, I will show you how to build a React Client to use the functionalities provided by this smart contract.

Material UI

Material UI is an open-source React component library that implements Google's Material Design.

It includes a comprehensive collection of prebuilt components that are ready to use in production right out of the box.

Material UI is beautiful by design and features a suite of customization options that make it easy to implement your own custom design system on top of our components.

Install Material UI.

Shell
npm install @mui/material @emotion/react @emotion/styled

Install Material UI Icons.

Shell
npm install @mui/icons-material

MUI has all different kinds of components. We’ll use App Bar, Box, Stack, Modal, and Image List.

  • App Bar

    The App bar displays information and actions relating to the current screen.

  • Box

    The Box component serves as a wrapper component for most of the CSS utility needs.

  • Stack

    The Stack component manages layout of immediate children along the vertical or horizontal axis with optional spacing and/or dividers between each child.

  • Modal

    The modal component provides a solid foundation for creating dialogs, popovers, lightboxes, or whatever else.

  • Image List

    Image lists display a collection of images in an organized grid.

Currently, tsconfig.json is created by hardhat typescript. It’s not fully cover react typescript. Update tsconfig.json as the below:

TypeScript
{
  "compilerOptions": {
    "target": "es2021",
    "lib": [
      "dom",
      "dom.iterable",
      "esnext"
    ],
    "allowJs": true,
    "skipLibCheck": true,
    "esModuleInterop": true,
    "allowSyntheticDefaultImports": true,
    "strict": true,
    "forceConsistentCasingInFileNames": true,
    "noFallthroughCasesInSwitch": true,
    "module": "commonjs",
    "moduleResolution": "node",
    "resolveJsonModule": true,
    "isolatedModules": true,
    "noEmit": true,
    "outDir": "dist",
    "sourceMap": true,
    "jsx": "react-jsx"
  },
  "include": ["./scripts", "./test", "./src/typechain-types"],
  "files": ["./hardhat.config.ts"]
}

Download MetaMask.svg to src folder, we use it as logo.

Add Demo.tsx file under src folder, and copy the below code. We starts with a basic App Bar.

TypeScript
import * as React from 'react';
import AppBar from '@mui/material/AppBar';
import Toolbar from '@mui/material/Toolbar';
import Typography from '@mui/material/Typography';
import CssBaseline from '@mui/material/CssBaseline';
import Container from '@mui/material/Container';
import Stack from '@mui/material/Stack';
import Avatar from '@mui/material/Avatar';
import logo from './metamask.svg';

function Demo() {
  return (
    <React.Fragment>
      <CssBaseline />
      <AppBar>
        <Toolbar>
          <Stack direction="row" spacing={2}>
            <Typography variant="h3" component="div">
              NFT Collection
            </Typography>
            <Avatar alt="logo" src={logo} sx={{ width: 64, height: 64 }} />
          </Stack>
        </Toolbar>
      </AppBar>
      <Toolbar />
      <Container>
      </Container>
    </React.Fragment>
  );
}

export default Demo;

Then change index.tsx to load Demo instead of App.

TypeScript
import React from 'react';
import ReactDOM from 'react-dom/client';
import './index.css';
import App from './App';
import Demo from './Demo';
import reportWebVitals from './reportWebVitals';

const root = ReactDOM.createRoot(
  document.getElementById('root') as HTMLElement
);
root.render(
  <React.StrictMode>
    <Demo />
  </React.StrictMode>
);

// If you want to start measuring performance in your app, pass a function
// to log results (for example: reportWebVitals(console.log))
// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitals
reportWebVitals();

Run it.

Shell
npm start

Image 13

Connect Wallet

Let’s double check the address where our NFT Collection contract deployed. It’s 0x5FbDB2315678afecb367f032d93F642f64180aa3. Define a const first.

TypeScript
const contractAddress = "0x5FbDB2315678afecb367f032d93F642f64180aa3";

Then define IWallet interface.

TypeScript
interface IWallet {
  iconColor: string;
  connectedWallet: string;
  contractAddress: string;
  contractSymbol: string;
  contractBaseTokenURI: string;
  contractOwnerAddress: string;
  contractPrice: string;
  isOwner: boolean;
}

We need use useState hook to initialize and update IWallet instance. React introduces Hooks from 16.8. useState is a built-in hook, lets you use local state within a function component. You pass the initial state to this function and it returns a variable with the current state value (not necessarily the initial state) and another function to update this value.

TypeScript
const [state, setState] = React.useState<IWallet>({
    iconColor: "disabled",
    connectedWallet: "",
    contractSymbol: "",
    contractAddress: "",
    contractBaseTokenURI: "",
    contractOwnerAddress: "",
    contractPrice: "",
    isOwner: false
});

Import ethers and NFTCollectible__factory.

TypeScript
import { BigNumber, ethers } from "ethers";
import { NFTCollectible__factory } from 
'./typechain-types/factories/contracts/NFTCollectible__factory'

Now write connect wallet function.

TypeScript
const connectWallet = async () => {
    try {
      console.log("connect wallet");
      const { ethereum } = window;

      if (!ethereum) {
        alert("Please install MetaMask!");
        return;
      }

      const accounts = await ethereum.request({
        method: "eth_requestAccounts",
      });
      console.log("Connected", accounts[0]);

      const provider = new ethers.providers.Web3Provider(ethereum);
      const contract = NFTCollectible__factory.connect
                       (contractAddress, provider.getSigner());
      //const contract = new ethers.Contract
      //(contractAddress, NFTCollectible__factory.abi, signer) as NFTCollectible;
      const ownerAddress = await contract.owner();
      const symbol = await contract.symbol();
      const baseTokenURI = await contract.baseTokenURI();
      const balance = await (await contract.balanceOf(accounts[0])).toNumber();
      const ethBalance = ethers.utils.formatEther
                         (await provider.getBalance(accounts[0]));
      const isOwner = (ownerAddress.toLowerCase() === accounts[0].toLowerCase());
      const price = ethers.utils.formatEther(await contract.PRICE());
      setState({
        iconColor: "success",
        connectedWallet: accounts[0],
        contractSymbol: symbol,
        contractAddress: contract.address,
        contractBaseTokenURI: baseTokenURI,
        contractOwnerAddress: ownerAddress,
        contractPrice: `${price} ETH`,
        isOwner: isOwner
      });

      console.log("Connected", accounts[0]);
    } catch (error) {
      console.log(error);
    }
};

You can see here, MetaMask extension has to be installed, otherwise you cannot get Ethereum object from window. Make UI changes, add Connect button and Connected Account, Contract Address, Contract Base Token URI text fields. Also, use Account Circle icon to indicate connected or not.

TypeScript
<Stack direction="row" spacing={2} sx={{ margin: 5 }}>
  <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
    <AccountCircle color={state.iconColor} sx={{ mr: 1, my: 0.5 }} />
    <TextField id="wallet_address" label="Connected Account" 
    sx={{ width: 300 }} variant="standard" value={state.connectedWallet}
      inputProps={{ readOnly: true, }}
    />
  </Box>
  <TextField id="contract_symbol" label="Contract Symbol" 
  vari-ant="standard" value={state.contractSymbol}
    inputProps={{ readOnly: true, }}
  />
  <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
    <TextField id="contract_address" label="Contract Address" 
    sx={{ width: 400 }} variant="standard" value={state.contractAddress}
      inputProps={{ readOnly: true, }}
    />
  </Box>
  <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
    <TextField id="contract_baseURI" label="Contract Base Token URI" 
    sx={{ width: 500 }} variant="standard" value={state.contractBaseTokenURI}
      inputProps={{ readOnly: true, }}
    />
  </Box>
</Stack>

Here is how it looks to use Stack. Stack have two directions, "row" and "column".

Run our app.

Image 14

Click Connect button.

Image 15

Ooohh, work as charming!

Load NFT Collection

Add state hook for image URL collection.

TypeScript
const [nftCollection, setNFTCollection] = React.useState<string[]>([]);

Write load NFT collection function.

TypeScript
const loadNFTCollection = async () => {
  try {
    console.log("load NFT collection");
    let baseURI: string = state.contractBaseTokenURI;
    baseURI = baseURI.replace("ipfs://", "https://gateway.pinata.cloud/ipfs/");
    setNFTCollection(
      [
        `${baseURI}0001.svg`,
        `${baseURI}0002.svg`,
        `${baseURI}0003.svg`,
        `${baseURI}0004.svg`,
      ]);
  } catch (error) {
    console.log(error);
  }
};

Import ImageList and ImageListItem.

TypeScript
import ImageList from '@mui/material/ImageList';
import ImageListItem from '@mui/material/ImageListItem';

Binding image list with NFT URL Collection.

TypeScript
<ImageList sx={{ width: 500, height: 450 }} cols={3} rowHeight={164}>
  {nftCollection.map((item) => (
    <ImageListItem key={item}>
      <img
        src={`${item}?w=164&h=164&fit=crop&auto=format`}
        srcSet={`${item}?w=164&h=164&fit=crop&auto=format&dpr=2 2x`}
        loading="lazy"
      />
    </ImageListItem>
  ))}
</ImageList>

Run app agin and click "Load NFT Collection" button.

Image 16

Mint NFT

Add IService interface.

TypeScript
interface IService {
  account: string;
  ethProvider?: ethers.providers.Web3Provider,
  contract?: NFTCollectible;
  currentBalance: number;
  ethBalance: string;
  mintAmount: number;
}

Use state hook.

TypeScript
const [service, setService] = React.useState<IService>({
    account: "",
    currentBalance: 0,
    ethBalance: "",
    mintAmount: 0
});

Mint NFT function.

TypeScript
const mintNFTs = async () => {
    try {
      console.log("mint NFTs");
      const address = service.account;
      const amount = service.mintAmount!;
      const contract = service.contract!;
      const price = await contract.PRICE();
      const ethValue = price.mul(BigNumber.from(amount));
      const signer = service.ethProvider!.getSigner();
      let txn = await contract.connect(signer!).mintNFTs(amount, { value: ethValue });
      await txn.wait();
      const balance = await contract.balanceOf(address);
      setService({...service, currentBalance: balance.toNumber(), mintAmount: 0});
    } catch (error) {
      console.log(error);
    }
};

Mint modal dialog.

TypeScript
<Modal
    aria-labelledby="transition-modal-title"
    aria-describedby="transition-modal-description"
    open={open}
    onClose={handleClose}
    closeAfterTransition
    BackdropComponent={Backdrop}
    BackdropProps={{
      timeout: 500,
    }}
    >
    <Fade in={open}>
      <Box sx={modalStyle}>
        <Stack spacing={1} sx={{ width: 500 }}>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="mint_account" label="Account" 
            sx={{ width: 500 }} variant="standard" value={service.account}
              inputProps={{ readOnly: true}}
            />
          </Box>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="price" label="NFT Price" 
            sx={{ width: 500 }} variant="standard" value={state.contractPrice}
              inputProps={{ readOnly: true}}
            />
          </Box>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="balance" label="Balance" 
            sx={{ width: 500 }} variant="standard" value={service.currentBalance}
              type = "number" inputProps={{ readOnly: true}}
            />
          </Box>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="mint_amount" type="number" 
            label="Mint Amount" sx={{ width: 500 }} 
            variant="standard" value={service.mintAmount}
             onChange={event => {
              const { value } = event.target;
              const amount = parseInt(value); 
              setService({...service, mintAmount: amount});
            }}
             />
          </Box>
          <Stack direction="row" spacing={2} sx={{ margin: 5 }}>
            <Button variant="outlined" onClick={mintNFTs}>Mint</Button>
            <Button variant="outlined" onClick={handleClose}>close</Button>
          </Stack>
        </Stack>
      </Box>
    </Fade>
</Modal>

Ru app, click "Mint NFT" button. You'll get a popup dialog.

Image 17

Withdraw

Withdraw function.

TypeScript
const withdraw = async () => {
    try {
      console.log("owner withdraw");
      const contract = service.contract!;
      const provider = service.ethProvider!;
      let txn = await contract.withdraw();
      await txn.wait();
      const ethBalance = ethers.utils.formatEther
            (await provider!.getBalance(service.account));
      setService({...service, ethBalance: `${ethBalance} ETH`});
    } catch (error) {
      console.log(error);
    }
};

Withdraw modal dialog,

TypeScript
<Modal
    id="withdrawal_modal"
    aria-labelledby="transition-modal-title"
    aria-describedby="transition-modal-description"
    open={openWithdrawal}
    onClose={handleCloseWithdrawal}
    closeAfterTransition
    BackdropComponent={Backdrop}
    BackdropProps={{
      timeout: 500,
    }}
    >
    <Fade in={openWithdrawal}>
      <Box sx={modalStyle}>
        <Stack spacing={1} sx={{ width: 500 }}>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="owner_account" label="Owner Account" 
            sx={{ width: 500 }} variant="standard" value={service.account}
              inputProps={{ readOnly: true }}
            />
          </Box>
          <Box sx={{ display: 'flex', alignItems: 'flex-end' }}>
            <TextField id="ethbalance" label="ETH Balance" 
            sx={{ width: 500 }} variant="standard" value={service.ethBalance}
              inputProps={{ readOnly: true }}
            />
          </Box>
          <Stack direction="row" spacing={2} sx={{ margin: 5 }}>
            <Button variant="outlined" onClick={withdraw}>Withdraw</Button>
            <Button variant="outlined" onClick={handleCloseWithdrawal}>close</Button>
          </Stack>
        </Stack>
      </Box>
    </Fade>
</Modal>

Withdraw is only available for contract owner who deployed the contract. So Withdraw button is only enabled for the owner.

TypeScript
<Button variant="contained" disabled={!state.isOwner} 
onClick={handleOpenWithdrawal}>Withdraw</Button>

If MetaMask currently connected account is not the owner of contract, Withdraw button is disabled.

Image 18

Change account to the owner in MetaMask.

Image 19

After change to the owner, click Connect button in our react client, Withdraw button will be enabled.

Image 20

Click “WITHDRAW” button.

Image 21

That's it. A small but interesting web3 app is done. Now you open a gate to the brand new world.

Epilogue: React Router

We have finished building the react web3 client. There is a very important react concept I haven’t mentioned. That is React Router. In our app, I created a demo component. and directly put it in index.tsx. That’s not a problem for simple application. But how about if you have multiple components want to navigate? React Router provides a perfect solution.

React Router isn't just about matching a url to a function or component: it's about building a full user interface that maps to the URL, so it might have more concepts in it than you're used to. React router does the three major jobs as the below.

  • Subscribing and manipulating the history stack
  • Matching the URL to your routes
  • Rendering a nested UI from the route matches

Install Ract Router. The latest version is V6.

Shell
npm install react-router-dom

Now import react router and demo component in App.tsx.

JavaScript
import { BrowserRouter as Router, Routes, Route } from "react-router-dom";

import Link from '@mui/material/Link';

import Demo from './Demo'

Create Home function in App.tsx.  Use Link to let the user change the URL or useNavigate to do it yourself. Here we use Link from Material UI not from react-router-dom. Essentially they are same thing except styling.

JavaScript
function Home() {
  return (
    <div className="App">
      <header className="App-header">
        <img src={logo} className="App-logo" alt="logo" />
        <p>
            With cryptocurrencies and blockchain technology, 
            NFTs have become part of this crazy world.            
        </p>
        <Box
          sx={{
            display: 'flex', flexWrap: 'wrap', 
                      justifyContent: 'center', typography: 'h3',
            '& > :not(style) + :not(style)': {
              ml: 2,
            },
          }}>
          <Link href="/demo" underline="none" sx={{ color: '#FFF' }}>
            Web3 NFT Demo
          </Link>
        </Box>
      </header>
    </div>
  );
}

Configuring routes in App function.

JavaScript
function App() {
  return (
    <Router>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="/demo" element={<Demo />} />
      </Routes>
    </Router>
  );
}

In previous versions of React Router you had to order your routes a certain way to get the right one to render when multiple routes matched an ambiguous URL. V6 is a lot smarter and will pick the most specific match so you don't have to worry about that anymore.

Don’t forget last thing, change back to App component in index.tsx.

JavaScript
root.render(
  <React.StrictMode>
    <App />
  </React.StrictMode>
);

Now run our app.

Shell
npm start

Image 22

Click Wbe3 NFT Demo, it will navigate to our web3 component.

How to Use the Source Code

First, install MetaMask extension on your browser (Chrome, Firefox or Edge).

Then download and extract the source code, open react-web3-ts folder with Visual Studio Code.

Then open New Terminal in VS Code.

  • Install all dependencies:
    Shell
    npm install
  • Compile smart contract:
    Shell
    npx hardhat compile
  • Start hardhat node:
    Shell
    npx hardhat node
  • Open another terminal, deploy the contract:
    Shell
    npx hardhat run scripts/deploy.ts --network localhost
  • Start app:
    Shell
    npm start

Conclusion

We covered a lot here, from smart contract to web3 app. I hope you learned a lot. Now you may ask, "what's the use of smart contracts?"

Advantages of smart contracts over centralized systems:

  1. Data cannot be changed or tampered with. So, it is almost impossible for malicious actors to manipulate data.
  2. It's completely decentralized.
  3. Unlike any centralized payment wallet, you don't have to pay any commission percentages to a middle man to transact.
  4. The most important is a smart contract may open a door to your financial freedom.

The sample project is in github now, dapp-ts. Enjoy coding!

History

  • 5th August, 2022: Initial version

License

This article, along with any associated source code and files, is licensed under The Code Project Open License (CPOL)