14 min read

App - ToDo List for Web3

Introduction

ToDo List Smart Contract

If you're looking to learn more about building decentralized applications (dApps) on the blockchain, a great place to start is by building a todo list app. A todo list is a simple application that allows users to create tasks, mark them as completed, and view the tasks they have yet to finish.

By building a todo list app on the blockchain, we can take advantage of the decentralized nature of the technology to create an app that is secure, transparent, and accessible to anyone with an internet connection.

In this blog post, we will walk you through the process of building a smart contract todo list app using Solidity 0.8.16. We will explain the basics of Solidity and show you how to create a smart contract that can store and manage todo items. We will also cover how to interact with the smart contract using a web3-enabled front-end interface, so that users can create, view, and complete tasks on the blockchain. By the end of this tutorial, you will have a solid understanding of how to build a dApp on the blockchain and will begin to be well-equipped to start building your own.

Project Setup

Hardhat is a development environment for building, testing, and deploying smart contracts. In this tutorial, we will be exploring how to set up a Hardhat development environment for building a ToDo List smart contract DApp using Solidity 0.8.16.

Prerequisites:

  • Basic knowledge of Solidity
  • Basic knowledge of TypeScript
  • Basic knowledge of React
  • Node.js and npm installed on your machine

Install Hardhat

Open a terminal window and in your desired location, create a new directory for the project.

mkdir todo-app

Them enter the directory:

cd todo-list-dapp

Next, install Hardhat, open a terminal window and run the following command:

npm install --save-dev hardhat

This will install Hardhat and add it to your project's devDependencies.

Initialize Hardhat

Next, run the following command to initialize Hardhat in your project:

npx hardhat

Choose yes for the following

  1. Create a Javascript project.
  2. Do you want to add a .gitignore?
  3. Do you want to install this sample project's dependencies with npm (@nomicfoundation/hardhat-toolbox)?

This will create a new hardhat.config.js file in your project's root directory

Install Required Dependencies

To build our ToDo List DApp, we will need to install the following dependencies:

  • ethers.js: A JavaScript library for interacting with Ethereum
  • dotenv: A zero-dependency module for loading environment variables
  • react: A JavaScript library for building user interfaces

To install these dependencies, run the following command:

npm install ethers@5.7.2 dotenv

We will be using Solidity 0.8.16. In order to do so the hardhat.config.js file must have the correct Solidity compiler version.

require("@nomicfoundation/hardhat-toolbox");

/** @type import('hardhat/config').HardhatUserConfig */
module.exports = {
	solidity: "0.8.16",
};

Back End Code

Now let's set up our back end code by writing our smart contract and tests.

Write the Smart Contract

Hardhat automatically generates Lock.sol as a default smart contract, which you can delete.

rm contracts/Lock.sol

Create a new file called ToDoList.sol in the contracts directory:

touch contracts/ToDoList.sol

Add the following code to ToDoList.sol:

// SPDX-License-Identifier: MIT
pragma solidity 0.8.16;

contract ToDoList {
    
    // Structure to store each ToDo item
    struct Task {
        string description; // The description of the task
        bool isCompleted; // Whether the task is completed or not
    }
    
    // Array to store all ToDo items
    Task[] public tasks;
    
    // Function to add a new ToDo item
    function addTask(string memory _description) public {
        tasks.push(Task(_description, false)); // Add a new task to the array with the given description and a default value of false for isCompleted
    }
    
    // Function to toggle the isCompleted state of a ToDo item
    function toggleTaskCompleted(uint _index) public {
        Task storage task = tasks[_index]; // Get the task at the given index from the array
        task.isCompleted = !task.isCompleted; // Toggle the value of isCompleted for the selected task
    }
    
    // Function to get the total number of ToDo items
    function getTaskCount() public view returns (uint) {
        return tasks.length; // Return the length of the tasks array, which represents the total number of tasks
    }
}

Let's go through the code with comments to explain each line:

// SPDX-License-Identifier: MIT
pragma solidity ^0.8.0;

contract ToDoList {
	//..
}

This is the start of the contract definition. We specify the license and compiler version at the top, and then define the contract itself with the name ToDoList.

struct Task {
	string description; // The description of the task
	bool isCompleted; // Whether the task is completed or not
}

