Build a Simple To-Do List App with React: A Beginner’s Guide

In today’s fast-paced world, staying organized is crucial. To-do lists are a fundamental tool for managing tasks, boosting productivity, and reducing stress. While numerous to-do list apps exist, building your own offers a fantastic opportunity to learn and solidify your React.js skills. This tutorial will guide you through creating a simple, yet functional, to-do list application from scratch. We’ll cover the core concepts of React, including components, state management, event handling, and conditional rendering, all while building a practical application you can use every day.

Why Build a To-Do List App?

Building a to-do list app provides several benefits, particularly for developers learning React:

  • Practical Application: You’ll learn by doing, applying React concepts to a real-world problem.
  • Component-Based Architecture: To-do lists are inherently component-rich, allowing you to practice creating reusable UI elements.
  • State Management: Managing the state of tasks (adding, completing, deleting) is a core skill in React.
  • Event Handling: You’ll learn how to respond to user interactions, such as button clicks and input changes.
  • Beginner-Friendly: The project is manageable in scope, making it ideal for beginners to intermediate React developers.

By the end of this tutorial, you’ll have a fully functional to-do list app and a solid understanding of fundamental React principles.

Prerequisites

Before we begin, ensure you have the following:

  • Node.js and npm (or yarn) installed: These are essential for managing project dependencies and running the development server.
  • A basic understanding of HTML, CSS, and JavaScript: Familiarity with these languages is necessary to understand the code.
  • A code editor: Choose your preferred editor (VS Code, Sublime Text, Atom, etc.).

Setting Up the Project

Let’s get started by setting up our React project using Create React App. This tool simplifies the project setup process, allowing us to focus on the code.

  1. Create a new React app: Open your terminal and run the following command to create a new React app named “todo-app”:
npx create-react-app todo-app
  1. Navigate to the project directory: Change your directory to the newly created project:
