Building Your First React Todo List Application

Master React fundamentals by building a production-ready todo app. Learn component structure, state management with hooks, accessibility best practices, and modern React patterns.

Why a Todo App is the Perfect Starting Point

The todo list is the "Hello World" of React applications, but building one properly teaches fundamental concepts that scale to production applications. Modern React development with hooks, proper state management, and performance considerations from the start.

This guide walks through building a production-ready todo app that demonstrates React best practices. Every pattern you learn here transfers directly to building complex web applications for our web development team.

What you'll learn:

  • Component architecture and composition patterns
  • State management with the useState hook
  • CRUD operations (create, read, update, delete)
  • Accessibility implementation (ARIA attributes)
  • Modern CSS for React components
  • When to scale with additional libraries
Core Concepts Covered

Everything you need to build a professional todo application

Project Setup

Initialize a React project with Vite for optimal developer experience and fast builds.

Component Structure

Learn how to organize components for maintainability and reusability.

State Management

Master useState hook and proper state update patterns for React applications.

CRUD Operations

Implement create, read, update, and delete functionality for todos.

Accessibility

Build inclusive apps with ARIA attributes and semantic HTML from day one.

Modern Styling

Apply CSS patterns that scale with your React application.

Setting Up Your React Project

Modern React development starts with modern tooling. Vite provides a lightning-fast development experience with instant server start and Hot Module Replacement (HMR) that makes development smooth and efficient.

Project Initialization

npm create vite@latest todo-app -- --template react
cd todo-app
npm install
npm run dev

Why Vite?

Vite offers significant advantages over older tooling:

  • Instant server start - No bundling during development
  • Lightning-fast HMR - Changes reflect immediately
  • Optimized builds - Production builds are highly optimized
  • Modern defaults - ES modules, TypeScript support, and more

Recommended Project Structure

src/
├── components/
│ ├── TodoApp.jsx # Main container with state
│ ├── TodoForm.jsx # Form for adding new todos
│ ├── TodoList.jsx # Renders the todo list
│ ├── TodoItem.jsx # Individual todo with checkbox and actions
│ └── TodoFilters.jsx # Filter buttons (All/Active/Completed)
├── hooks/
│ └── useTodos.js # Custom hook for todo logic
├── App.jsx
└── main.jsx

Following this structured approach ensures your codebase remains maintainable as complexity grows, a principle our professional web development services apply to every project we build.

Project Setup Commands
1# Create a new React project with Vite2npm create vite@latest my-todo-app -- --template react3 4# Navigate to the project5cd my-todo-app6 7# Install dependencies8npm install9 10# Start the development server11npm run dev12 13# When ready for production14npm run build

Building the Todo App Structure

A well-structured todo app demonstrates how React components should be organized in production applications. The component hierarchy reflects data flow and makes the application easy to maintain and extend.

Component Hierarchy

TodoApp (main container, holds todos state)
├── TodoForm (input for new todos)
├── TodoFilters (filter buttons: All/Active/Completed)
└── TodoList (renders filtered todos)
 └── TodoItem (individual todo with checkbox and actions)

The Initial TodoApp Component

function TodoApp() {
 return (
 <div className="todoapp stack-large">
 <h1>TodoMatic</h1>
 <form>
 <h2 className="label-wrapper">
 <label htmlFor="new-todo-input" className="label__lg">
 What needs to be done?
 </label>
 </h2>
 <input
 type="text"
 id="new-todo-input"
 className="input input__lg"
 name="text"
 autoComplete="off"
 />
 <button type="submit" className="btn btn__primary btn__lg">
 Add
 </button>
 </form>
 </div>
 );
}

export default TodoApp;

This foundational structure, based on MDN's comprehensive React tutorial, establishes the component hierarchy and data flow that will scale as features grow.

Accessibility Considerations from the Start

Building accessible applications isn't an afterthought--it should be part of your initial component design. React makes it straightforward to implement proper accessibility patterns.

Key Accessibility Features

<button
 type="button"
 className="btn toggle-btn"
 aria-pressed="true"
>
 <span className="visually-hidden">Show </span>
 <span>all</span>
 <span className="visually-hidden"> tasks</span>
</button>

Accessibility explanations:

  • aria-pressed indicates the toggle button state for screen readers
  • visually-hidden text provides context to assistive technology without cluttering the visual design
  • Semantic HTML (<button>, <label>, <ul>) improves both accessibility and SEO
  • htmlFor on labels ensures form fields are properly associated

