Build a To-Do List App with React Hooks, Tailwind, and localStorage (React Beginner Projects Part 2)

Uci Lasmana
25 min readJul 24, 2023

--

In this tutorial, we will use React Hooks (useState, useEffect, useContext, and useReducer) for handling logic, Tailwind CSS for styling the components, and localStorage for saving data. Here, we‘ll focus more on how to use useContext and useReducer hooks.

If you want to learn more about how to use useState and useEffect, you can visit the previous part of the Beginner Projects tutorial. There, you will learn how to use useState, useEffect and useRef by building a Tic-Tac-Toe Game.

However, I recommend that you first learn from this guide, React Hooks: A Companion Guide for React Beginner Projects. This guide will help you gain basic knowledge of React Hooks.

Alright, let’s get started!

To-Do List App

As you can see, the image below is the user interface of the to-do list app that we will build in this tutorial.

You can check the app by visiting this link, https://ucilasmana.github.io/to-do-list

From the UI above we can get into details about what kind of functions we need to build:

  1. To create a list, we need an input element to write what we want to do and a calendar to add the date when we are going to do it. So, the date can’t be a past date, it should be either today's date or an upcoming date.
  2. Since today’s plans are more important, we need to separate them from the upcoming plans. So, we’re going to have two lists, “Today” and “Upcoming”.
  3. When we complete a plan, it will be crossed off the list. Then the number of completed plans will increase.
  4. If we want to change or remove a plan we have already created, we can hover over the plan to display the edit and the delete buttons. However, this does not apply to plans that have been crossed out.
  5. Clicking the edit button will open a modal with an input element and a calendar, while clicking the delete button will open a modal to confirm the plan’s removal.

However, before we start building the app, let’s explore localStorage first.

Introduction to localStorage

localStorage is an object from the Web Storage API, along with sessionStorage. The Web Storage API is used for storing and retrieving data from the user’s browser. The data stored in localStorage doesn’t expire and persists even when the browser is closed, unless the user clears it manually. localStorage can only store strings, so objects or arrays need to be converted to JSON format before storing them.

localStorage is different from cookies, which have expiration dates and are sent to the server with every request. localStorage can store up to 5MB while the cookies only up to 4KB. localStorage is also different from sessionStorage, which only lasts for one browser session and is cleared when the tab or window is closed.

Here are the key methods for storage object:

  1. setItem(key, value): This method allows you to add a new data item to the storage or update an existing one. The key parameter is the name of the item, while the value is the data you want to store.
  2. getItem(key): This method retrieves the data item with the specified key. If the item does not exist, it returns null.
  3. removeItem(key): This method removes the data item with the specified key.
  4. clear(): This method removes all data items from the storage.
  5. key(index): This method, when passed a number n, returns the name of the nth key in the storage.

Let’s get to work now!

A React Project

If you don’t have a React project to build this app, you can create it first.

npx create-react-app my-app-name

Wait for the whole process. When it’s done, start the project. You can see the project live on localhost:3000.

In case you got the warning below when you run npm start:

“One of your dependencies, babel-preset-react-app, is importing the @babel/plugin-proposal-private-property-in-object package without declaring it in its dependencies”

You can fix it by installing this plugin:

npm install --save-dev @babel/plugin-proposal-private-property-in-object

Install Tailwind CSS

Unlike the previous tutorial, we’re going to use the Tailwind CSS framework to help us write CSS code faster.

npm install -D tailwindcss

After we install the framework, run this command to generate a tailwind.config.js file.

npx tailwindcss init

We’re going to use the tailwind.config.js file to add additional information such as the paths to all of our components and any CSS rules that Tailwind CSS doesn’t provide. Open the file and copy the code below:

/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {},
},
plugins: [],
}

Open the index.css file, then add the @tailwind directives for each of Tailwind’s layers.

@tailwind base;
@tailwind components;
@tailwind utilities;

Next, run the command below and wait for the whole process to complete. After it’s done, we can start using Tailwind’s utility classes to style our component.

npm run start

Install React DatePicker

React DatePicker is a date picker component. We’re going to use this component to display a calendar. Run the command below to install the package:

npm install react-datepicker --save

With this component you can set dates in different languages and formats, set date range, highlight dates, filter dates, and even include times in the calendar. You can visit this demo page, React Datepicker to see more how to use this component.

Directory Structure

Open the src folder, then create new JSX and CSS files following directory structure:

Set CSS Rules

Copy these CSS rules provided below, so we can focus on building our logic. However, if you have your own style, feel free to proceed with that.

tailwind.config.js

/** @type {import('tailwindcss').Config} */
module.exports = {
content: [
"./src/**/*.{js,jsx,ts,tsx}",
],
theme: {
extend: {
fontFamily: {
rem:'REM',
borel: 'Borel'
},
},
},
plugins: [],
}

index.css

@tailwind base;
@tailwind components;
@tailwind utilities;

@import url('https://fonts.googleapis.com/css2?family=Borel&family=REM:wght@200;300;500;600;800&display=swap');

input[type=checkbox]:checked ~ .checkmark {
@apply bg-red-500
}
input[type=checkbox]:checked ~ .checkmark:after {
@apply block
}
.checkmark:after {
@apply content-[''] absolute hidden left-1 top-0.5 w-[7px] h-2.5 border-r-[3px] border-b-[3px] border-white rotate-45
}

html{
@apply scroll-smooth h-full
}

body {
@apply box-border m-0 p-0 h-full antialiased
}

