Blog Post

The Ultimate Guide to Building a To-Do List App with React & Vite

Why Build a To-Do App?

Building a to-do list application is the universal rite of passage for frontend developers. It forces you to master the most critical concepts of React: managing state, handling user inputs, mutating arrays immutably, and using the core Hooks API.

In this guide, we will build a modern, lightning-fast React application using Vite (the modern replacement for Create React App), and we will ensure our tasks are saved in the browser's Local Storage so they survive page refreshes.

Step 1: Scaffolding the Project with Vite

Create React App (CRA) is officially deprecated. Today, developers use Vite for instant server starts and lightning-fast Hot Module Replacement (HMR).

Open your terminal and run:

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

Your app is now live at http://localhost:5173.

Step 2: Managing State with useState

Open src/App.jsx and clear out the default boilerplate. We need two pieces of state: one to track what the user is typing, and one to hold our array of tasks.

import { useState } from 'react'
import './App.css'

function App() {
  const [tasks, setTasks] = useState([]);
  const [input, setInput] = useState('');

  // Add a new task to the array immutably
  const addTask = (e) => {
    e.preventDefault();
    if (!input.trim()) return;
    
    const newTask = {
      id: crypto.randomUUID(), // Modern way to generate unique IDs
      text: input,
      completed: false
    };
    
    // Spread operator prevents mutating the original array
    setTasks([...tasks, newTask]); 
    setInput('');
  };

  return (
    <div className="app">
      <h1>React To-Do List</h1>
      <form onSubmit={addTask}>
        <input 
          value={input} 
          onChange={(e) => setInput(e.target.value)} 
          placeholder="Add a new task..." 
        />
        <button type="submit">Add</button>
      </form>
    </div>
  )
}

export default App

Step 3: Toggling and Deleting Tasks

Now we need to render the tasks and give users the ability to mark them as done or delete them. We will use the .map() and .filter() array methods.

// Add these functions inside your App component
const toggleTask = (id) => {
  setTasks(tasks.map(task => 
    task.id === id ? { ...task, completed: !task.completed } : task
  ));
};

const deleteTask = (id) => {
  setTasks(tasks.filter(task => task.id !== id));
};

// Update your return statement to include the list:
return (
  // ... form code above
  <ul>
    {tasks.map(task => (
      <li key={task.id} style={{ textDecoration: task.completed ? 'line-through' : 'none' }}>
        <span onClick={() => toggleTask(task.id)}>{task.text}</span>
        <button onClick={() => deleteTask(task.id)}>X</button>
      </li>
    ))}
  </ul>
)

Step 4: Persisting Data with useEffect

If you refresh the browser right now, your tasks disappear. We solve this by writing to the browser's localStorage whenever the `tasks` array changes. We use the useEffect hook to handle this side-effect.

import { useState, useEffect } from 'react'

// Load tasks from storage on initial render
const [tasks, setTasks] = useState(() => {
  const saved = localStorage.getItem('react-todos');
  return saved ? JSON.parse(saved) : [];
});

// Save tasks to storage whenever the 'tasks' array changes
useEffect(() => {
  localStorage.setItem('react-todos', JSON.stringify(tasks));
}, [tasks]);

Conclusion

Congratulations! You have built a fully functional React application. You've learned how to leverage the Virtual DOM, maintain state immutability, handle form submissions, and persist data using React Hooks. You are now ready to tackle complex component architectures and APIs!