How to build a todo app in React
Building a todo application is a classic way to learn React fundamentals including state management, event handling, and component composition. As the creator of CoreUI with over 10 years of React experience since 2014, I’ve built todo-style interfaces for task management systems, project tracking tools, and checklist applications. The most effective approach uses useState for managing todos, useEffect for persistence, and controlled inputs for adding new items. This provides a fully functional todo app with all essential features.
Create a basic todo app with add and display functionality.
import { useState } from 'react'
function TodoApp() {
const [todos, setTodos] = useState([])
const [inputValue, setInputValue] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
if (inputValue.trim()) {
const newTodo = {
id: Date.now(),
text: inputValue,
completed: false
}
setTodos([...todos, newTodo])
setInputValue('')
}
}
return (
<div className="todo-app">
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>{todo.text}</li>
))}
</ul>
</div>
)
}
The todos array stores all todo items. The inputValue tracks the current input. The handleSubmit creates new todos with unique IDs. The form prevents empty todos. The list renders all todos using map.
Adding Toggle Complete Functionality
Implement checkbox to mark todos as completed.
import { useState } from 'react'
function TodoApp() {
const [todos, setTodos] = useState([])
const [inputValue, setInputValue] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
if (inputValue.trim()) {
setTodos([...todos, {
id: Date.now(),
text: inputValue,
completed: false
}])
setInputValue('')
}
}
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
return (
<div className="todo-app">
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
</li>
))}
</ul>
</div>
)
}
The toggleTodo function finds the todo by ID and flips its completed state. The checkbox reflects the completed status. Completed todos get strikethrough styling. The map creates a new array preserving immutability.
Adding Delete Functionality
Implement delete buttons to remove todos.
import { useState } from 'react'
function TodoApp() {
const [todos, setTodos] = useState([])
const [inputValue, setInputValue] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
if (inputValue.trim()) {
setTodos([...todos, {
id: Date.now(),
text: inputValue,
completed: false
}])
setInputValue('')
}
}
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
return (
<div className="todo-app">
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}
The deleteTodo function filters out the todo with matching ID. The delete button calls this function. The filter creates a new array without the deleted item. This provides complete CRUD functionality.
Persisting Todos to LocalStorage
Save todos to localStorage so they persist across page refreshes.
import { useState, useEffect } from 'react'
function TodoApp() {
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos')
return saved ? JSON.parse(saved) : []
})
const [inputValue, setInputValue] = useState('')
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos])
const handleSubmit = (e) => {
e.preventDefault()
if (inputValue.trim()) {
setTodos([...todos, {
id: Date.now(),
text: inputValue,
completed: false
}])
setInputValue('')
}
}
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
return (
<div className="todo-app">
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
</div>
)
}
The useState initializer loads saved todos from localStorage. The useEffect saves todos whenever they change. This provides automatic persistence without manual save buttons.
Adding Filter Functionality
Filter todos by completion status.
import { useState, useEffect } from 'react'
function TodoApp() {
const [todos, setTodos] = useState(() => {
const saved = localStorage.getItem('todos')
return saved ? JSON.parse(saved) : []
})
const [inputValue, setInputValue] = useState('')
const [filter, setFilter] = useState('all') // 'all', 'active', 'completed'
useEffect(() => {
localStorage.setItem('todos', JSON.stringify(todos))
}, [todos])
const handleSubmit = (e) => {
e.preventDefault()
if (inputValue.trim()) {
setTodos([...todos, {
id: Date.now(),
text: inputValue,
completed: false
}])
setInputValue('')
}
}
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
const getFilteredTodos = () => {
switch (filter) {
case 'active':
return todos.filter(todo => !todo.completed)
case 'completed':
return todos.filter(todo => todo.completed)
default:
return todos
}
}
const filteredTodos = getFilteredTodos()
return (
<div className="todo-app">
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<div className="filters">
<button onClick={() => setFilter('all')}>All</button>
<button onClick={() => setFilter('active')}>Active</button>
<button onClick={() => setFilter('completed')}>Completed</button>
</div>
<ul>
{filteredTodos.map(todo => (
<li key={todo.id}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => toggleTodo(todo.id)}
/>
<span style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}>
{todo.text}
</span>
<button onClick={() => deleteTodo(todo.id)}>Delete</button>
</li>
))}
</ul>
<p>{filteredTodos.length} items</p>
</div>
)
}
The filter state controls which todos display. The getFilteredTodos function returns filtered results. Filter buttons update the filter state. The count shows how many items match the current filter.
Adding Edit Functionality
Implement inline editing for todo text.
import { useState } from 'react'
function TodoItem({ todo, onToggle, onDelete, onEdit }) {
const [isEditing, setIsEditing] = useState(false)
const [editValue, setEditValue] = useState(todo.text)
const handleSubmit = (e) => {
e.preventDefault()
if (editValue.trim()) {
onEdit(todo.id, editValue)
setIsEditing(false)
}
}
if (isEditing) {
return (
<li>
<form onSubmit={handleSubmit}>
<input
type="text"
value={editValue}
onChange={(e) => setEditValue(e.target.value)}
autoFocus
/>
<button type="submit">Save</button>
<button type="button" onClick={() => setIsEditing(false)}>Cancel</button>
</form>
</li>
)
}
return (
<li>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
/>
<span
style={{ textDecoration: todo.completed ? 'line-through' : 'none' }}
onDoubleClick={() => setIsEditing(true)}
>
{todo.text}
</span>
<button onClick={() => setIsEditing(true)}>Edit</button>
<button onClick={() => onDelete(todo.id)}>Delete</button>
</li>
)
}
function TodoApp() {
const [todos, setTodos] = useState([])
const [inputValue, setInputValue] = useState('')
const handleSubmit = (e) => {
e.preventDefault()
if (inputValue.trim()) {
setTodos([...todos, {
id: Date.now(),
text: inputValue,
completed: false
}])
setInputValue('')
}
}
const toggleTodo = (id) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
))
}
const deleteTodo = (id) => {
setTodos(todos.filter(todo => todo.id !== id))
}
const editTodo = (id, newText) => {
setTodos(todos.map(todo =>
todo.id === id ? { ...todo, text: newText } : todo
))
}
return (
<div className="todo-app">
<h1>Todo List</h1>
<form onSubmit={handleSubmit}>
<input
type="text"
value={inputValue}
onChange={(e) => setInputValue(e.target.value)}
placeholder="Add a new todo..."
/>
<button type="submit">Add</button>
</form>
<ul>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
onToggle={toggleTodo}
onDelete={deleteTodo}
onEdit={editTodo}
/>
))}
</ul>
</div>
)
}
The TodoItem component manages edit mode internally. Double-clicking the text or clicking edit enters edit mode. The edit form saves or cancels changes. The editTodo function updates the todo text. This provides full CRUD operations with inline editing.
Best Practice Note
This is the same todo application architecture we use in CoreUI admin templates for task management features. For production applications, add input validation to prevent invalid todos, implement undo/redo functionality for better UX, and add keyboard shortcuts (Enter to add, Escape to cancel edit). Consider adding due dates, priorities, or categories for more complex task management. Implement drag-and-drop reordering for better organization. For team applications, sync todos with a backend API instead of localStorage. Use CoreUI’s form components for professionally styled inputs and buttons that work great in todo interfaces.