::-webkit-scrollbar {
@apply w-[3px]
}
::-webkit-scrollbar-track {
@apply bg-gray-400
}
::-webkit-scrollbar-thumb {
@apply bg-red-600 rounded
}

::-webkit-scrollbar-thumb:hover {
@apply bg-gray-600
}

We use @apply to incorporate any existing utility classes into our custom CSS. This can be used to override the default styles of elements, as we did in index.css file. We can also use this method to override the styles in a third-party library, as we plan to do in calendar.css file. The CSS rules in the calendar.css file are from react-datepicker/dist/react-datepicker.css.

calendar.css



react-datepicker__month-year-read-view--down-arrow, .react-datepicker__navigation-icon::before {
@apply border-red-600
}

.react-datepicker__header {
@apply border-orange-400 bg-orange-200 pt-3

}
.react-datepicker {
@apply font-borel border-orange-400 bg-gray-50 text-red-600
}

.react-datepicker__day:hover,
.react-datepicker__month-text:hover,
.react-datepicker__quarter-text:hover,
.react-datepicker__year-text:hover {
@apply bg-red-600 text-white
}

.react-datepicker__day-name,
.react-datepicker__day,
.react-datepicker__time-name {
@apply pt-2.5 text-red-600 leading-4
}
.react-datepicker__current-month,
.react-datepicker-time__header,
.react-datepicker-year-header {
@apply text-red-600
}
.react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::before, .react-datepicker-popper[data-placement^=bottom] .react-datepicker__triangle::after {
@apply border-t-0 border-b-orange-600/75
}
.react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::before, .react-datepicker-popper[data-placement^=top] .react-datepicker__triangle::after {
@apply border-b-0 border-t-orange-600/75
}
.react-datepicker__day--selected, .react-datepicker__day--in-selecting-range, .react-datepicker__day--in-range,
.react-datepicker__month-text--selected,
.react-datepicker__month-text--in-selecting-range,
.react-datepicker__month-text--in-range,
.react-datepicker__quarter-text--selected,
.react-datepicker__quarter-text--in-selecting-range,
.react-datepicker__quarter-text--in-range,
.react-datepicker__year-text--selected,
.react-datepicker__year-text--in-selecting-range,
.react-datepicker__year-text--in-range {
@apply bg-red-600 text-white
}

.react-datepicker__day--disabled,
.react-datepicker__month-text--disabled,
.react-datepicker__quarter-text--disabled,
.react-datepicker__year-text--disabled {
cursor: default;
color: #ccc;
}
.react-datepicker__day--disabled:hover,
.react-datepicker__month-text--disabled:hover,
.react-datepicker__quarter-text--disabled:hover,
.react-datepicker__year-text--disabled:hover {
background-color: transparent;
color: #ccc;
}

SVG

We’re going to use SVG icons for calendar, empty list, edit, delete, and close buttons.

svg.jsx

import * as React from "react"

export const NoPlan = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
xmlSpace="preserve"
className={props.className}
viewBox="0 0 458.018 458.018"

>
<path d="M307.631 425.737h.002a2.817 2.817 0 0 1-2.814 2.813H36.111a2.817 2.817 0 0 1-2.814-2.813V32.282a2.817 2.817 0 0 1 2.814-2.814h268.708a2.817 2.817 0 0 1 2.814 2.814v27.411l29.442-28.412C336.543 13.943 322.283 0 304.819 0H36.111C18.311 0 3.829 14.481 3.829 32.282v393.455c0 17.799 14.481 32.281 32.281 32.281h268.708c17.8 0 32.281-14.481 32.281-32.281V287.234l-29.468 29.467v109.036z" />
<path d="M55.319 345.509c0 8.137 6.597 14.734 14.734 14.734h51.527a43.932 43.932 0 0 1-6.32-29.467H70.053v-.001c-8.137 0-14.734 6.597-14.734 14.734zM131.134 256.828H70.053c-8.137 0-14.734 6.597-14.734 14.734s6.597 14.734 14.734 14.734h54.697l6.384-29.468zM184.444 182.882H70.053c-8.137 0-14.734 6.597-14.734 14.734s6.597 14.734 14.734 14.734h84.923l29.468-29.468zM258.39 108.936H70.053c-8.137 0-14.734 6.597-14.734 14.734s6.597 14.734 14.734 14.734h158.869l29.468-29.468zM436.809 60.304c-24.123-24.836-63.396-24.718-87.457-.657L166.87 242.13a14.836 14.836 0 0 0-3.982 7.299l-18.249 84.244a14.736 14.736 0 0 0 17.52 17.52l84.244-18.249a15.009 15.009 0 0 0 7.299-3.982l182.482-182.483c23.921-23.919 23.881-62.243.625-86.175zM178.283 317.548l7.686-35.482 27.796 27.796-35.482 7.686zm237.064-191.906L243.283 297.706l-45.158-45.159L370.188 80.483c12.872-12.873 33.93-12.445 46.257 1.154 11.313 12.465 11.061 31.846-1.098 44.005z" />
</svg>
)

