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} />
))}
Learnings:
- Controlled Inputs Learned how to manage form inputs using useState, keeping the input value fully controlled by React state.
-
Basic Validation
Added validation to prevent empty or whitespace-only todos from being added:if (task.trim() === ""){
alert("Enter task to add");
return;
} Immutable State Updates (Arrays)
Used the spread operator to update the todos array:
jsx
setTodos([...todos, task]);
Resetting Input for Better UX
Cleared the input field after adding a todo:
setTask("");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>
Learnings:
- Deleting a Todo the Right Way: Used filter() to remove a specific todo instead of changing the original array.
- 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.
- Understanding Immutability: filter() creates a new array instead of changing the old one. This follows Reactβs rule of not mutating state directly.
- 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>
Learnings:
- Toggling State Using map(): Used map() to update only the clicked todo without affecting others.
- Conditional Rendering: Displayed the β mark only when completed is true.
- Deleting a Todo Safely: Used filter() to remove a specific todo from state.
- Avoiding Index-Based Bugs: Used a unique id instead of array index for both toggle and delete logic.
- 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)
}
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>
)}
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>
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])
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("")
}
}
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)
}, [])
This runs only once.
Hydration β putting stored data back into React state
What goes wrong WITHOUT loaded?
Order of events when app starts:
- React renders with: todos = []
- useEffect([todos]) runs
- Saves empty array to localStorage β
- Old todos are LOST
- 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])
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)