This defines a Task struct that contains a description string and a isCompleted boolean flag.

// Array to store all ToDo items
Task[] public tasks;

This creates a public array tasks to store all the Task items.

function addTask(string memory _description) public {
    tasks.push(Task(_description, false));
}

This is the addTask function that takes in a string _description and creates a new Task item with the given description and a default isCompleted value of false. It then pushes this new Task item onto the end of the tasks array.

function toggleTaskCompleted(uint _index) public {
    Task storage task = tasks[_index];
    task.isCompleted = !task.isCompleted;
}

This is the toggleTaskCompleted function that takes in a uint _index and toggles the isCompleted flag of the Task item at the given index in the tasks array.

function getTaskCount() public view returns (uint) {
    return tasks.length;
}

This is the getTaskCount function that returns the total number of Task items in the tasks array.

Overall, this smart contract defines a simple ToDo list with the ability to add new items, mark items as completed, and retrieve the total number of items.

Write the Hardhat Tests

Now that we have our smart contract written, we need to write tests to ensure that it works correctly. The test code below is a set of JavaScript tests that use the Mocha testing framework, the Chai assertion library to test the functionality of the ToDoList smart contract.

First, navigate to the directory root and delete the default test for the Lock.sol contract called Lock.js.

rm test/Lock.js

Create a new file called ToDoList.test.js in the test/ directory

touch test/ToDoList.js

Then add the following code:

const { expect } = require("chai");    // assertion library for MochaJS
const { ethers } = require("hardhat"); // found in hardhat

describe("ToDoList", function () {
  let toDoList;

  beforeEach(async () => {
    // Deploy ToDoList contract
    const ToDoList = await ethers.getContractFactory("ToDoList");
    toDoList = await ToDoList.deploy();
    await toDoList.deployed();

    // Add some tasks to the list
    await toDoList.addTask("Task 1");
    await toDoList.addTask("Task 2");
    await toDoList.addTask("Task 3");
  });

  it("Should add a new task to the list", async function () {
    // Add a new task to the list
    await toDoList.addTask("Task 4");

    // Get the total number of tasks
    const taskCount = await toDoList.getTaskCount();

    // Assert that the task count is now 4
    expect(taskCount).to.equal(4);
  });

  it("Should toggle the isCompleted state of a task", async function () {
    // Toggle the isCompleted state of the second task
    await toDoList.toggleTaskCompleted(1);

    // Get the second task from the list
    const task = await toDoList.tasks(1);

    // Assert that the isCompleted state of the second task is now true
    expect(task.isCompleted).to.equal(true);
  });
});

The above code is our testing for our simple ToDo list app.