export const Edit = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className={props.className}
fill="none"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
d="m18.378 8.45-9.414 9.415a2 2 0 0 1-1.022.547L5 19l.588-2.942a2 2 0 0 1 .547-1.022l9.415-9.415m2.828 2.829 1.415-1.414a1 1 0 0 0 0-1.415l-1.415-1.414a1 1 0 0 0-1.414 0L15.55 5.621m2.828 2.829L15.55 5.62"
/>
</svg>
)
export const Delete = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className={props.className}
viewBox="0 0 24 24"
{...props}
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 12v5M14 12v5M4 7h16M6 10v8a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3v-8M9 5a2 2 0 0 1 2-2h2a2 2 0 0 1 2 2v2H9V5Z"
/>
</svg>
)
export const Close = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className={props.className}
viewBox="0 -960 960 960"
{...props}
>
<path d="m249-207-42-42 231-231-231-231 42-42 231 231 231-231 42 42-231 231 231 231-42 42-231-231-231 231Z" />
</svg>
)
export const Calendar = (props) => (
<svg
xmlns="http://www.w3.org/2000/svg"
className={props.className}
viewBox="0 -960 960 960"
{...props}
>
<path d="M180-80q-24 0-42-18t-18-42v-620q0-24 18-42t42-18h65v-60h65v60h340v-60h65v60h65q24 0 42 18t18 42v620q0 24-18 42t-42 18H180Zm0-60h600v-430H180v430Zm0-490h600v-130H180v130Zm0 0v-130 130Zm300 230q-17 0-28.5-11.5T440-440q0-17 11.5-28.5T480-480q17 0 28.5 11.5T520-440q0 17-11.5 28.5T480-400Zm-160 0q-17 0-28.5-11.5T280-440q0-17 11.5-28.5T320-480q17 0 28.5 11.5T360-440q0 17-11.5 28.5T320-400Zm320 0q-17 0-28.5-11.5T600-440q0-17 11.5-28.5T640-480q17 0 28.5 11.5T680-440q0 17-11.5 28.5T640-400ZM480-240q-17 0-28.5-11.5T440-280q0-17 11.5-28.5T480-320q17 0 28.5 11.5T520-280q0 17-11.5 28.5T480-240Zm-160 0q-17 0-28.5-11.5T280-280q0-17 11.5-28.5T320-320q17 0 28.5 11.5T360-280q0 17-11.5 28.5T320-240Zm320 0q-17 0-28.5-11.5T600-280q0-17 11.5-28.5T640-320q17 0 28.5 11.5T680-280q0 17-11.5 28.5T640-240Z" />
</svg>
)

This svg.jsx file contains five react components. We have NoPlan, Edit, Delete, Close and Calendar components here. You can transform an SVG icon into React component using SVGR Playground. All you need to do is copy the SVG code and paste it to SVGR Playground.

Alright, it’s time to build the app!

App.js

Since the App.js is the parent component, we will define our Context and useReducer here.

import { useState, useReducer, createContext, useEffect } from "react";

import Todo from "./components/Todo";
import AddTodo from './components/Actions/AddTodo'
import Modal from './components/Actions/Modal'
import { modalReducer, tasksReducer } from "./reducers";

export const TodoContext = createContext()

const initialTask = []

const initialModal= {
show:false, selectedId:null, modalType:null
}

function App() {

const [tasks, taskDispatch] = useReducer(
tasksReducer, initialTask, ()=>{
const localData = localStorage.getItem('state');
return localData? JSON.parse(localData):initialTask
})

const [modal, modalDispatch] = useReducer(
modalReducer, initialModal)

useEffect(() => {
localStorage.setItem('state', JSON.stringify(tasks));

}, [tasks]);


return (
<div className="App relative min-h-screen lg:overflow-hidden text-red-600 bg-amber-100">
<TodoContext.Provider value={{tasks, modal, taskDispatch, modalDispatch}} >
<AddTodo/>
<Todo/>
{modal.show &&<Modal/>}
</TodoContext.Provider>
</div>
);
}

export default App;

We use useContext to share states between components without passing props. But to use the hook, we need to define and initialize the Context outside the parent component like this:

import { ... createContext} from "react";

export const TodoContext = createContext()

We export the Context because we want to use it in child component files. Here, we have AddTodo, Todo, and Modal as the child components here. We wrap these child components with Context Provider, enabling them to use the context values that we want to share between components. When any of these context values change, all components that used the context will re-render.

...
function App() {
...
<TodoContext.Provider value={{ tasks, modal, taskDispatch, modalDispatch}} >
<AddTodo/>
<Todo/>
{modal.show &&<Modal/>}
</TodoContext.Provider>
...
}

The context values are the states and dispatch functions returned by the useReducer. The states are the variables that hold the data we need for our components, while the dispatch functions are the functions that we use to update the state by sending actions, just like a courier that delivers your request.

import { ...useReducer} from "react";

import { modalReducer, tasksReducer } from "./reducers";

const initialTask = []

const initialModal= {
show:false, selectedId:null, modalType:null
}

function App() {

const [tasks, taskDispatch] = useReducer(
tasksReducer, initialTask, ()=>{
const localData = localStorage.getItem('state');
return localData? JSON.parse(localData):initialTask
})

const [modal, modalDispatch] = useReducer(
modalReducer, initialModal)

useEffect(() => {
localStorage.setItem('state', JSON.stringify(tasks));

}, [tasks]);

...

}

export default App;

From the code above, you can see that we’re using two reducer functions which are tasksReducer and modalReducer.

The taskReducer handles anything related to tasks, such as creating, updating task status, editing, and deleting task data.

Meanwhile, the modalReducer manages anything related to the modal, like opening the modal based on the task id and closing the modal.

This is how we define our useReducer :

const [tasks, taskDispatch] = useReducer(
tasksReducer, initialTask, ()=>{
const localData = localStorage.getItem('state');
return localData? JSON.parse(localData):initialTask
})

const [modal, modalDispatch] = useReducer(
modalReducer, initialModal)

These reducer functions, taskReducer and modalReducer are imported from reducer.js.