Visually Hidden CSS Pattern

.visually-hidden {
 position: absolute;
 width: 1px;
 height: 1px;
 padding: 0;
 margin: -1px;
 overflow: hidden;
 clip: rect(0, 0, 0, 0);
 white-space: nowrap;
 border: 0;
}

This CSS ensures screen readers can access the context text while keeping it invisible to sighted users, following the accessibility patterns documented by MDN Web Docs.

Managing State with React Hooks

The useState hook is the foundation of React state management. Understanding how to structure and update state properly is essential for building reliable applications. Whether you're building a simple todo app or a complex business application, these state management principles apply universally across our web development projects.

Using useState

import { useState } from 'react';

function TodoApp() {
 const [todos, setTodos] = useState([
 { id: 1, text: 'Learn React', completed: false },
 { id: 2, text: 'Build a todo app', completed: false },
 { id: 3, text: 'Deploy to production', completed: false }
 ]);

 const [filter, setFilter] = useState('all');

 // ... event handlers
}

State Structure Best Practices

Do this:

// Normalized data with unique IDs
const [todos, setTodos] = useState([
 { id: 'todo-1', text: 'Task 1', completed: false },
 { id: 'todo-2', text: 'Task 2', completed: true }
]);

// Filter state separate from data
const [filter, setFilter] = useState('all');

Avoid this:

// Redundant state that must be kept in sync
const [completedTodos, setCompletedTodos] = useState([]);
const [activeTodos, setActiveTodos] = useState([]);

// Derived state should be calculated, not stored
const allTodos = [...completedTodos, ...activeTodos];

These patterns align with React's official guidance on state structure.

Proper State Updates

// Adding a new todo - use spread operator
const addTodo = (text) => {
 const newTodo = {
 id: crypto.randomUUID(), // or Date.now()
 text,
 completed: false
 };
 setTodos([...todos, newTodo]);
};

// Toggling completion - map and preserve other items
const toggleTodo = (id) => {
 setTodos(todos.map(todo =>
 todo.id === id
 ? { ...todo, completed: !todo.completed }
 : todo
 ));
};

// Deleting a todo - filter out the matching item
const deleteTodo = (id) => {
 setTodos(todos.filter(todo => todo.id !== id));
};

Implementing CRUD Operations

A complete todo application needs to handle all CRUD operations: Create, Read, Update, and Delete. Each operation has specific patterns in React.

Creating New Todos

function TodoForm({ onAddTodo }) {
 const [inputValue, setInputValue] = useState('');

 const handleSubmit = (e) => {
 e.preventDefault();
 if (inputValue.trim()) {
 onAddTodo(inputValue.trim());
 setInputValue(''); // Clear input after adding
 }
 };

 return (
 <form onSubmit={handleSubmit}>
 <label htmlFor="new-todo-input">What needs to be done?</label>
 <input
 type="text"
 id="new-todo-input"
 value={inputValue}
 onChange={(e) => setInputValue(e.target.value)}
 placeholder="Add a new task..."
 aria-label="Add a new todo item"
 />
 <button type="submit">Add</button>
 </form>
 );
}

Reading and Displaying Todos

function TodoList({ todos, onToggle, onDelete }) {
 if (todos.length === 0) {
 return <p className="empty-state">No todos yet. Add one above!</p>;
 }

 return (
 <ul role="list" aria-label="Todo list">
 {todos.map(todo => (
 <TodoItem
 key={todo.id}
 todo={todo}
 onToggle={onToggle}
 onDelete={onDelete}
 />
 ))}
 </ul>
 );
}

function TodoItem({ todo, onToggle, onDelete }) {
 return (
 <li className={`todo ${todo.completed ? 'completed' : ''}`}>
 <div className="todo-content">
 <input
 type="checkbox"
 id={`todo-${todo.id}`}
 checked={todo.completed}
 onChange={() => onToggle(todo.id)}
 aria-label={`Mark ${todo.text} as ${todo.completed ? 'incomplete' : 'complete'}`}
 />
 <label htmlFor={`todo-${todo.id}`}>
 {todo.text}
 </label>
 </div>
 <div className="todo-actions">
 <button
 type="button"
 onClick={() => onDelete(todo.id)}
 className="btn btn__danger"
 aria-label={`Delete ${todo.text}`}
 >
 Delete
 </button>
 </div>
 </li>
 );
}

The list rendering pattern follows React's official documentation on rendering lists, which emphasizes the critical importance of stable keys for efficient updates.

