Build a To-Do List App with React Hooks, Tailwind, and localStorage (React Beginner Projects Part 2)
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:
- 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.
- 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”.
- When we complete a plan, it will be crossed off the list. Then the number of completed plans will increase.
- 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.
- 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:
setItem(key, value)
: This method allows you to add a new data item to the storage or update an existing one. Thekey
parameter is the name of the item, while thevalue
is the data you want to store.getItem(key)
: This method retrieves the data item with the specified key. If the item does not exist, it returnsnull
.removeItem(key)
: This method removes the data item with the specified key.clear()
: This method removes all data items from the storage.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 inindex.css
file. We can also use this method to override the styles in a third-party library, as we plan to do incalendar.css file
. The CSS rules in thecalendar.css
file are fromreact-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 thestate
. It returns a new array with all the existing tasks(…state)
and a new task object. The new task object includes anid
,activity
,date
, andstatus
(which is initially set tofalse
). - The
completed
case is used to mark a task as completed. It maps through thestate
and when it finds a task with anid
that matches theaction.id
, it returns a new object with all the existing properties of the task(…task)
and updates thestatus
totrue
. - The
changed
case is used to change the details of a task. Just like thecompleted
case, it will maps through thestate
to find the matchesid
then returns a new object with all the existing properties and updates theactivity
anddate
. - The
deleted
case is used to delete a task. It filters thestate
and returns a new array that does not include the task with anid
that matches theaction.id
. - Meanwhile, the
default
is used for if none of the above cases match the action cases, the function simply returns thecurrent 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 amodal
. It returns a new object with propertiesshow
set totrue
,selectedId
set toaction.id
, andmodalType
set toaction.modalType
- The
closeModal
case is used to close amodal
. It returns a new object with propertiesshow
set tofalse
,selectedId
set tonull
, andmodalType
set tonull
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 tolocalStorage
is because in React, thestate
is used to store and track changes to data that affects the UI. Whenstate
changes, the component re-renders, allowing the UI to update and reflect the current data. If we update thelocalStorage
directly without updating thestate
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 theTodayList
andUpcomingList
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 oldvirtual
DOM
and only update thereal 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 thereal DOM
. Updating thevirtual DOM
is much faster than updating thereal DOM
because there’s nothing to display in the UI. Thereal DOM
will be updated after React compare the difference between the new and oldvirtual 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 aref
, passes it to a child component, and returns a new component that can receive aref
prop. This allows parent components to directly access theref
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 areonClick
,value
,onChange
.onClick
is a function that opens or closes the date picker dialog,value
is a string representing the currently selected date, andonChange
is a function that can be used to change the selected date.We destructure the
props
so we can directly useonClick
instead ofprops.onClick
to access theonClick
function passed in as aprop
.
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
: Thisprop
is used to set the currently selected date.onChange
: Thisprop
is an event listener that gets called whenever the date changes. In this case, it’s updating thestartDate
state.minDate
: Thisprop
is used to set the minimum selectable date. In this case, it’s set to the current date.customInput
: Thisprop
allows you to provide a custom input component. In this case, we’re passing theDateInput
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!