reducer.js

A reducer function is used to manage the state and it takes two parameters, state and action. Here, we use switch statement to handle the actions.

export function tasksReducer(state, action) {
switch (action.type)
{
case 'added':{
return[...state,
{
id:action.id,
activity:action.activity,
date:action.date,
status:false
}
]
}
case 'completed':{
return state.map(task=>task.id === action.id? {
...task, status:true}: task)

}
case 'changed': {
return state.map(task=>task.id === action.id? {
...task, activity:action.activity, date:action.date}: task)

}
case 'deleted': {
return state.filter(task=>task.id !== action.id)
}
default:{
return state
}
}
}
export function modalReducer(state, action) {
switch (action.type)
{
case 'openModal': {
return {
show:true,
selectedId:action.id,
modalType:action.modalType
}
}
case 'closeModal':{
return {
show:false,
selectedId:null,
modalType:null
}
}
default:{
return state
}
}
}

The taskReducer function is used to manage the state of tasks. In this function we have four actions:

  • The added case is used to add a new task to the state. It returns a new array with all the existing tasks (…state) and a new task object. The new task object includes an id, activity, date, and status (which is initially set to false).
  • The completed case is used to mark a task as completed. It maps through the state and when it finds a task with an id that matches the action.id, it returns a new object with all the existing properties of the task (…task) and updates the status to true.
  • The changed case is used to change the details of a task. Just like the completed case, it will maps through the state to find the matches id then returns a new object with all the existing properties and updates the activity and date.
  • The deleted case is used to delete a task. It filters the state and returns a new array that does not include the task with an id that matches the action.id.
  • Meanwhile, the default is used for if none of the above cases match the action cases, the function simply returns the current state.

The modalReducer function is used to manage the state of modal. In this function we have two actions:

  • The openModal case is used to open a modal. It returns a new object with properties show set to true, selectedId set to action.id, and modalType set to action.modalType
  • The closeModal case is used to close a modal. It returns a new object with properties show set to false, selectedId set to null, and modalType set to null

And, the default case is used for if none of the above cases match the action cases, the function simply returns the current state.

Now let’s go back to our useReducer.

const [tasks, taskDispatch] = useReducer(
tasksReducer, initialTask, ()=>{
const localData = localStorage.getItem('state');
return localData? JSON.parse(localData):initialTask
})

const [modal, modalDispatch] = useReducer(
modalReducer, initialModal)

Here we have initialTask and initialModal as initial arguments. These initial arguments will be used as initial states during the first render.

const initialTask = []

const initialModal= {
show:false, selectedId:null, modalType:null
}

However, since the useReducer for tasks is using an init function to set up the initial state, it can’t just take the initial argument as an initial state. The initial argument will be passed to the init function, which then return the actual initial state.

()=>{
const localData = localStorage.getItem('state');
return localData? JSON.parse(localData):initialTask
})

Here, we access the localStorage of the user’s browser and retrieve the item with the key state. If the item exists, we will get the item as a string. So, we need to parse this JSON string into a JavaScript object before returning it as an initial state. But if the item doesn’t exist, the function will just return the initialTask as an initial state.

useEffect(() => {
localStorage.setItem('state', JSON.stringify(tasks));

}, [tasks]);

We use useEffect hook to ensure that the current state is saved to localStorage every time the state changes. But to save it, we need to convert the JavaScript object into JSON string.

The reason why we save the value to state before we store it to localStorage is because in React, the state is used to store and track changes to data that affects the UI. When state changes, the component re-renders, allowing the UI to update and reflect the current data. If we update the localStorage directly without updating the state first, the UI wouldn’t re-render and the UI wouldn’t reflect the current data until the next render.

AddTodo.jsx

This component is designed for adding a new task. Here we import the Form component, which contains form elements that handle the actions of adding and changing tasks.

import Form from './Form';
const AddTodo = () => {
return (
<>
<div className="lg:absolute lg:top-0 lg:left-0 bg-amber-50 h-72 lg:h-screen w-full lg:w-1/2 lg:overflow-hidden">
<span className="font-borel absolute top-4 left-4 font-semibold text-xs sm:text-base">To-Do List</span>
<div className="pt-2 px-10 sm:px-6 sm:ml-14 flex w-full sm:w-5/6 flex-col h-72 lg:min-h-screen justify-center gap-5 lg:gap-8 ">
<h3 className="font-rem font-bold min-[360px]:mt-4 text-2xl min-[360px]:text-3xl min-[520px]:text-4xl md:text-5xl">Do you have any plans ?</h3>
<Form/>
</div>
</div>
</>
)
}

export default AddTodo

Todo.jsx

import React, { useContext, useState } from 'react'
import { TodoContext } from '../App'
import TodayList from './Lists/TodayList'
import UpcomingLists from './Lists/UpcomingLists'
import {NoPlan, Edit, Delete} from '../asset/svg'


const Todo = () => {
const {modalDispatch} = useContext(TodoContext);

function handleEditModal(taskID){
modalDispatch({
type:'openModal',
id:taskID,
modalType:'Edit'
})

}

function handleDeleteModal(taskID){
modalDispatch({
type:'openModal',
id:taskID,
modalType:'Delete'
})
}

return (
<>
<div className="flex flex-col lg:flex-row justify-center items-center lg:overflow-hidden lg:max-h-screen">
<div className="lg:absolute lg:top-0 lg:right-0 h-full overflow-auto w-full lg:w-1/2 ">
<div className='flex flex-col gap-10 justify-center pt-12 pb-6'>
<TodayList NoPlan={NoPlan} Edit={Edit} Delete={Delete} editModal={handleEditModal} deleteModal={handleDeleteModal}/>
<UpcomingLists NoPlan={NoPlan} Edit={Edit} Delete={Delete} editModal={handleEditModal} deleteModal={handleDeleteModal}/>
</div>
</div>
</div>
</>
)
}