describe("ToDoList", function () {
  let toDoList;
  //..
}

The describe function is used to group together related tests. In this case, the tests are all related to the ToDoList contract.

  beforeEach(async () => {
    // Deploy ToDoList contract
    const ToDoList = await ethers.getContractFactory("ToDoList");
    toDoList = await ToDoList.deploy();
    await toDoList.deployed();

    // Add some tasks to the list
    await toDoList.addTask("Task 1");
    await toDoList.addTask("Task 2");
    await toDoList.addTask("Task 3");
  });

The beforeEach function is used to run a block of code before each test in the suite. In this case, it deploys a new instance of the ToDoList contract and adds three tasks to the list.

//..
expect(taskCount).to.equal(4);
//..

The expect function from the Chai library is used to make assertions about the values returned by the contract functions. The ethers library is used to interact with the contract instance and call its functions.

  it("Should add a new task to the list", async function () {
    // Add a new task to the list
    await toDoList.addTask("Task 4");

    // Get the total number of tasks
    const taskCount = await toDoList.getTaskCount();

    // Assert that the task count is now 4
    expect(taskCount).to.equal(4);
  });

The first test, which is wrapped in an it block, checks that a new task can be added to the list. It does this by calling the addTask function on the toDoList instance and passing in a new task description. It then gets the total number of tasks using the getTaskCount function and asserts that the count is now equal to 4.

  it("Should toggle the isCompleted state of a task", async function () {
    // Toggle the isCompleted state of the second task
    await toDoList.toggleTaskCompleted(1);

    // Get the second task from the list
    const task = await toDoList.tasks(1);

    // Assert that the isCompleted state of the second task is now true
    expect(task.isCompleted).to.equal(true);
  });

The second test checks that the toggleTaskCompleted function works as expected. It calls the function with an index of 1 to toggle the completed state of the second task in the list. It then gets the second task from the list and asserts that the completed state is now true.

Overall, this test suite provides a comprehensive set of tests to ensure that the ToDoList contract functions as expected.

Run the Tests

After defining the ToDoList contract and creating a test file for it, we can continue by running the tests with Hardhat.

To run the tests, we need to use the npx hardhat test command in the terminal.

npx hardhat test

This command will compile the contracts and run the tests defined in the test file. If all the tests pass, you should see output similar to the following:

  ToDoList
    ✓ Should add a new task to the list (1055ms)
    ✓ Should toggle the isCompleted state of a task (1122ms)


  2 passing (2s)

If any of the tests fail, you'll see an error message indicating which test(s) failed and why.

Now that we've successfully tested the ToDoList contract, we can move on to building the front-end of our dApp using React and Ethers.js.

Build the Front-End with React and Ethers.js

We'll start by creating a new React app using create-react-app. We will create the front end inside the project root.

npx create-react-app todo-list-ui

This command will create a new React app in a directory called todo-list-dapp.
When prompted, select yes to the question: Need to install the following packages: create-react-app@5.0.1

Once the app is created, we need to install the necessary dependencies:

cd todo-list-ui
npm install @nomiclabs/hardhat-ethers dotenv

We'll be using Ethers.js to interact with our ToDoList contract, and Hardhat to deploy and test the contract. The dotenv package is used for managing environment variables.

Next, in hardhat.config.js, located the root directory of our app, add the following code

require("dotenv").config();

const { PROJECT_ID, ROPSTEN_PRIVATE_KEY } = process.env;

module.exports = {
  networks: {
    ropsten: {
      url: `https://ropsten.infura.io/v3/${PROJECT_ID}`,
    },
  },
  solidity: "0.8.16",
};

This configuration sets up the Ropsten test network using your chosen Ethereum Node infrastructure provider as the provider.

We need to set up a few environment variables for this to work. In the root directory of our app, create a new file called .env:

touch .env

This file should contain the following:

PROJECT_ID=<your Ethereum infrastructure provider project ID>
TODO_LIST_ADDRESS=<your ToDoList deployed address> 
WALLET_SECRET_PHRASE=<your test wallet seed phrase> 

Replace <your Ethereum infrastructure provider project ID> with your own values. You can create a project from your favorite node provider and get a Goerli testnet account from a faucet.

Next, open a .gitignore file to hide your .env file secrets by preventing them from being committed to Github publicly. Hardhat created one as part of its setup process.

touch .gitignore

Then, in addition to the text already in .gitignore copy and paste the following to your .gitignore file from this file. Within this file .env is specified to not be committed.

Finally, we'll create a new file called src/ethersConnect.js. This file will contain the code needed to connect to the ToDoList contract using Ethers.js. Ethers.js is a popular JavaScript library used for interacting with Ethereum and other Ethereum-compatible blockchains.

import { ethers } from 'ethers';
import ToDoList from '../artifacts/contracts/ToDoList.sol/ToDoList.json';

const connectToContract = async () => {
  // Check if MetaMask is installed
  if (typeof window.ethereum !== 'undefined') {
    // Create a new provider using the Infura endpoint and current network ID
    const provider = new ethers.providers.InfuraProvider(
      process.env.NETWORK,   // double check
      process.env.PROJECT_ID // double check
    );

    // Request access to the user's MetaMask wallet
    await window.ethereum.request({ method: 'eth_requestAccounts' });

    // Create a new signer using the current provider and user's MetaMask account
    const signer = new ethers.providers.Web3Provider(window.ethereum).getSigner();

    // Create a new instance of the ToDoList contract using the current signer and contract address
    const contract = new ethers.Contract(
      process.env.TODO_LIST_ADDRESS,
      ToDoList.abi,
      signer
    );

    return contract;
  } else {
    console.log('Please install MetaMask');
  }
};

export default connectToContract;

The ethersConnect.js file imports the ethers library, as well as the ToDoList contract ABI from the artifacts folder. It then defines a function called connectToContract which does the following:

  1. Checks if the user has MetaMask installed
  2. Creates a new provider using the Infura endpoint and network ID specified in the .env file
  3. Requests access to the user's MetaMask wallet
  4. Creates a new signer using the current provider and user's MetaMask account
  5. Creates a new instance of the ToDoList contract using the current signer and contract address specified in the .env file

The function returns the instance of the ToDoList contract, which can then be used to interact with the smart contract on the Ethereum network.

In the next section, we'll create the React components needed to render the UI for the ToDo list dApp.

Building the Front End UI

React Components

We'll create four React components for our dApp:

  1. TaskForm: A form to add new tasks to the list
  2. TaskList: A list of all the tasks
  3. TaskItem: A single task item
  4. App: The main component that renders the TaskForm and TaskList components

Create a new folder called src/components and create four new files inside: TaskForm.jsTaskList.jsTaskItem.js, and App.js.

TaskForm Component

The TaskForm component will contain a form to add new tasks to the list. Here's the code for TaskForm.js:

import React, { useState } from 'react';

function TaskForm(props) {
  const [inputValue, setInputValue] = useState('');

  const handleSubmit = async (event) => {
    event.preventDefault();

    // Call the addTask function in the smart contract
    await props.contract.addTask(inputValue);

    // Clear the input field
    setInputValue('');
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        type="text"
        value={inputValue}
        onChange={(event) => setInputValue(event.target.value)}
        placeholder="Enter a new task"
      />
      <button type="submit">Add Task</button>
    </form>
  );
}

export default TaskForm;

This component uses the useState hook to manage the state of the input field. It also calls the addTask function in the smart contract when the form is submitted.

TaskList Component

The TaskList component will render a list of all the tasks. Here's the code for TaskList.js:

import React from 'react';
import TaskItem from './TaskItem';

function TaskList(props) {
  return (
    <div>
      {props.tasks.map((task, index) => (
        <TaskItem
          key={index}
          task={task}
          index={index}
          contract={props.contract}
        />
      ))}
    </div>
  );
}

export default TaskList;

This component maps over the tasks array passed as a prop and renders a TaskItem component for each task.

TaskItem Component

The TaskItem component will render a single task item. It will also contain a checkbox to toggle the completion status of the task. Here's the code for TaskItem.js:

import React from 'react';

function TaskItem(props) {
  const handleToggle = async () => {
    // Call the toggleTaskCompleted function in the smart contract
    await props.contract.toggleTaskCompleted(props.index);
  };

  return (
    <div>
      <input
        type="checkbox"
        checked={props.task.isCompleted}
        onChange={handleToggle}
      />
      <span>{props.task.description}</span>
    </div>
  );
}

export default TaskItem;

This component uses the toggleTaskCompleted function in the smart contract to toggle the completion status of the task.

App Component

The App component is the main component that renders the TaskForm and TaskList components. Here's the code for App.js:

import { useState, useEffect } from "react";
import { ethers } from "ethers";
import "./App.css";
import TaskForm from "./components/TaskForm";
import TaskList from "./components/TaskList";
import { CONTRACT_ADDRESS } from "./config";
import { TodoListABI } from "./abi/TodoListABI";

require("dotenv").config();

function App() {
  // State variables for tasks and contract
  const [tasks, setTasks] = useState([]);
  const [contract, setContract] = useState();

  // Function to load tasks from contract
  const loadTasks = async () => {
    // Check if the contract is set
    if (contract) {
      // Call smart contract method to get total number of tasks
      const taskCount = await contract.getTaskCount();

      // Loop through tasks and get description and completion status for each task
      const newTasks = [];
      for (let i = 0; i < taskCount; i++) {
        const task = await contract.tasks(i);
        newTasks.push({
          id: i,
          description: task.description,
          isCompleted: task.isCompleted,
        });
      }
      setTasks(newTasks);
    }
  };

  // Function to toggle completion status of a task
  const toggleCompletion = async (id) => {
    // Check if the contract is set
    if (contract) {
      // Call smart contract method to toggle completion status of the selected task
      await contract.toggleTaskCompleted(id);

      // Reload tasks from the contract
      loadTasks();
    }
  };

  // Function to remove a task from the list
  const removeTask = async (id) => {
    // Check if the contract is set
    if (contract) {
      // Call smart contract method to remove the selected task
      await contract.removeTask(id);

      // Reload tasks from the contract
      loadTasks();
    }
  };

  // Effect hook to initialize the contract and load tasks from the contract on component mount
  useEffect(() => {
    // Connect to Ethereum network with MetaMask
    const connectToEthereum = async () => {
      // Check if MetaMask is installed
      if (window.ethereum) {
        // Enable MetaMask and get user account
        await window.ethereum.enable();
        const provider = new ethers.providers.Web3Provider(window.ethereum);
        const signer = provider.getSigner();

        // Load contract interface and initialize contract instance
        const contract = new ethers.Contract(
          CONTRACT_ADDRESS,
          TodoListABI,
          signer
        );
        setContract(contract);

        // Load tasks from the contract
        loadTasks();
      } else {
        alert("Please install MetaMask to use this dApp");
      }
    };
    connectToEthereum();
  }, []);

  // Effect hook to listen for changes in MetaMask account and reload tasks from the contract
  useEffect(() => {
    // Listen for changes in MetaMask account
    const handleAccountsChanged = async (accounts) => {
      // Reload tasks from the contract
      loadTasks();
    };
    window.ethereum.on("accountsChanged", handleAccountsChanged);

    // Cleanup function to remove event listener
    return () => {
      window.ethereum.off("accountsChanged", handleAccountsChanged);
    };
  }, []);

return (
    <div className="App">
      <h1>To-Do List</h1>
      <TaskForm contract={contract} loadTasks={loadTasks} />
      <TaskList
        tasks={tasks}
        toggleCompletion={toggleCompletion}
        removeTask={removeTask}
      />
    </div>
  );

This is the App component of a React-based dApp that displays a ToDo list. Here's a breakdown of the code:

  • The useState hook is used to declare state variables tasks and contracttasks will store an array of ToDo items, while contract will hold the instance of the deployed smart contract.
  • The loadTasks function loads the list of tasks from the smart contract. It checks if the contract instance is available, calls the getTaskCount method to get the total number of tasks, and then loops through the tasks to get their description and completion status.
  • The toggleCompletion function toggles the completion status of a task. It calls the toggleTaskCompleted method of the smart contract to update the completion status of a specific task, and then reloads the task list using the loadTasks function.
  • The removeTask function removes a task from the list. It calls the removeTask method of the smart contract to remove the task at the specified index, and then reloads the task list using the loadTasks function.
  • The useEffect hook is used twice to set up and manage the component's side effects. The first hook initializes the contract and loads the task list from the contract when the component mounts. The second hook listens for changes in the user's MetaMask account and reloads the task list from the contract if the account changes.
  • The return statement renders the UI components, including the TaskForm component for adding new tasks, the TaskList component for displaying the list of tasks, and a header for the app.

The code also integrates with MetaMask by connecting to the Ethereum network, enabling MetaMask, getting the user's account, and using the account to sign transactions with the smart contract. This is done in the connectToEthereum function called in the first useEffect hook.

Finally, the require("dotenv").config(); statement at the top of the file imports and uses the dotenv package for managing environment variables like the contract address.

Conclusion

In conclusion, we have created a ToDo list app using Solidity, React, and Ethers.js. We started by writing the smart contract code to handle adding tasks, toggling their completion status, and getting the total number of tasks. We then wrote unit tests for the smart contract using Mocha, Chai, and Hardhat.

Next, we set up a React app and used Ethers.js to connect to the smart contract on the Ethereum network. We built a user interface that allowed users to add tasks to the list, toggle their completion status, and display the current list of tasks.

We also implemented a method for checking off tasks that are done using event listeners for changes in the to-do item status. We integrated MetaMask for user authentication and made the app more secure by using dotenv for secret management.

Overall, this tutorial provides a solid foundation for building decentralized apps using Ethereum, Solidity, React, and Ethers.js. With these tools, developers can create a wide variety of decentralized apps that are secure, efficient, and highly functional.

Feedback

Have feedback? Found something that needs to be updated, accurate, or explained?

Join our discord and share what can be improved.

Any and all feedback is welcomed.