DEV Community

Cover image for How to create a dApp with React & Solidity on Ethereum Blockchain
xamhans
xamhans

Posted on • Updated on

How to create a dApp with React & Solidity on Ethereum Blockchain

In this tutorial I will show you how you can build a fullstack dApp, that will run on Ethereum and many Layer 2 platforms (Polygon, Celo, ..)
We will start with the backend part, where we will write a Smart Contract with the help of Hardhat and Solidity.
Afterwards we will build a frontend with React and Ethers.js to interact with our smart contract. We will also use IPFS with the help of the Pinata API.

🎬VIDEO EDITION:https://youtu.be/1dWxCE_RfaE

What is a dApp?

dApp stands for decentralised App. In a classic way an app would run on a single server (maybe the backend is on another server). In a decentralised world the frontend part will be served on IPFS (where a node in a distributed network is servering the files) and the backend will be running on a Smart Contract or Program on a node in a decentralised network.

Startup Grind Gib (@StartupGrindGib) / Twitter

You must be that tall to ride

I know everyone is hyped about web3, and this is nice ! really. But Web3 is an extension to Web2 so please, make sure that you know the basics of web development before you continue with this tutorial.

Tools we are using

Now that we know what a dApp is in general, here are the tools we are going to use to build one.

Frontend part:
React
Ether.js (for communicating with the smart contract)

Backend part:
Solidity
Hardhat (enviornment for easily write, test and deploy solidity code)

Starter Template

I will use this starter template for this tutorial, you can get it here:
enter image description here
https://github.com/XamHans/React-Solidity-Typescript-Starter
The finished project can be found here:
https://github.com/XamHans/image-contest

What we will build 🔫

We are going to build a voting app. Users can upload and vote for images.


Backend Part

Lets see what needs to be done from the backend part to achieve this dApp goal. We need a way to

  • create a canidadate ( a candidate is simply a user that has uploaded
    an image)

  • Get all candidates with their images

  • Increase the votes from one candidate, if a user likes the image from
    that specific candidate

Head over to /backend/contracts/ExmapleContract.sol
delete the boilerplate/example code in there and rename the file and contract to VoteManager.

Defining the struct for our candidates

We are going to use a struct ( is like a class but without any implementation logic) for defining the properties of a candidate.

struct Candidate {
uint id;
uint totalVote;
string name;
string imageHash;
address candidateAddress;
}

  • totalVote keeps track of the current canddiates votes

  • imageHash will store the IPFS Hash for the image

  • candidateAddress is the public key address of the candidate

Lets start with some logic, create a candidate

mapping(address => Candidate) private candidates;
mapping(uint=> address) private accounts;

function registerCandidate(string calldata _name, string calldata _imageHash) external {
    require(msg.sender != address(0), "Sender address must be valid"); 
    candidatesIds.increment();
    uint candidateId = candidatesIds.current();
    address _address = address(msg.sender);
    Candidate memory newCandidate = Candidate(candidateId, 0, _name, _imageHash, _address);  
    candidates[_address] = newCandidate;  
    accounts[candidateId] = msg.sender;
    emit candidateCreated(_address, _name);
    }
Enter fullscreen mode Exit fullscreen mode

registerCandidate is an external function, this means that this function can only be called from outside the contract. You could also mark it as public, but this would be gas inefficient.

The function takes two parameters, the name and the image from the candidate (ipfs hash). These two parameters are from memory type calldata.

calldata is a non-modifiable, non-persistent area where function
arguments are stored

We use require(msg.sender != address(0)); to check if the caller of the function really exists.

Require acts like an early out, where the condition (inside the brackets) is checked. If the condition is false, the function stops and returns the error message.

In the next two following lines we make use of openzeppelin counter to manage our ID's. With candidatesIds.increment(); we increase the value by 1 and get the current value with candidatesIds.current(); In order to use the address of the caller we need to "parse" it before usage, this is simply achieved with address(msg.sender).

OpenZeppelin Contracts helps you minimize risk by using battle-tested libraries of smart contracts for Ethereum and other blockchains, learn more here: https://openzeppelin.com/contracts/

We now can create a new Candidate by passing all neccessary parameters

memory newCandidate = Candidate(candidateId, 0, _name, _imageHash, _address);

Look out here to the memory keyword before "newCandidate". In Solidity you have to explicitly set the storage type if you want to create new objects. Storage from type memory will live as long as the function is executed, if you need permantent storage use storage type.

candidates[_address] = newCandidate;  
Enter fullscreen mode Exit fullscreen mode