export default Todo

In this component we use useContext for the first time. We need to import the useContext so we can access the context from parent component like this:

import React, { useContext... } from 'react'
import { TodoContext } from '../App'

const Todo = () => {
const {modalDispatch} = useContext(TodoContext);
...
}
export default Todo

Here we access modalDispatch from TodoContext. We use the modalDispatch to update the modal state.

function handleEditModal(taskID){
modalDispatch({
type:'openModal',
id:taskID,
modalType:'Edit'
})}

function handleDeleteModal(taskID){
modalDispatch({
type:'openModal',
id:taskID,
modalType:'Delete'
})}

We create two functions, handleEditModal and handleDeleteModal to call the modalDispatch with an action object. This action object has three properties, type, id, and modalType. The type property specifies the type of the action that we want to perform. These functions are designed to open the modal for different purposes. You can see it from the modalType property value in each function. And the id is used to determine which data will be displayed in the modal.

...
import TodayList from './Lists/TodayList'
import UpcomingLists from './Lists/UpcomingLists'
import {NoPlan, Edit, Delete} from '../asset/svg'


const Todo = () => {
...
<TodayList NoPlan={NoPlan} Edit={Edit} Delete={Delete} editModal={handleEditModal} deleteModal={handleDeleteModal}/>
<UpcomingLists NoPlan={NoPlan} Edit={Edit} Delete={Delete} editModal={handleEditModal} deleteModal={handleDeleteModal}/>
...
}

The handleEditModal and handleDeleteModal functions are passed as props to TodayList and UpcomingList components because these components require these functions. We also pass the SVG components (NoPlan, Edit, and Delete) as well, since the TodayList and UpcomingList components need them.

Actually, you can just import the SVG components in the TodayList and UpcomingList components. We’re only building a simple app, so we don’t need to worry about a new instance of a component being created each time the app re-render. When re-renders occur, React will compare the new and old virtual DOM and only update the real DOM for the parts that have changed.

In case if you are wondering about virtual DOM, it’s a representation of the UI that is kept in memory and synced with the real DOM. Updating the virtual DOM is much faster than updating the real DOM because there’s nothing to display in the UI. The real DOM will be updated after React compare the difference between the new and old virtual DOM.

So why did I still pass the SVG components as props even knowing all this? Well, I just wanted to write this explanation 😄

TodayList.jsx

import {useContext} from 'react'
import { TodoContext } from '../../App';

const TodayList = ({NoPlan, Edit, Delete, editModal, deleteModal}) => {

const {tasks, taskDispatch} = useContext(TodoContext)
const tasksToday= tasks.filter(task=>new Date(task.date).toLocaleDateString()===new Date().toLocaleDateString())

const incompletedTasks= tasksToday.filter(task=>task.status===false)
const completedTasks= tasksToday.filter(task=>task.status===true)
function handleCompleteTask(taskID){
taskDispatch({
type:'completed',
id:taskID
})
}

return (
<>

<div className="w-full flex flex-col justify-center items-center gap-7">
<h3 className="font-borel text-xl md:text-2xl font-semibold">Today</h3>
<div className="bg-white/90 min-h-24 w-4/5 p-5 rounded-md shadow-sm">
<div className="flex flex-col divide-y divide-red-200">
{incompletedTasks.map(task => (
<div key={task.id} className='relative flex group/actions items-center'>
<label className='relative pl-8 py-4 w-5/6'> {task.activity}
<input type="checkbox" className="appearance-none" value="" onChange={() => handleCompleteTask(task.id)}/>
<span className="checkmark absolute top-5 left-1 w-4 h-4 bg-orange-100/75 ring-1 shadow-sm rounded-sm outline-none ring-red-400 checked:bg-red-500"></span>
</label>
<div className='flex flex-col absolute right-0 my-2 p-0.5 invisible gap-0.5 bg-orange-100 rounded-md group-hover/actions:visible'>
<div className="cursor-pointer hover:bg-orange-400/40 hover:rounded-t-md" onClick={()=>editModal(task.id)}>
<Edit className="h-5 w-5 stroke-red-500 hover:stroke-red-600 stroke-2"/>
</div>
<div className="cursor-pointer hover:bg-orange-400/40 hover:rounded-b-md" onClick={()=>deleteModal(task.id)}>
<Delete className="fill-none h-5 w-5 stroke-red-500 hover:stroke-red-600 stroke-2"/>
</div>
</div>
</div>
)) }
{incompletedTasks.length===0 &&(
<div className="p-4 flex flex-col gap-4 items-center justify-center">
<NoPlan className="h-12 w-12 fill-gray-300" />
<span className='font-borel text-[#c6cbd2]'>No Plans Today</span>
</div>
)}
</div>
{completedTasks.length!==0 &&(
<div className='border-t border-red-200 flex flex-col sm:flex-row gap-2 justify-between items-center py-2 text-xs sm:text-sm'>
<div className='sm:w-4/5 sm:break-words '>
{completedTasks.map(task => (
<label key={task.id} className='line-through text-gray-400 text-sm pr-1'>{task.activity}</label>
))}
</div>
<div className='flex sm:flex-col gap-2 sm:gap-0 items-center sm:w-1/5'>
<h3 className='font-rem text-3xl'>{completedTasks.length}</h3>
<span className='text-xs font-semibold sm:font-medium'>Completed</span>
</div>
</div>
)}
</div>
</div>
</>
)
}