Editing Todos (Inline)

function TodoItem({ todo, onToggle, onDelete, onEdit }) {
 const [isEditing, setIsEditing] = useState(false);
 const [editText, setEditText] = useState(todo.text);

 const handleEdit = () => {
 if (editText.trim()) {
 onEdit(todo.id, editText.trim());
 setIsEditing(false);
 }
 };

 if (isEditing) {
 return (
 <li className="todo editing">
 <input
 type="text"
 value={editText}
 onChange={(e) => setEditText(e.target.value)}
 onBlur={handleEdit}
 onKeyDown={(e) => e.key === 'Enter' && handleEdit()}
 autoFocus
 aria-label="Edit todo"
 />
 <button type="button" onClick={handleEdit}>Save</button>
 </li>
 );
 }

 return (
 <li className={`todo ${todo.completed ? 'completed' : ''}`}>
 {/* Display mode markup */}
 </li>
 );
}

Filtering Todos

function TodoFilters({ currentFilter, onFilterChange }) {
 const filters = [
 { value: 'all', label: 'All' },
 { value: 'active', label: 'Active' },
 { value: 'completed', label: 'Completed' }
 ];

 return (
 <div className="filters btn-group" role="group" aria-label="Filter todos">
 {filters.map(filter => (
 <button
 key={filter.value}
 type="button"
 className={`btn toggle-btn ${currentFilter === filter.value ? 'active' : ''}`}
 aria-pressed={currentFilter === filter.value}
 onClick={() => onFilterChange(filter.value)}
 >
 <span className="visually-hidden">Show </span>
 <span>{filter.label}</span>
 <span className="visually-hidden"> tasks</span>
 </button>
 ))}
 </div>
 );
}

// Filter logic
const getFilteredTodos = (todos, filter) => {
 switch (filter) {
 case 'active':
 return todos.filter(todo => !todo.completed);
 case 'completed':
 return todos.filter(todo => todo.completed);
 default:
 return todos;
 }
};

Styling the Todo App

Modern React applications deserve modern CSS. The todo app demonstrates how to structure styles that are maintainable, responsive, and accessible.

Core CSS Architecture

/* Layout utilities */
.stack > * + * {
 margin-top: 1rem;
}

.stack-large > * + * {
 margin-top: 2rem;
}

/* Main container */
.todoapp {
 max-width: 600px;
 margin: 0 auto;
 padding: 2rem;
}

/* Todo item styling */
.todo {
 display: flex;
 align-items: center;
 justify-content: space-between;
 padding: 1rem;
 border: 1px solid #e0e0e0;
 border-radius: 8px;
 background: white;
 transition: box-shadow 0.2s ease;
}

.todo:hover {
 box-shadow: 0 2px 8px rgba(0, 0, 0, 0.1);
}

.todo.completed label {
 text-decoration: line-through;
 color: #888;
}

/* Form inputs */
.input__lg {
 width: 100%;
 padding: 0.75rem 1rem;
 font-size: 1.125rem;
 border: 2px solid #e0e0e0;
 border-radius: 8px;
 transition: border-color 0.2s ease;
}

.input__lg:focus {
 outline: none;
 border-color: #007bff;
}

/* Button variants */
.btn {
 padding: 0.5rem 1rem;
 border: 1px solid #ddd;
 border-radius: 6px;
 background: white;
 cursor: pointer;
 font-size: 1rem;
 transition: all 0.2s ease;
}

.btn:hover {
 background: #f5f5f5;
}

.btn__primary {
 background: #007bff;
 color: white;
 border-color: #007bff;
}

.btn__primary:hover {
 background: #0056b3;
}

.btn__danger {
 background: #dc3545;
 color: white;
 border-color: #dc3545;
}

.btn__danger:hover {
 background: #c82333;
}

.btn__lg {
 padding: 0.75rem 1.5rem;
 font-size: 1.125rem;
}

/* Filter buttons */
.filters.btn-group {
 display: flex;
 gap: 0.5rem;
}

.toggle-btn[aria-pressed="true"] {
 background: #007bff;
 color: white;
 border-color: #007bff;
}

/* Accessibility utility */
.visually-hidden {
 position: absolute;
 width: 1px;
 height: 1px;
 padding: 0;
 margin: -1px;
 overflow: hidden;
 clip: rect(0, 0, 0, 0);
 white-space: nowrap;
 border: 0;
}

These styling patterns, derived from MDN's React tutorial approach, prioritize readability and maintainability.

Responsive Design