Here we create a new key->value assignment in the candidates mapping. The key is the address of the caller (the candidate) and the value is the newly created candidate object. We use this mapping to organize our candidates, this mapping is permanently stored on the blockchain because its a state variable.

  • State Variables − Variables whose values are permanently stored in a contract storage.

  • Local Variables − Variables whose values are present till function is executing.

accounts[candidateId] = msg.sender;
Enter fullscreen mode Exit fullscreen mode

Same game but with candidateId as key and the caller address as a value. You might ask why the heck we need this mapping, but just be patient, it will all make sense soon :)

Now lets implement the vote function

function vote(address _forCandidate) external {
    candidates[_forCandidate].totalVote += 1;
    emit Voted(_forCandidate, msg.sender, candidates[_forCandidate].totalVote);
}
Enter fullscreen mode Exit fullscreen mode

The vote function is super simple. We pass in the address of the candidate who will recieve the vote.

candidates[_forCandidate].totalVote += 1;
Enter fullscreen mode Exit fullscreen mode

In candidates mapping we use the address as our key to get the candidate object and increase the totalVote by one.
After that we will emit an event

 emit Voted(_forCandidate, candidates[_forCandidate].totalVote);
Enter fullscreen mode Exit fullscreen mode

that event will act as a response. It contains informations that we will use on the frontend to update the UI.

The last function, get all candidates

function fetchCandidates() external  view  returns ( Candidate[] memory) {
 uint itemCount = candidatesIds.current();
 Candidate[] memory candidatesArray = new Candidate[](itemCount);
    for (uint i = 0; i < itemCount; i++) {
        uint currentId = i + 1;
        Candidate memory currentCandidate = candidates[accounts[currentId]];
        candidatesArray[i] = currentCandidate;
    }
    return candidatesArray;
 }
Enter fullscreen mode Exit fullscreen mode

Maybe you see this code and ask, heee, why we not just return the mapping? Well I also thought this, googled it and it turned out we cant. Therefore we will need a helper array to store our candidates. We get the current id (just a simple number) with

candidatesIds.current();
Enter fullscreen mode Exit fullscreen mode

okay now we know the maximum for our iteration and we store it in a variable called itemCount, we also use this variable in order to create our helper array candidatesArray . Here we will make use of our helper mapping accounts.

accounts
| 0 | 0x1234.. |
|--|--|
| 1 | 0x8521.. |

candidates
| 0x1234.. | {...} |
|--|--|
| 0x8521.. | {...} |

Otherwise we would have no chance to iterate over the candidates because we dont know the keys (addresses of the candidates) to iterate over. We could used ID as the key for the candidates mapping but then the vote function would more complicated.

Wow there were many "woulds" in the previous section.
Take a short break, we will continue with the deployment of the smart contract.


Deployment Part

1) Spin up local testnet
First we need to spin up our local ethereum blockchain. With the template starter you can simply use
npm run testnet or with npx hardhat node
2) Compile contract
Before we deploy the contract we need to compile it first. Open a new terminal and write
npm run compile or npx hardhat compile
This will also create the ABI. The ABI is essential for other programs (like our frontend) in order to communicate with the contract. It defines what functions can be called with the corresponding parameters.
3) Deploy contract
First go the deploy script (backend/scripts/deploy.ts) and make sure ethers.getContractFactory is grabbing the right contract
enter image description here
Finally deploy the votemanager contract to the local testnet with
npm run deploy or npx hardhat run --network localhost scripts/deploy.ts
Copy the address of the deployed contract we will need it afterwards.
enter image description here

Connect MetaMask to the local testnet

If you have spinned up the local testnet you will see an output like this:
enter image description here
copy one of these private keys and head over to
MetaMask --> Click on Profile Pictrue --> Import Account
enter image description here
Paste the private key to the input field and make sure that you have set up the local network.


Frontend Part

Head over to the frontend/App.tsx and create these state variables

const [contract, setContract] = useState()
const [selectedImage, setSelectedImage] = useState()
const [candidates, setCandidates] = useState<>([])
const [candidateFormData, setCandidateFormData] = useState({ name: '', imageHash: '' })
const contractAddress = "0xf899d9772a6BB9b251865ed33dc2CC733Ab4Bd65"
Enter fullscreen mode Exit fullscreen mode

Paste the copied address to the contractAddress variable.
enter image description here

Now copy this useEffect and paste it below the variables section.

useEffect(() => {
    setContract(getContract(contractAddress))
}, [])
Enter fullscreen mode Exit fullscreen mode