export default TodayList

In this component we only want to show tasks that have the same date as today’s date, so we need to filter the tasks.

const tasksToday= tasks.filter(task=>new Date(task.date).toLocaleDateString()===new Date().toLocaleDateString())

After we get the today tasks, we categorize them based on their status, complete and incomplete. We then need to filter the tasks again. If the task status is false then it’ becomes part of the incompletedTasks, but if the task status is true then it becomes part of the completedTasks.

const incompletedTasks= tasksToday.filter(task=>task.status===false)
const completedTasks= tasksToday.filter(task=>task.status===true)

Besides their status, the differences between incompletedTasks and completedTasks are that incompletedTasks have a checkbox and buttons for editing and deleting

{incompletedTasks.map(task => (
<div key={task.id} className='relative flex group/actions items-center'>
<label className='relative pl-8 py-4 w-5/6'> {task.activity}
<input type="checkbox" className="appearance-none" value="" onChange={() => handleCompleteTask(task.id)}/>
<span className="checkmark absolute top-5 left-1 w-4 h-4 bg-orange-100/75 ring-1 shadow-sm rounded-sm outline-none ring-red-400 checked:bg-red-500"></span>
</label>
<div className='flex flex-col absolute right-0 my-2 p-0.5 invisible gap-0.5 bg-orange-100 rounded-md group-hover/actions:visible'>
<div className="cursor-pointer hover:bg-orange-400/40 hover:rounded-t-md" onClick={()=>editModal(task.id)}>
<Edit className="h-5 w-5 stroke-red-500 hover:stroke-red-600 stroke-2"/>
</div>
<div className="cursor-pointer hover:bg-orange-400/40 hover:rounded-b-md" onClick={()=>deleteModal(task.id)}>
<Delete className="fill-none h-5 w-5 stroke-red-500 hover:stroke-red-600 stroke-2"/>
</div>
</div>
</div>
)) }

When a user clicks the edit or delete buttons, a modal will open based on the task id. These buttons serve a different purpose.

The edit button triggers the editModal function, which corresponds to the handleEditModal function. On the other hand, the delete button triggers the deleteModal function, which corresponds to the handleDeleteModal function. Both of these functions call the modalDispatch in the Todo component to open the modal.

{completedTasks.length!==0 &&(
<div className='border-t border-red-200 flex flex-col sm:flex-row gap-2 justify-between items-center py-2 text-xs sm:text-sm'>
<div className='sm:w-4/5 sm:break-words '>
{completedTasks.map(task => (
<label key={task.id} className='line-through text-gray-400 text-sm pr-1'>{task.activity}</label>
))}
</div>
<div className='flex sm:flex-col gap-2 sm:gap-0 items-center sm:w-1/5'>
<h3 className='font-rem text-3xl'>{completedTasks.length}</h3>
<span className='text-xs font-semibold sm:font-medium'>Completed</span>
</div>
</div>
)}

When we click one of the task checkboxes in incompletedTasks section, the task will be crossed off the list and moved to the completedTasks section. This action will increase the number of tasks in the completedTasks section.

UpcomingLists.jsx

import {useContext} from 'react'
import { TodoContext } from '../../App';

const month = ["Jan","Feb","Mar","Apr","May","Jun","Jul","Aug","Sep","Oct","Nov","Dec"];

const UpcomingLists = ({NoPlan, Edit, Delete, editModal, deleteModal}) => {
const {tasks} = useContext(TodoContext);
const tasksUpcoming = tasks.filter(task=>new Date(task.date)>new Date())
return (
<>
<div className="w-full flex flex-col justify-center items-center gap-7">
<h3 className="font-borel text-xl md:text-2xl font-semibold">Upcoming</h3>
<div className="bg-white/90 min-h-24 w-4/5 p-5 rounded-md shadow-sm">
<div className="flex flex-col divide-y divide-red-200">
{tasksUpcoming.map(task => (
<div key={task.id} className='relative flex group/actions items-center py-2'>
<span className='text-xs font-semibold text-gray-400'>{new Date(task.date).getDate()} {month[new Date(task.date).getMonth()]}<br/>{new Date(task.date).getFullYear()}</span>
<label className='relative pl-4 py-2 w-5/6'>{task.activity}
</label>
<div className='flex flex-col absolute right-0 my-2 p-0.5 invisible gap-0.5 bg-orange-100 rounded-md group-hover/actions:visible'>
<div className="cursor-pointer hover:bg-orange-400/40 hover:rounded-t-md" onClick={()=>editModal(task.id)}>
<Edit className="h-5 w-5 stroke-red-500 hover:stroke-red-600 stroke-2"/>
</div>
<div className="cursor-pointer hover:bg-orange-400/40 hover:rounded-b-md" onClick={()=>deleteModal(task.id)}>
<Delete className="fill-none h-5 w-5 stroke-red-500 hover:stroke-red-600 stroke-2"/>
</div>
</div>
</div>
))}
</div>
{tasksUpcoming.length===0 &&(
<div className="p-4 flex flex-col gap-4 items-center justify-center">
<NoPlan className="h-12 w-12 fill-gray-300"/>
<span className='font-borel text-[#c6cbd2]'>No Upcoming Plans</span>
</div>
)}
</div>
</div>
</>
)
}