cd todo-app
  1. Start the development server: Run the following command to start the development server. This will open the app in your browser (usually at http://localhost:3000):
npm start

You should see the default React app welcome screen in your browser. Now, let’s clean up the boilerplate code.

Cleaning Up the Boilerplate

Open the project in your code editor. We’ll start by removing unnecessary files and modifying the existing ones to match our project structure.

  1. Delete unnecessary files: In the `src` directory, delete the following files:
    • `App.css`
    • `App.test.js`
    • `logo.svg`
    • `reportWebVitals.js`
    • `setupTests.js`
  2. Modify `App.js`: Open `src/App.js` and replace its contents with the following code. This will be our main component:
import React, { useState } from 'react';

function App() {
  const [todos, setTodos] = useState([]);
  const [newTodo, setNewTodo] = useState('');

  const addTodo = () => {
    if (newTodo.trim() !== '') {
      setTodos([...todos, { id: Date.now(), text: newTodo, completed: false }]);
      setNewTodo('');
    }
  };

  const toggleComplete = (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="container">
      <h1>To-Do List</h1>
      <div className="input-group">
        <input
          type="text"
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder="Add a new task..."
        />
        <button onClick={addTodo}>Add</button>
      </div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            <span onClick={() => toggleComplete(todo.id)}>{todo.text}</span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;
  1. Create `App.css`: Create a new file named `App.css` in the `src` directory and add the following CSS to style your app:
.container {
  max-width: 500px;
  margin: 20px auto;
  padding: 20px;
  border: 1px solid #ccc;
  border-radius: 5px;
  background-color: #f9f9f9;
}

h1 {
  text-align: center;
  color: #333;
}

.input-group {
  display: flex;
  margin-bottom: 10px;
}

input[type="text"] {
  flex-grow: 1;
  padding: 8px;
  border: 1px solid #ccc;
  border-radius: 4px;
}

button {
  padding: 8px 15px;
  background-color: #4CAF50;
  color: white;
  border: none;
  border-radius: 4px;
  cursor: pointer;
  margin-left: 10px;
}

button:hover {
  background-color: #3e8e41;
}

ul {
  list-style: none;
  padding: 0;
}

li {
  display: flex;
  align-items: center;
  padding: 10px;
  border-bottom: 1px solid #eee;
}

li:last-child {
  border-bottom: none;
}

span {
  flex-grow: 1;
  cursor: pointer;
}

.completed {
  text-decoration: line-through;
  color: #888;
}
  1. Import `App.css` in `App.js`: Add the following line at the top of `src/App.js`:
import './App.css';

Your basic project structure is now set up! The code in `App.js` currently includes the basic structure of the to-do list, and `App.css` provides the styling.

Understanding the Code

Let’s break down the code in `App.js` to understand how it works:

Import React and useState

import React, { useState } from 'react';

This line imports the `React` library and the `useState` hook. The `useState` hook allows us to manage the component’s state.

State Variables

const [todos, setTodos] = useState([]);
const [newTodo, setNewTodo] = useState('');

We declare two state variables using the `useState` hook:

  • `todos`: An array that stores our to-do items. It’s initialized as an empty array.
  • `newTodo`: A string that stores the text of the new to-do item being entered in the input field. It’s initialized as an empty string.

`addTodo` Function

const addTodo = () => {
  if (newTodo.trim() !== '') {
    setTodos([...todos, { id: Date.now(), text: newTodo, completed: false }]);
    setNewTodo('');
  }
};

This function is called when the “Add” button is clicked. It does the following:

  • Checks if the `newTodo` input is not empty (after trimming whitespace).
  • Uses the spread operator (`…`) to create a new array with the existing `todos` and adds a new todo object to it. The new todo object includes:
    • `id`: A unique identifier generated using `Date.now()`.
    • `text`: The text entered in the `newTodo` input.
    • `completed`: A boolean value, initialized to `false`.
  • Updates the `todos` state using `setTodos`.
  • Clears the `newTodo` input by setting `setNewTodo` to an empty string.

`toggleComplete` Function

const toggleComplete = (id) => {
  setTodos(
    todos.map((todo) =>
      todo.id === id ? { ...todo, completed: !todo.completed } : todo
    )
  );
};

This function is called when a to-do item’s text is clicked. It does the following:

  • Uses the `map` method to iterate over the `todos` array.
  • For each to-do item, it checks if its `id` matches the `id` passed to the function.
  • If the IDs match, it creates a new object using the spread operator (`…`) to update the `completed` property to its opposite value (`!todo.completed`).
  • If the IDs don’t match, it returns the original `todo` object.
  • Updates the `todos` state using `setTodos`.

`deleteTodo` Function

const deleteTodo = (id) => {
  setTodos(todos.filter((todo) => todo.id !== id));
};

This function is called when the “Delete” button is clicked. It does the following:

  • Uses the `filter` method to create a new array containing only the to-do items whose `id` does not match the `id` passed to the function.
  • Updates the `todos` state using `setTodos`.

JSX Structure (Return Statement)

The `return` statement contains the JSX (JavaScript XML) that renders the UI:

  • Container: A `div` with the class name “container” to hold the entire to-do list.
  • Heading: An `h1` element with the text “To-Do List”.
  • Input Group: A `div` with the class name “input-group” containing:
    • An `input` field for entering new tasks. The `value` is bound to the `newTodo` state, and the `onChange` event updates the `newTodo` state when the input changes.
    • An `Add` button that calls the `addTodo` function when clicked.
  • To-Do List: An unordered list (`ul`) that iterates over the `todos` array using the `map` method. For each to-do item:
    • A list item (`li`) with a unique `key` (using `todo.id`).
    • A `span` element displaying the to-do item’s text. The `onClick` event calls the `toggleComplete` function.
    • A `Delete` button that calls the `deleteTodo` function when clicked.
  • Conditional Styling: The `li` element has a class name of “completed” if the `todo.completed` property is true, applying a line-through style to the text.

Adding Functionality

Now, let’s add the core functionality to our app. We’ll start by implementing the ability to add new tasks, mark them as complete, and delete them.

Adding Tasks

We’ve already implemented the `addTodo` function in the previous step. It takes the value from the input field, creates a new todo object, and adds it to the `todos` array. The input field’s `onChange` event is bound to the `setNewTodo` function, which updates the `newTodo` state as the user types.

Testing: Type a task in the input field and click the “Add” button. The task should appear in the to-do list below.

Completing Tasks

The `toggleComplete` function handles the toggling of the “completed” state of a to-do item. When the user clicks the text of a to-do item, this function is called. It updates the `completed` property of that specific item in the `todos` array, changing its visual appearance (strikethrough). The CSS class `.completed` handles the visual styling.

Testing: Click on a task in the list. It should be marked as complete (strikethrough). Click it again to unmark it.

Deleting Tasks

The `deleteTodo` function removes a to-do item from the `todos` array. It filters the array, keeping only the items whose IDs do not match the ID of the item to be deleted. The “Delete” button next to each task triggers this function.

Testing: Click the “Delete” button next to a task. The task should be removed from the list.

Advanced Features (Optional)

Once you have the basic functionality working, you can add more features to enhance your to-do list app. Here are some ideas:

  • Local Storage: Save the to-do list data to the browser’s local storage so that the tasks persist even when the user closes the browser.
  • Edit Tasks: Add an edit feature to allow users to modify the text of existing tasks.
  • Prioritization: Implement a way to prioritize tasks (e.g., using different colors or a drag-and-drop feature).
  • Due Dates: Add the ability to set due dates for tasks.
  • Filtering: Implement filters to show all tasks, completed tasks, or incomplete tasks.
  • Drag and Drop: Implement drag-and-drop functionality to reorder the tasks.
  • Themes: Allow users to choose different themes for the app.

Let’s add the Local Storage feature to store the to-do list data. This will ensure that the data persists even after the user closes the browser.

Implementing Local Storage

To implement local storage, we’ll use the `useEffect` hook. This hook allows us to perform side effects, such as interacting with the browser’s local storage. We’ll use two `useEffect` hooks:

  1. Saving to Local Storage: This `useEffect` hook will save the `todos` array to local storage whenever it changes.
  2. Loading from Local Storage: This `useEffect` hook will load the `todos` array from local storage when the component mounts.

Modify your `App.js` file as follows:

import React, { useState, useEffect } from 'react';

function App() {
  const [todos, setTodos] = useState(() => {
    // Load todos from local storage on initial render
    const savedTodos = localStorage.getItem('todos');
    return savedTodos ? JSON.parse(savedTodos) : [];
  });
  const [newTodo, setNewTodo] = useState('');

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

  const addTodo = () => {
    if (newTodo.trim() !== '') {
      setTodos([...todos, { id: Date.now(), text: newTodo, completed: false }]);
      setNewTodo('');
    }
  };

  const toggleComplete = (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="container">
      <h1>To-Do List</h1>
      <div className="input-group">
        <input
          type="text"
          value={newTodo}
          onChange={(e) => setNewTodo(e.target.value)}
          placeholder="Add a new task..."
        />
        <button onClick={addTodo}>Add</button>
      </div>
      <ul>
        {todos.map((todo) => (
          <li key={todo.id} className={todo.completed ? 'completed' : ''}>
            <span onClick={() => toggleComplete(todo.id)}>{todo.text}</span>
            <button onClick={() => deleteTodo(todo.id)}>Delete</button>
          </li>
        ))}
      </ul>
    </div>
  );
}

export default App;

Here’s a breakdown of the changes:

  • Initial State with Local Storage: The `todos` state is now initialized with a function. This function attempts to load data from local storage when the component first renders.
    • const [todos, setTodos] = useState(() => { ... });
    • Inside the function:
      • const savedTodos = localStorage.getItem('todos'); retrieves the stored data.
      • return savedTodos ? JSON.parse(savedTodos) : []; parses the JSON if it exists and returns it, otherwise returns an empty array.
  • Saving to Local Storage with `useEffect`: The `useEffect` hook is used to save the `todos` array to local storage whenever the `todos` state changes.
    • useEffect(() => { ... }, [todos]);
    • Inside the hook:
      • localStorage.setItem('todos', JSON.stringify(todos)); converts the `todos` array to a JSON string and saves it in local storage.
    • The dependency array `[todos]` ensures that this effect runs only when the `todos` state changes.

Testing the Local Storage feature:

  1. Add some tasks to your to-do list.
  2. Refresh the page. The tasks should still be there.
  3. Close the browser tab and reopen the app. The tasks should persist.

Common Mistakes and How to Fix Them

When building a React to-do list app, developers often encounter common mistakes. Here are a few and how to address them:

1. Incorrect State Updates

Mistake: Directly modifying the state variables instead of using the `set…` functions provided by `useState`. For example, trying to modify the `todos` array directly: `todos.push(…)`. This can lead to unexpected behavior and UI updates not reflecting the changes.

Fix: Always use the `setTodos` function to update the `todos` state. When updating arrays or objects, create a new copy of the array or object with the changes using the spread operator (`…`) or the `map` method. This ensures that React detects the state change and re-renders the component.


// Incorrect: Directly modifying the state
const addTodo = () => {
  todos.push({ id: Date.now(), text: newTodo, completed: false }); // Incorrect
  setNewTodo('');
};

// Correct: Using setTodos and the spread operator
const addTodo = () => {
  if (newTodo.trim() !== '') {
    setTodos([...todos, { id: Date.now(), text: newTodo, completed: false }]); // Correct
    setNewTodo('');
  }
};

2. Missing or Incorrect Keys in `map`

Mistake: Forgetting to include a unique `key` prop when rendering a list of items using the `map` method, or using the wrong key. This can lead to React not efficiently updating the list and potential rendering issues.

Fix: Always provide a unique `key` prop to each element rendered within a `map` function. The `key` should be stable, unique, and ideally, a property of the data being rendered (like an `id`).


// Incorrect: Missing key
{todos.map((todo) => (
  <li>{todo.text}</li>
))}

// Correct: Using a unique key (todo.id)
{todos.map((todo) => (
  <li key={todo.id}>{todo.text}</li>
))}

3. Incorrect Event Handling

Mistake: Not understanding how to handle events in React, especially passing arguments to event handlers. This can lead to unexpected behavior, such as incorrect data being passed or functions not being called correctly.

Fix: When passing arguments to event handlers, use an arrow function to wrap the handler call. This ensures that the function is called with the correct arguments when the event occurs.


// Incorrect: Incorrect argument passing
<button onClick={deleteTodo(todo.id)}>Delete</button> // Incorrect: deleteTodo is called immediately

// Correct: Using an arrow function
<button onClick={() => deleteTodo(todo.id)}>Delete</button> // Correct: deleteTodo is called when the button is clicked

4. Improper Use of `useEffect`

Mistake: Not understanding the purpose and usage of the `useEffect` hook, particularly its dependency array. This can lead to infinite loops, performance issues, or incorrect data fetching.

Fix: The `useEffect` hook’s dependency array is crucial. It tells React when to re-run the effect. If the effect doesn’t depend on any state or props, you can omit the dependency array (but be careful!). If the effect depends on specific state or props, include them in the dependency array. If you want the effect to run only once after the initial render, pass an empty dependency array (`[]`).


// Run the effect only once after the initial render (e.g., fetching data)
useEffect(() => {
  // Fetch data
}, []);

// Run the effect whenever the 'todos' state changes (e.g., saving data to local storage)
useEffect(() => {
  // Save to local storage
}, [todos]);

5. CSS Styling Issues

Mistake: Not understanding how CSS works with React components or making CSS-related errors such as incorrect class names, or not importing the CSS file correctly.

Fix:

  • Import CSS files: Make sure you import your CSS file in the component where you are using the styles. For example: `import ‘./App.css’;`
  • Use the `className` prop: Use the `className` prop to apply CSS classes to your HTML elements.
  • Check your class names: Double-check that your class names in your CSS file match the class names used in your JSX.
  • Use the correct CSS selectors: Make sure your CSS selectors are correctly targeting the elements you want to style.

Key Takeaways

In this tutorial, we’ve walked through the process of building a simple to-do list app with React. We covered the following key concepts:

  • Project Setup: Using Create React App to quickly set up a React project.
  • Component Structure: Understanding the structure of a React component and how to render UI elements.
  • State Management: Using the `useState` hook to manage component state and trigger re-renders.
  • Event Handling: Handling user interactions, such as button clicks and input changes.
  • Conditional Rendering: Rendering different UI elements based on the component’s state.
  • Local Storage: Persisting data using the browser’s local storage.

By building this app, you’ve gained practical experience with fundamental React concepts. You’ve learned how to structure a React application, manage state, handle events, and create dynamic UI elements. This project serves as a solid foundation for building more complex React applications.

FAQ

Here are some frequently asked questions about building a to-do list app with React:

  1. Can I use a different state management library instead of `useState`?

    Yes, you can. While `useState` is great for simple state management, for more complex applications, you might consider using libraries like Redux, Zustand, or Context API. These libraries offer more advanced features and are helpful for managing global state across your application.

  2. How can I deploy this app?

    You can deploy your React app to various platforms, such as Netlify, Vercel, or GitHub Pages. These platforms provide free hosting and make it easy to deploy your app with just a few clicks. You’ll typically build your app for production using `npm run build` before deploying.

  3. How do I add more advanced features?

    You can expand this app by adding features like: drag-and-drop functionality for reordering tasks, due dates, priority levels, and filters to sort tasks by status (e.g., completed, incomplete). You can also integrate with a backend to store the data in a database.

  4. What are some good resources for learning more about React?

    There are many excellent resources available, including the official React documentation, online courses (e.g., Udemy, Coursera, freeCodeCamp), and tutorials on websites like Medium and freeCodeCamp.

  5. How can I style my React components?

    You can style your React components using various methods, including inline styles, CSS files (as shown in this tutorial), CSS modules, styled-components, and Tailwind CSS.

Building a to-do list app is an excellent starting point for learning React. As you continue to build and experiment, you’ll gain a deeper understanding of React’s capabilities and how to apply them to create interactive and engaging user interfaces. Remember to practice consistently, experiment with different features, and embrace the learning process. The skills you acquire while building this app will be invaluable as you progress in your React development journey. This project is just the first step; the possibilities are endless, and your skills will continue to grow with each new project you undertake.