In this useEffect we assign our contract variable with the help of the helper function getContract. This functions returns a contract abstraction from our smart contract, that we can use, to interact with our smart contract. Lets see how its done.

import { Contract, ethers } from  "ethers";
import VoteManagerContract from  '../../../../backend/artifacts/contracts/VoteManager.sol/VoteManager.json'

export  default  function getContract(contractAddress: string): Contract {
    const provider = new ethers.providers.Web3Provider( (window as any).ethereum);
    const signer = provider.getSigner();

    const contract = new ethers.Contract(
    contractAddress,
    VoteManagerContract.abi,
    signer
    );

    return contract;
}
Enter fullscreen mode Exit fullscreen mode

Ethers.js Vocabs
First we need to create an Ethereum Provider. A provider is an abstraction for connection to a blockchain, in this case for Ethereum. MetaMask injects a global API into websites withwindow.ethereum. This API allows websites to request users' Ethereum accounts, read data from blockchains the user is connected to, and so on. Ethers.js wrapping this API in its Provider API. I know, what you are thinking...

From the Provider we get the Signer and then we can create the Contract (have a look at the picture above for the Ethers.js Terminology).
In order to create the contract we need to pass in the ABI as second parameter. The ABI is a JSON file defining our smart contract functionality and how it needs to be called (meaning the parameters of each function). Because the starter template is a monorepo we can easily import the VoteManager ABI from the artifacts/contracts directory. Thats it, our contract abstraction is created and we return it to the App.tsx where its used to call the contract.

Creating the candidate

We need a form with an input for the candidates name and a input for the candidates image.
I have used mui for the frontend part but feel free to rewrite to your needs

<Container  maxWidth="md" sx={ marginY: "2rem" }>
    <Box component="form">
        <Stack  direction="row"  alignItems="center"  spacing={2}  mb={4}>
            <TextField  id="filled-basic"       
            label="Name"  variant="filled"
                name="name"
                value={candidateFormData.name}   
                onChange={handleChange}  />
                <label  htmlFor="contained-button-file">
                <input type="file" accept="image/*" onChange={(e) => setSelectedImage(e.target?.files[0])} />
            </label>

            <Button  variant="contained"  component="span" 
             onClick={() => registerCandidate()}>    
                Register as Candidate  
            </Button>
        </Stack>
    </Box>
</Container>
Enter fullscreen mode Exit fullscreen mode

enter image description here
Nothing special in here, lets head over register to the candidate logic, where the magic will happen

async function registerCandidate() {
    // get the name from formdata
    const name = candidateFormData.name; 

    // getting the IPFS Image Hash from the Pinata API Service
    const ipfsImageHash = await IPFSUploadHandler() 

   // call the VoteManager registerCandidate Contract Function
    contract.registerCandidate(name, ipfsImageHash); 

    // response from the contract / the candidateCreated Event
    contract.on("candidateCreated", async  function (evt) {
        getAllCandidates()
    })
}
Enter fullscreen mode Exit fullscreen mode

First we get the name of the first input. Second, we call the Pinata IPFS API with our image to get the IPFS Image Hash of this picture.

Have a look at the GitHup Repository https://github.com/XamHans/image-contest in the services folder to gain more insights about the the IPFSUploadHandler and the Pinata API function call.
If you need more infos about IPFS check my slides about IPFS https://drive.google.com/drive/folders/11qKP4BydqOytD5ZCn7W9pMSi1XiU5hj7?usp=sharing

Then we will use the contract variable (that we have set in the useEffect with the helper function) to call the registerCandidate function.
With on we subscribe to events that are triggered from the contract.

emit candidateCreated(_address, _name)

contract.on("candidateCreated", async  function (event) {
            getAllCandidates()
        })
Enter fullscreen mode Exit fullscreen mode

the first paramter is the name of the event, the second the handler function. If we recieve the event, we will call the getAllCAndidates() function to get all candidates, including the newest that we have just created :)

Get all candidates

async  function getAllCandidates() {  
    const retrievedCandidates = await contract.fetchCandidates();
    const tempArray = [] 

    retrievedCandidates.forEach(candidate => {
        tempArray.push({    
        id: candidate.id, 
        name: candidate.name, 
        totalVote: candidate.totalVote, 
        imageHash: candidate.imageHash,
        candidateAddress: candidate.candidateAddress 
        })
    }) 
    setCandidates(tempArray)
}
Enter fullscreen mode Exit fullscreen mode