export default UpcomingLists

In the UpcomingLists component, we filter the tasks that have upcoming dates.

const tasksUpcoming = tasks.filter(task=>new Date(task.date)>new Date())

Besides their dates, the difference between upcomingTasks and todayTasks is that upcomingTasks do not have checkboxes. They only have edit and delete buttons, similar to todayTasks.

Modal.jsx

import React, { useContext, useState } from 'react'
import { TodoContext } from '../../App'
import Form from './Form'
import { Close } from '../../asset/svg'
const Modal = () => {
const {modal, modalDispatch, taskDispatch} = useContext(TodoContext)
function closeModal(){
modalDispatch({
type:'closeModal',
})
}
function handleDeleteTask(){
taskDispatch({
type:'deleted',
id:modal.selectedId
})
modalDispatch({
type:'closeModal',
})
}

return (
<>
<div className='fixed top-0 bg-amber-600/40 w-full h-full'>
<div className="flex justify-center items-center h-full w-full">
<div className='relative bg-orange-50 w-3/4 p-8 rounded-xl shadow-xl'>
<div onClick={()=>closeModal()} className="absolute cursor-pointer right-2 top-2">
<Close className="h-7 w-7 fill-red-600 hover:fill-red-700"/>
</div>
<h3 className='font-semibold text-red-600 font-borel text-xl sm:text-2xl'> {modal.modalType} the Plan</h3>
{modal.modalType==='Edit'? <Form/> :
<div className='text-center'>
<h3 className='mt-4 mb-8 text-red-700 font-rem text-xl sm:text-2xl'>Are you sure you want to delete this plan?</h3>
<button onClick={()=>handleDeleteTask()} className='text-red-600 px-4 py-2 border border-red-400 bg-gray-100 hover:bg-red-700 hover:text-white rounded-lg shadow-md text-xs min-[360px]:text-sm sm:text-base font-rem'>Confirm Deletion</button>
</div>
}
</div>
</div>

</div>

</>
)
}
export default Modal

The Modal component is only rendered when the show property value from the modal state is true. The show property can only be true if the user clicks the edit or delete buttons.

//App.js
{modal.show &&<Modal/>}

When modalType is Edit, the Form component will be rendered. If not, the modal will display a confirmation message asking the user to remove the task.

