DEV Community

Abhishek Arankal
Abhishek Arankal

Posted on • Edited on

Todo App

Introduction

After completing my first logic-focused project (Counters), I wanted to take the next natural step in complexity β€” not by improving the UI, but by challenging my thinking.

This led me to Project 2: Todo App (Logic-First, Not UI).

Why a Todo App?

Counters helped me understand single-value state logic.
A Todo app forces you to think in terms of:

  • Arrays instead of single values
  • CRUD operations (add, update, delete)
  • Conditional rendering
  • Edge cases and state consistency

This is where many React learners start to struggle β€” which makes it the perfect place to grow.

The goal of this project was not to build a fancy interface, but to strengthen my problem-solving approach, write predictable state updates, and handle real-world logic scenarios that appear in almost every frontend application.

In this article, I’ll break down how I approached the Todo app step by step, the logic decisions I made, the mistakes I encountered, and what I learned by solving them.

Level-by-Level Development πŸš€

Level 1: Add Todo Creation with Validation

Commit: Level 1: Add todo creation with validation

const [todos, setTodos] = useState([]);
const [task, setTask] = useState("");

const handleSubmit = () => {
  if (task.trim() === "") {
    alert("Enter task to add");
    return;
  }
  setTodos([...todos, task]);
  setTask("");
};

<input type="text" placeholder="Add task..." value={task} 
onChange={(e) =>setTask(e.target.value)} className="border rounded px-2 py-2 w-80 m-2"/>
<button
  onClick={handleSubmit}
  className="border rounded p-2 w-25 cursor-pointer hover:bg-gray-400 hover:text-black"
>
  Add Task
</button>

{todos.map((todo, idx) => (
  <TaskCard todo={todo} key={idx} />
))}
Enter fullscreen mode Exit fullscreen mode

Learnings:

  1. Controlled Inputs Learned how to manage form inputs using useState, keeping the input value fully controlled by React state.
  2. Basic Validation
    Added validation to prevent empty or whitespace-only todos from being added:

    if (task.trim() === ""){
    alert("Enter task to add");
    return;
    }

  3. Immutable State Updates (Arrays)
    Used the spread operator to update the todos array:
    jsx
    setTodos([...todos, task]);

  4. Resetting Input for Better UX
    Cleared the input field after adding a todo:
    setTask("");

  5. Rendering Dynamic Lists
    Used map() to render todos dynamically:
    {todos.map((todo, idx) => (
    <TaskCard todo={todo} key={idx} />
    ))}

Level 2: Delete Todo (Filter Logic)
commit:Level2: Add delete todo functionality

Goal:

  • Allow users to delete a todo
  • Remove only the selected todo
  • Keep the remaining todos safe
  • Learn proper state removal logic in React

Core Idea:
Instead of modifying the existing todos array, create a new array(updatedTodos) that excludes the selected todo and update the state with it.

Logic:

const handleDeleteTask = (selectedTodo) => {
  const updatedTodos = todos.filter((todo) => todo.id !== selectedTodo.id);
setTodos(updatedTodos);
};`

<button className='px-2 py-0.5 border rounded text-sm bg-red-300 text-black' onClick={() =>handleDelete(todo)}>Delete</button>
Enter fullscreen mode Exit fullscreen mode

Learnings:

  1. Deleting a Todo the Right Way: Used filter() to remove a specific todo instead of changing the original array.
  2. Why Not Use Index: Using index can cause bugs when items are added or removed. So I deleted todos using a unique id, which is safer and more reliable.
  3. Understanding Immutability: filter() creates a new array instead of changing the old one. This follows React’s rule of not mutating state directly.
  4. Predictable State Updates: Even after deleting multiple todos, the state stays clean and behaves as expected.

Level 3: Completing a Task (State Toggle Logic in React)
commit: Level 3: Add toggle completed functionality

Goal:

  • Mark a todo as completed or not completed
  • Delete a specific todo
  • Handle state updates safely
  • Improve logic without breaking existing features

Logic:

const handleTaskCompleted = (todo) => {
  const updatedTodos = todos.map((t) => t.id === todo.id ? {...t, completed: !t.completed} : t)
  setTodos(updatedTodos)
}
<button className='w-5 h-5 border' onClick={() =>handleTaskCompleted(todo)}>{todo.completed && <span className="text-md font-bold leading-none">βœ”</span>}</button>
Enter fullscreen mode Exit fullscreen mode

Learnings:

  1. Toggling State Using map(): Used map() to update only the clicked todo without affecting others.
  2. Conditional Rendering: Displayed the βœ” mark only when completed is true.
  3. Deleting a Todo Safely: Used filter() to remove a specific todo from state.
  4. Avoiding Index-Based Bugs: Used a unique id instead of array index for both toggle and delete logic.
  5. Immutability in React State: Both map() and filter() return new arrays, keeping state updates safe.

Level 4: Editing a Task (Inline Edit Logic in React)
commit: Level 4: Add edit todo functionality with id-based edit state

Goal:

  • Edit a todo task directly inside the UI
  • Allow only one task to be edited at a time
  • Save updated task text safely
  • Cancel editing without changing data
  • Prevent empty or unchanged task updates

Logic:

App.jsx Logic:

const [isEditingId, setIsEditingId] = useState(null)


const handleEditSave = (todo, editedTask) => {
  const trimmedTask = editedTask.trim()
  if(trimmedTask === "" || todo.task.toLowerCase() === trimmedTask.toLowerCase()){
    setIsEditingId(null)
    return
  };

  const updatedTodos = todos.map((t) => t.id === todo.id ? {...t, task: trimmedTask} : t)
  setTodos(updatedTodos)
  setIsEditingId(null)

}
Enter fullscreen mode Exit fullscreen mode

TaskCard.jsx Logic:

const [editedTask, setEditedTask] = useState(todo.task)

  useEffect(() => {
    if(editingId === todo.id){
      setEditedTask(todo.task)
    }
  }, [editingId, todo.task])

//rendering logic
{editingId === todo.id ? (
  <>
    <input
      value={editedTask}
      onChange={(e) => setEditedTask(e.target.value)}
    />
    <button onClick={() => handleEditSave(todo, editedTask)}>
      Save
    </button>
    <button onClick={() => setIsEditingId(null)}>
      Cancel
    </button>
  </>
) : (
  <button onClick={() => setIsEditingId(todo.id)}>
    Edit
  </button>
)}
Enter fullscreen mode Exit fullscreen mode

Faced Bug:
At first, I used a global boolean value (true / false) to control edit mode.

const [isEditing, setIsEditing] = useState(false)

When isEditing was set to true:

  • All task cards entered edit mode
  • Every task showed an input field at the same time

This happened because:

  • All task cards were using the same edit state
  • There was no way to know which task was being edited

Bug Fix:
Instead of using a boolean, I changed the logic to use an id-based(uniqueId) edit state.

const [isEditingId, setIsEditingId] = useState(null)

Now:

  • isEditingId stores the id of the task being edited
  • Only the matching task card enters edit mode
  • Other task cards remain unchanged

"Small bug, big learning πŸš€"
This change made my edit feature clean, predictable, and scalable.

Learnings:

  • Problem with using true/false state: Using a single boolean caused all task cards to enter edit mode.
  • Id-Based Control: Using a task id allows precise control over UI behavior.
  • Scoped State Logic: State should represent what is being edited, not just if editing exists.
  • Safe State Updates: map() updates only the selected todo without affecting others.
  • Controlled Inputs: Local input state makes editing predictable.
  • Avoiding Index Bugs: Using unique id is safer than array index.

Level 5: Task Filtering Logic (All/ Active / Completed)
commit: Level 5: Add todo filters (all, active, completed)

Goal:

  • Show tasks based on their status
  • Support All, Active, and Completed filters
  • Keep filtering logic simple and safe
  • Avoid breaking existing task features

Logic:

const [filter, setFilter] = useState("all")

//filterTask logic
const filteredTasks = todos.filter((todo) => {
  if (filter === "active") return !todo.completed
  if (filter === "completed") return todo.completed
  return true
})

<div className='flex items-center justify-around mt-5'>
    <button className={`px-3 py-1 border rounded w-25 text-black font-semibold cursor-pointer ${filter === "all" ? "bg-orange-300" : "bg-white"}`} value="all" onClick={(e) => setFilter(e.target.value)}>All</button>
    <button className={`px-3 py-1 border rounded w-25 text-black font-semibold cursor-pointer ${filter === "active" ? "bg-orange-300" : "bg-white"}`} value="active" onClick={(e) => setFilter(e.target.value)}>Active</button>
    <button className={`px-3 py-1 border rounded w-25 text-black font-semibold cursor-pointer ${filter === "completed" ? "bg-orange-300" : "bg-white"}`} value="completed" onClick={(e) => setFilter(e.target.value)}>Completed</button>
</div>
Enter fullscreen mode Exit fullscreen mode

How it Works:

  • filter() loops through all todos
  • Based on the selected filter:
    • Active β†’ shows tasks that are not completed
    • Completed β†’ shows only completed tasks
    • All β†’ returns every task
  • Returning true keeps the task in the list

Learnings:

  • Using filter(): Helps create a new list without changing the original state
  • Conditional Logic: Makes filtering flexible and easy to read
  • Boolean Checks: Clear boolean checks help prevent logic mistakes
  • Safe State Usage: Filtering does not mutate the original array
  • Clear UI Control: Filter value directly controls what the user sees

Optimizing Task Filtering Using useMemo
commit: Optimize task filtering using useMemo

Goal:

  • Improve performance of task filtering
  • Avoid unnecessary recalculations on every render
  • Keep logic clean and readable
  • Optimize without changing existing behavior

Problem Before Optimization:

  • Filtering logic ran on every render
  • Even when todos and filter didn’t change
  • This can affect performance as the app grows

Optimized Logic:

import { useMemo } from "react"

const filteredTasks = useMemo(() => {
  return todos.filter((todo) => {
  if(filter === "active") return todo.completed !== true
  if(filter === "completed") return todo.completed === true
  return true
})
}, [filter, todos])
Enter fullscreen mode Exit fullscreen mode

How useMemo Helps:

  • React remembers the filtered result
  • Recalculates only when:
    • todos changes
    • filter changes
  • Prevents extra work during re-renders

Level 6 – Edge Case Hardening
commit: Level 6: Handle edge cases, duplicates

Goal:
Make the app stable and user-proof by handling scenarios users commonly break apps with.

At this stage, my Todo app already supported:

  • Add
  • Delete
  • Edit
  • Complete
  • Filters (All / Active / Completed) But it still had hidden problems.

Problems Before Level 6:
Here’s what could go wrong:

  • Users could add duplicate todos
  • Extra spaces could sneak into task text
  • UI looked broken when the list was empty
  • Filters showed nothing with no explanation
  • Edit/save edge cases could behave oddly

These are real UX and logic bugs, not beginner mistakes.

What I Implemented in Level 6:

1️⃣. Prevent Duplicate Todos
Before adding a task, I checked if it already exists (case-insensitive):

const handleSubmit = () => {
      if(task.trim() === ""){
        alert("Enter task to add")
        return;
      }
      const checkTodoExists = todos.some((todo) => todo.task.toLowerCase() === task.toLowerCase().trim())
      if(checkTodoExists){
        alert("Task Already Exists")
        setTask("")
      } else {
        setTodos([...todos, {id: Date.now(), task: task.trim(), completed: false}])
        setTask("")
      }

  }
Enter fullscreen mode Exit fullscreen mode

If it exists:

  • Block the add
  • Show a message
  • Keep state safe

This ensures:

  • No logical duplicates
  • Cleaner data

2️⃣. Trim Input Everywhere
I enforced trimming:

  • While adding
  • While editing This stores data always clean:

task.trim()

No accidental " Learn React " values anymore

3️⃣ Empty State UI (Very Important UX)
Instead of rendering nothing, I added a clear empty state:

No tasks yet. Add one above πŸ‘†

This applies to:

  • First-time users
  • After clearing all tasks
  • When filters return no results This alone made the app feel more professional.

Learnings:
"Edge cases are not rare β€” they are guaranteed.”
Level 6 taught me to:

  • Think defensively
  • Validate data before storing
  • Treat UX as part of logic
  • Prevent bugs instead of fixing them later

Level 7 – Persisting Todos with localStorage
commit: Level 7: Persist todos using localStorage

After Level 6, my app was stable β€” but refreshing the page still wiped everything.

Goal:
Make todos persist across page refreshes using localStorage.

What I Implemented in Level 7

1️⃣ Load Todos on App Mount
When the app loads, read saved todos from localStorage:

const [loaded, setLoaded] = useState(false) //hydration
useEffect(() => {
  const localStorageTodosData = localStorage.getItem("todosData")
  const parsedlocalStorageTodosData = JSON.parse(localStorageTodosData)
  if(parsedlocalStorageTodosData){
    setTodos(parsedlocalStorageTodosData)
  }
  setLoaded(true)
}, [])
Enter fullscreen mode Exit fullscreen mode

This runs only once.
Hydration β†’ putting stored data back into React state

What goes wrong WITHOUT loaded?
Order of events when app starts:

  1. React renders with: todos = []
  2. useEffect([todos]) runs
    • Saves empty array to localStorage ❌
    • Old todos are LOST
  3. Then load effect runs
    • Too late β€” data already overwritten

2️⃣ Save Todos on Every Change

useEffect(() => {
  if(loaded){
       localStorage.setItem("todosData", JSON.stringify(todos))
  }

}, [todos])
Enter fullscreen mode Exit fullscreen mode

loaded prevents React from saving empty state before loading real data.

Now the flow becomes:
localStorage β†’ React state β†’ UI
UI change β†’ React state β†’ localStorage

3️⃣ Persist ONLY What Matters
I intentionally did NOT store:

  • Filters
  • Editing state
  • Input text
  • UI-only flags

Only this was persisted: todos

What I Tested:

  • Add β†’ refresh β†’ still exists βœ…
  • Delete β†’ refresh β†’ still deleted βœ…
  • Edit β†’ refresh β†’ updated text persists βœ…
  • Toggle completed β†’ refresh β†’ preserved βœ…
  • Clear completed β†’ refresh β†’ stays cleared βœ…

Learnings:

  • How to use useEffect properly
  • How to hydrate state safely
  • How to avoid overwriting data on first render
  • The difference between data state and UI state

Why I Used Level-Based Commits

I built this Task Manager using level-based commits, where each commit represents one concept or learning

This helped me:

  • Track progress clearly
  • Explain my logic easily in interviews
  • Keep commits meaningful
  • Show real learning on GitHub

GitHub Repository

GitHub: https://github.com/Abhishek-Arankal/Smart-Task-Manager

Final Thoughts

This project taught me one important lesson:
Logic improves through iteration, not complexity.
Instead of jumping to advanced features, I focused on:

  • Building small
  • Handling real edge cases
  • Fixing bugs intentionally
  • Writing predictable logic

I’ll continue building small projects, documenting mistakes, and learning in public.

If you see a better or cleaner way to write this logic,
I’d genuinely appreciate your feedback πŸ™Œ

Top comments (0)