Pretty straight forwards, we call the fetchCandidates function from the contract, the response looks like this:
Respsonse of getAllCandidates Function
Wee see that we get the properties double, I have no clue why. If you know why, please let me know !
We create a temporary array , iterate over the response and fill the temporary Array with the candidates objects. Finally we assign the candidates state variable with the tempArray.
Let's show the candidates with their images, therefore paste this below the register candidate part.

 {candidates.length > 0 && (<Container  sx={ bgcolor: "#F0F3F7" }>
    <Box  sx={ flexGrow: 1, paddingY: "3rem", paddingX: "2rem" }}>
        <Grid  container  spacing={ xs: 2, md: 3 }  columns={ xs: 4, sm: 8, md: 12 }>  
        { 
        candidates.map((candidate, index) =>
            <Grid  item  sm={4}  key={index}>
               <Card>
                    <CardMedia  component="img"  image={candidate.imageHash     alt="candidate image"     />
                    <CardContent>
                        <Typography  gutterBottom  component="div">
                            Total votes: {(candidate.totalVote as BigNumber).toNumber()} 
                        </Typography>
                        <Typography  variant="body2"  color="text.secondary">
                            {candidate.name}
                        </Typography> 
                        <Typography  variant="body2"  color="text.secondary">  
                            {candidate.candidateAddress}
                        </Typography>
                    </CardContent>
                    <CardActions  disableSpacing  sx={paddingTop: "0"}>
                        <IconButton  aria-label="like picture"  sx={bgcolor: 'info.contrastText', color: 'info.main'}
                        onClick={() => vote(candidate.candidateAddress)}>
                        <FavoriteIcon/>
                        </IconButton>
                    </CardActions>
                    </Card>
            </Grid>)
            }
        </Grid>
    </Box>
    )}
Enter fullscreen mode Exit fullscreen mode

Candidates card

We are almost done ! Whats missing is vote functionality.

function vote(address: string) {
    if (!address) {   
        throw Error("no address defined")
    }
    contract.vote(address);
    contract.on("Voted", function (event) {
        getAllCandidates()
    })
}
Enter fullscreen mode Exit fullscreen mode

This one is simple. In our iteration over the candidates we have the like button:

onClick={() => vote(candidate.candidateAddress)}>
Enter fullscreen mode Exit fullscreen mode

So we pass in the address of the candidate to this function and then we check if the address is not null. Afterwards we call the vote() function of contract with the candidates address.
If the vote is done, we will listen to the "Voted" event and then, for the sake of simplicity, we fetch all the Candidates again to show the updates value(s).

This way is cleaner to register the event handlers because it happens
only if the contract is changing instead of every function call

useEffect(() => {
  if (contract) {
  contract.on("Voted", async  function () {
      getAllCandidates()
  })
  contract.on("candidateCreated", async  function () {
  getAllCandidates()
  })
}}, [contract])

Congratulation, your first dApp is ready

You did it, do you feel the power ?


We covered a lot of topics in this tutorial.
You now know

  • the memory types calldata, memory and storage of solidity
  • what openzeppelin is and how to import their contracts
  • use require as an early out criterium to be code and gas efficient
  • how to store images on IPFS with the help of Pinata service.
  • that you can send events from your smart contract as a kind of response to your frontend.
  • that the ABI defines your smart contract and that you can use ethers.js to interact with the ethereum blockchain

Thanks for reading

If you like this kind of content or have any questions (I am not an expert) let's connect on twitter or linkedin
https://twitter.com/XamHans |
https://www.linkedin.com/in/johannes-m%C3%BCller-6b8ba1198/

PS

Do you want a job in web3?
Check out my newest project ➡️ https://www.newdevsontheblock.com/

Discussion (5)

Collapse
jdnichollsc profile image
J.D Nicholls

There are some great hooks to connect metamask from a React project:

Thanks for sharing! <3

Collapse
xamhans profile image
xamhans Author

I will definitely check this out ! Thank you so much 🥰
Have a nice Sunday

Collapse
sharypovandrey profile image
Andrey Sharypov • Edited on

Hi Johannes, thank you for this amazing article!
I've been trying to import my key to the Metamask, but I didn't find an option to set a localhost net. Could you explain please how you did it?

Collapse
xamhans profile image
xamhans Author

Hi Andrey,
Thank you 😊
Yeah you first need to define the local network in metamask. @afozbek Abdullah is explaining this very good in his article
dev.to/afozbek/how-to-add-custom-n...

Collapse
sharypovandrey profile image
Andrey Sharypov

thank you for the help, now I got success to launch project🤓