{modal.modalType==='Edit'? <Form/> :
<div className='text-center'>
<h3 className='mt-4 mb-8 text-red-700 font-rem text-xl sm:text-2xl'>Are you sure you want to delete this plan?</h3>
<button onClick={()=>handleDeleteTask()} className='text-red-600 px-4 py-2 border border-red-400 bg-gray-100 hover:bg-red-700 hover:text-white rounded-lg shadow-md text-xs min-[360px]:text-sm sm:text-base font-rem'>Confirm Deletion</button>
</div>

When the user clicks the Confirm Deletion button, it will trigger the handleDeleteTask function.

function handleDeleteTask(){
taskDispatch({
type:'deleted',
id:modal.selectedId
})
modalDispatch({
type:'closeModal',
})
}

The handleDeleteTask function will call both taskDispatch and modalDispatch. We use taskDispatch to delete the task, and modalDispatch to close the modal.

Form.jsx

In the Form component we have an input element for writing the activity name and a calendar for selecting the due date.

import {useState, useContext, useRef, forwardRef, useEffect} from 'react'
import { Calendar } from "../../asset/svg"
import DatePicker from "react-datepicker"
import "react-datepicker/dist/react-datepicker.css";
import "./calendar.css"
import { TodoContext } from '../../App';

const checkValue = (value) => {
if (!value.trim()) {
return false;
} else {
return true;
}
};

const Form = () => {
const {tasks, modal, taskDispatch, modalDispatch} = useContext(TodoContext);
const [activity, setActivity] =useState('')
const [startDate, setStartDate] = useState(null);

useEffect(()=>{
if(modal.modalType==='Edit'){
const editTask = tasks.find(task => task.id === modal.selectedId)
if(editTask)
{
setStartDate(new Date(editTask.date))
setActivity(editTask.activity)
}
else{
alert("Sorry, we can't find the plan you are asking for")
}
}
else{
setActivity('')
setStartDate(new Date());
}
}, [modal.show])

function handleAddTask(activity, date){
if (checkValue(activity)) {
taskDispatch({
type:'added',
id:Math.random().toString(36).substring(2),
activity:activity,
date:date,
})
setActivity('')
setStartDate(new Date());
}
else{
alert("Please write your plan first")
}
}
function handleEditTask(activity, date){
if (checkValue(activity)) {
taskDispatch({
type:'changed',
id:modal.selectedId,
activity:activity,
date:date
})
modalDispatch({
type:'closeModal',
})
setActivity('')
setStartDate(new Date());
}
else{
alert("Please write your plan first")
}
}
const DateInput = forwardRef(({ onClick }, ref) => (
<button type='button' title="Add Due Date" className="calendar" ref={ref} onClick={onClick}>
<Calendar className="h-7 w-7 fill-red-600 hover:fill-red-700" />
</button>
));

return (
<>
<form className='text-right' onSubmit={(event)=>{
event.preventDefault()
modal.show? handleEditTask(activity, startDate) : handleAddTask(activity, startDate)
}
}>
<div className="flex w-full items-end justify-end my-5">
<input value={activity} onChange={e=>setActivity(e.target.value)} placeholder="I want to ..." className="w-full bg-transparent placeholder-gray-500 border-b-2 border-red-600 py-2 text-sm min-[360px]:text-base sm:text-lg md:text-xl font-light font-rem text-gray-700 outline-none focus:border-red-700" required/>
<DatePicker
selected={startDate}
onChange={(date) => setStartDate(date)} minDate={new Date()}
customInput={<DateInput />}
/>
</div>
<button type="submit" className="bg-red-600 hover:bg-red-700 text-white rounded-lg self-end font-semibold w-12 min-[360px]:w-16 h-7 min-[360px]:h-9 sm:h-10 text-xs min-[360px]:text-base md:text-lg font-rem">{modal.show?"Edit":"Add"}</button>
</form>
</>
)
}
export default Form

To create or edit a task, we need states to keep the activity name and the due date.

const [activity, setActivity] =useState('')
const [startDate, setStartDate] = useState(null);

In order to use the calendar, we need to import the DatePicker component from react-datepicker, the CSS files for styling the calendar and the SVG component for the calendar icon.

...
import { Calendar } from "../../asset/svg"
import DatePicker from "react-datepicker"
import "react-datepicker/dist/react-datepicker.css";
import "./calendar.css"
...

Here, we’re not going to use the default input from DatePicker, we will use the custom version. To use the custom version, we need to import the forwardRef function.

import {.. forwardRef} from 'react'
...

forwardRef is a function that creates a ref, passes it to a child component, and returns a new component that can receive a ref prop. This allows parent components to directly access the ref of a child component


...
const Form = () => {
...
const DateInput = forwardRef(({ onClick }, ref) => (
<button type='button' title="Add Due Date" className="calendar" ref={ref} onClick={onClick}>
<Calendar className="h-7 w-7 fill-red-600 hover:fill-red-700" />
</button>
));
return (
<>
...
<DatePicker
selected={startDate}
onChange={(date) => setStartDate(date)} minDate={new Date()}
customInput={<DateInput />}
/>
...
</>
)
}

DateInput is the new component that gets returned from forwardRef. This component takes two arguments, props and ref.

The props are destructured to extract the onClick prop and the ref is passed directly to the button element.

The props that we can use in the DateInput component are onClick , value, onChange.onClickis a function that opens or closes the date picker dialog, value is a string representing the currently selected date, and onChangeis a function that can be used to change the selected date.

We destructure the props so we can directly use onClick instead of props.onClick to access the onClick function passed in as a prop.

forwardRef allows DatePicker component which is the parent of DateInput component to access the ref of the button within the DateInput component.

In the DatePicker component, we have four props :

  • selected: This prop is used to set the currently selected date.
  • onChange: This prop is an event listener that gets called whenever the date changes. In this case, it’s updating the startDate state.
  • minDate: This prop is used to set the minimum selectable date. In this case, it’s set to the current date.
  • customInput: This prop allows you to provide a custom input component. In this case, we’re passing the DateInput component that we defined earlier.

When the user want to add a new task, the onSubmit event handler will call event.preventDefault() to prevent the form’s default submit behavior, which is to refresh the page.

Then, it checks the value of modal.show. If modal.show is true, it calls the handleEditTask function. If modal.show is false, it calls the handleAddTask function. Both functions pass the same parameters, activity and startDate.

...
const Form = () => {
...
return (
<>
<form className='text-right' onSubmit={(event)=>{
event.preventDefault()
modal.show? handleEditTask(activity, startDate) : handleAddTask(activity, startDate)
}
}>
...
</form>
</>
)
}

So, when the modal is not showing, the form is in ‘add’ mode and will add a new task when submitted. However, when the modal is showing, the form is in ‘edit’ mode and will edit an existing task when submitted.

...

const checkValue = (value) => {
if (!value.trim()) {
return false;
} else {
return true;
}
};

const Form = () => {
...

function handleAddTask(activity, date){
if (checkValue(activity)) {
taskDispatch({
type:'added',
id:Math.random().toString(36).substring(2),
activity:activity,
date:date,
})
setActivity('')
setStartDate(new Date());
}
else{
alert("Please write your plan first")
}
}
function handleEditTask(activity, date){
if (checkValue(activity)) {
taskDispatch({
type:'changed',
id:modal.selectedId,
activity:activity,
date:date
})
modalDispatch({
type:'closeModal',
})
setActivity('')
setStartDate(new Date());
}
else{
alert("Please write your plan first")
}
}
...

}
export default Form

To ensure that the activity name value is not empty, we validate them using the checkValue function which is defined outside of the Form component. This function checks if the passed value, after being trimmed of whitespace, is not an empty string.

When the checkValue function returns true, the activity parameter passed to the handleAddTask or handleEditTask functions. Then taskDispatch function will be called.

In the handleAddTask function, taskDispatch is called with an object that has the type ‘added’, a unique id generated using Math.random(), and the activity and date parameters. This adds a new task with the given activity and date to the task list.

In the handleEditTask function, taskDispatch is called with an object that has the type ‘changed’, the id of the selected task from modal.selectedId, and the activity and date parameters. This updates the activity and date of the existing task with the given id.

After the task is added or edited, the setActivity('') function is called to reset the activity input field.

Finally, we have finished this tutorial!

Here, we learned how to use useContext, useReducer, and how to save the state to localStorage. I know this tutorial might not be perfect, but it still can be helpful for beginners, I hope.

You can check the full code of this to-do-list app in this GitHub repository: https://github.com/ucilasmana/to-do-list

Have a good day!

--

--

Uci Lasmana

Sharing knowledge not only helps me grow as a developer but also contributes to the growth of fellow developers. Let’s grow together!