@media (max-width: 600px) {
 .todoapp {
 padding: 1rem;
 }

 .btn__lg {
 width: 100%;
 margin-top: 0.5rem;
 }

 .input__lg {
 font-size: 1rem;
 }

 .todo {
 flex-direction: column;
 align-items: flex-start;
 gap: 0.75rem;
 }

 .todo-actions {
 width: 100%;
 display: flex;
 justify-content: flex-end;
 }

 .filters.btn-group {
 flex-wrap: wrap;
 }
}

Connecting to Modern Data Patterns

While local state works for simple todo apps, production applications often need server-side persistence and more sophisticated state management. For complex applications requiring AI-powered features or automation, our AI automation services can help scale your React applications further.

When to Consider TanStack Query

For todo apps that need:

  • Server-side persistence - Save todos to a database
  • Cache management - Automatic caching and refetching
  • Optimistic updates - Immediate UI updates while saving
  • Background sync - Keep data fresh without user action
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';

function useTodos() {
 const queryClient = useQueryClient();

 const { data: todos, isLoading } = useQuery({
 queryKey: ['todos'],
 queryFn: async () => {
 const response = await fetch('/api/todos');
 return response.json();
 }
 });

 const addTodoMutation = useMutation({
 mutationFn: async (newTodo) => {
 const response = await fetch('/api/todos', {
 method: 'POST',
 body: JSON.stringify(newTodo)
 });
 return response.json();
 },
 onSuccess: () => {
 queryClient.invalidateQueries(['todos']);
 }
 });

 return { todos, isLoading, addTodoMutation };
}

TanStack Query has become the industry standard for server state, as analyzed in recent state management discussions.

URL State for Persistence

Store filter state in the URL for shareable links:

import { useSearchParams } from 'react-router';

function TodoFilters({ currentFilter, onFilterChange }) {
 const [searchParams, setSearchParams] = useSearchParams();

 const handleFilterChange = (filter) => {
 setSearchParams({ filter });
 onFilterChange(filter);
 };

 // Get initial filter from URL
 const filterFromUrl = searchParams.get('filter') || 'all';

 return (
 <div className="filters">
 {['all', 'active', 'completed'].map(filter => (
 <button
 key={filter}
 onClick={() => handleFilterChange(filter)}
 className={filterFromUrl === filter ? 'active' : ''}
 >
 {filter.charAt(0).toUpperCase() + filter.slice(1)}
 </button>
 ))}
 </div>
 );
}

Custom Hook for Reusable Logic

Extract todo logic into a custom hook:

function useTodos() {
 const [todos, setTodos] = useState([
 { id: '1', text: 'Learn React', completed: false }
 ]);
 const [filter, setFilter] = useState('all');

 const addTodo = (text) => {
 const newTodo = {
 id: crypto.randomUUID(),
 text,
 completed: false
 };
 setTodos([...todos, newTodo]);
 };

 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, text) => {
 setTodos(todos.map(todo =>
 todo.id === id ? { ...todo, text } : todo
 ));
 };

 const filteredTodos = todos.filter(todo => {
 if (filter === 'active') return !todo.completed;
 if (filter === 'completed') return todo.completed;
 return true;
 });

 return {
 todos: filteredTodos,
 allTodos: todos,
 filter,
 setFilter,
 addTodo,
 toggleTodo,
 deleteTodo,
 editTodo
 };
}
Best Practices Summary

Key principles for production-ready React applications

Single Source of Truth

Keep state in one place, derive what you need. Avoid redundant state that must be synchronized.

Immutable Updates

Never mutate state directly. Use spread operator, map(), and filter() for predictable updates.

Component Composition

Build small, focused components that do one thing well. Compose them for complex UIs.

Accessibility First

Implement ARIA attributes and semantic HTML from the start. Inclusive apps are better apps.

Modern Tooling

Use Vite for fast development. Leverage React hooks for cleaner, more intuitive code.

Scale Thoughtfully

Know when local state is enough and when to introduce libraries like TanStack Query.

Frequently Asked Questions

Ready to Build Professional React Applications?

Our team specializes in building custom React applications with modern best practices, optimal performance, and scalable architecture.

Sources

  1. MDN Web Docs: Beginning our React ToDo app - Comprehensive tutorial covering todo app structure, styling, and accessibility patterns
  2. React.dev: Managing State - Official React documentation on state management principles and useState patterns
  3. Developer Way: React State Management in 2025 - Modern perspective on state management covering TanStack Query and Context API patterns