Todo List App

Used in this guide:
Next.js 15.4.1
React 18

Introduction

Learn how to build a complete Todo List App using Next.js, React hooks, and localStorage. This beginner-friendly tutorial walks you through state management, form handling, conditional rendering, task sorting, and handling hydration mismatch, all in a real-world project.

Todo App

This Todo app lets users add, display, complete, and remove tasks in real time. It uses React hooks to manage state and localStorage to persist data between sessions. Tasks are automatically sorted by completion status, and the interface gives immediate feedback when input is empty.

app/(pages)/todo-app/page.tsx
'use client'
import { FormEvent, useEffect, useState } from "react"
 
interface TodoProps {
    id: number,
    name: string,
    complete: boolean,
}
 
export default function TodoAppPage() {
 
    const [textInput, setTextInput] = useState("");
    const [todoList, setTodoList] = useState<TodoProps[]>([])
    const [errorMessage, setErrorMessage] = useState("");
    const [isMounted, setIsmounted] = useState(false);
 
    const handleAddTask = (e: FormEvent) => {
        e.preventDefault();
        
        setErrorMessage("");
        if (textInput === "") {
            setErrorMessage("Please type something to add to the list.");
            return;
        }
 
        const highestId = todoList.reduce((max, item) => {
            if (item.id > max) {
                return max = item.id;
            }
            return max
        }, 0)
 
        const newTask = {
            id: highestId + 1,
            name: textInput,
            complete: false,
        }
 
        setTodoList(prev => [...prev, newTask]);
        setTextInput("");
    }
 
    const sortList = (list: TodoProps[]) => {
        const updatedList = list.sort((a, b) => Number(a.complete) - Number(b.complete));
        setTodoList(updatedList);
        return updatedList;
    }
 
    const toggleCompletion = (id: number) => {
        const updatedList = todoList.map((item) => item.id === id ? {...item, complete: !item.complete} : item );
        setTodoList(sortList(updatedList));
    }
 
    useEffect(() => {
        setIsmounted(true);
    }, [])
 
    useEffect(() => {
        if (isMounted) {
            const saved = localStorage.getItem("savedTodoList");
            if (saved) {
                const parsed = JSON.parse(saved) as TodoProps[];
                setTodoList(parsed);
            }
        }
    }, [isMounted])
 
    useEffect(() => {
        if (isMounted) {
            localStorage.setItem("savedTodoList", JSON.stringify(todoList));
        }
    }, [todoList, isMounted])
 
    return (
        <div className="flex flex-col gap-4 w-full max-w-[500px] mx-auto py-8">
            <h1 className="font-semibold text-lg">My Todo App</h1>
            {errorMessage && <p className="text-amber-600 text-sm">{errorMessage}</p>}
            <form onSubmit={handleAddTask} className="flex w-full gap-2 justify-between items-center text-sm">
                <input
                    type="text"
                    value={textInput}
                    onChange={e => setTextInput(e.target.value)}
                    placeholder="Write task to add..."
                    className="w-full bg-gray-700 py-2 px-4"
                />
                <button
                    type="submit"
                    className="bg-sky-600 py-2 px-4 shrink-0 cursor-pointer"
                    >Add Task</button>
            </form>
 
            {todoList.length > 0 ? (
                <div>
                    {todoList.map((item) => (
                        <div key={item.id}
                            className="flex justify-between items-center w-full text-sm border-b-1 border-gray-700 py-2">
                            <p className="px-4">
                                {item.name}
                                <span 
                                    onClick={() => toggleCompletion(item.id)}
                                    className={`${item.complete ? "text-emerald-500" : "text-gray-600"} ml-2 cursor-pointer`}
                                >{item.complete ? "(completed)" : "(on-going)"}</span>
                            </p>
                            <button
                                className="bg-amber-600 py-2 px-4 shrink-0"
                                >Remove</button>
                        </div>
                    ))}
                </div>
            ) : <p className="text-gray-600 text-sm">There is no task to do</p>}
        </div>
    )
}


Explanation

The handleAddTask function is triggered when the form is submitted. It prevents the default page reload, checks if the input is empty, and sets an error message if so. If input exists, it calculates a new unique ID by finding the current highest ID in the list and adding one. Then it creates a new task object with the input name and a default complete value of false, adds it to the todo list, and resets the input field.

    const handleAddTask = (e: FormEvent) => {
        e.preventDefault();
        
        setErrorMessage("");
        if (textInput === "") {
            setErrorMessage("Please type something to add to the list.");
            return;
        }
 
        const highestId = todoList.reduce((max, item) => {
            if (item.id > max) {
                return max = item.id;
            }
            return max
        }, 0)
 
        const newTask = {
            id: highestId + 1,
            name: textInput,
            complete: false,
        }
 
        setTodoList(prev => [...prev, newTask]);
        setTextInput("");
    }

The sortList function takes a list of tasks and reorders them so that incomplete tasks appear before completed ones. It uses the numeric values of the complete boolean to determine sort order, updates the main todoList state with the sorted array, and also returns the updated list for immediate use.

    const sortList = (list: TodoProps[]) => {
        const updatedList = list.sort((a, b) => Number(a.complete) - Number(b.complete));
        setTodoList(updatedList);
        return updatedList;
    }

The toggleCompletion function flips the complete status of a specific task based on its ID. It creates a new array with the updated task and passes it through the sortList function to ensure completed tasks are pushed to the bottom before updating the main todoList state.

    const toggleCompletion = (id: number) => {
        const updatedList = todoList.map((item) => item.id === id ? {...item, complete: !item.complete} : item );
        setTodoList(sortList(updatedList));
    }

The set of useEffect hooks manages saving and loading the todo list from localStorage. The first effect sets a mounted flag once the component is loaded. The second effect checks if the component is mounted, retrieves the saved list from localStorage, parses it, and sets it as the current state. The third effect watches for changes in the todo list and updates localStorage with the latest state only after the initial mount. This prevents reading or writing too early during SSR or hydration.

    useEffect(() => {
        setIsmounted(true);
    }, [])
 
    useEffect(() => {
        if (isMounted) {
            const saved = localStorage.getItem("savedTodoList");
            if (saved) {
                const parsed = JSON.parse(saved) as TodoProps[];
                setTodoList(parsed);
            }
        }
    }, [isMounted])
 
    useEffect(() => {
        if (isMounted) {
            localStorage.setItem("savedTodoList", JSON.stringify(todoList));
        }
    }, [todoList, isMounted])

JKT

Stay focused, and the rest will follow

©Jakkrit Turner. All rights reserved