If creating the Hello, World! example was a celebration of you getting your feet wet with React, creating the quintessential Todo List app is a celebration of you approaching React mastery! In this tutorial, we are going to tie together a lot of the concepts and techniques you've learned to create something that works as follows:
The way this Todo List app works is pretty simple. Type in a task or item or whatever you want into the input field and press Add (or hit Enter/Return). Once you've submitted your item, you will see it appear as an entry. You can keep adding items to have them all show up.
To remove an item, just click on an existing entry. That entry will politely slide away. Pretty simple, right? In the following sections, we will build this app from scratch using a lot of the adrenaline-inducing techniques we've learned so far (function components, props, useState, events, and lists):

This is going to be a fun exercise where we build each part of the app and learn (in awesomely painstaking detail) how the various little things work along the way.
Onwards!
The first step is to create a new React app. From your command line, navigate to the folder you want to create your new project, and enter the following:
npm create vite@latest todolist -- --template react
Press Enter/Return to run that command. Vite will ask you a couple of questions. The first is which linter you'd like to use (the default is fine), and the second is whether you'd like to install everything and start the dev server right now. Saying yes to that second question is the quickest path. If you said no (or if you like doing things by hand), go into your new folder and run these two commands:
cd todolist
npm install
A few moments later, a brand new React project will be ready for you. Since we want to start from a blank slate, we are going to delete some of the demo content Vite gives us. Inside the src folder, delete App.jsx, App.css, and the assets folder. We won't need any of them.
By now, you know the drill. We need a starting point, so let's look at our HTML document. Open the index.html file in the root of your project (not inside src). There is only one change to make here, and that is to give our page a better title:
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<link rel="icon" type="image/svg+xml" href="/favicon.svg" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>Todo List</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.jsx"></script>
</body>
</html>
Everything else is already set up for us. The real magic is going to be happening in our src directory where our JavaScript and CSS files will live. Open index.css and replace everything inside it with the following style rules:
body {
padding: 50px;
background-color: #66CCFF;
font-family: sans-serif;
}
#root {
display: flex;
justify-content: center;
}
Once you have done this, let's work on the JavaScript that rounds out our starting page. Open main.jsx and replace its contents with the following:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
createRoot(document.getElementById("root")).render(
<StrictMode>
<p>Hello!</p>
</StrictMode>
);
Take a moment to look at what we've just added. By now, you should be fully familiar with what is going on with the HTML, CSS, and JavaScript at this point. What we really have is the foundation. To see it in action, run the following command from inside your project folder (you can skip this if Vite already started the dev server for you):
npm run dev
Open the address Vite prints in your terminal (it is usually http://localhost:5173/), and you'll see a lonely Hello! on a blue background. That is exactly what we want. In the following sections, we'll build on top of this all the various pieces that make up the rest of our Todo List app.
Right now, our app doesn't do a whole lot. It doesn't look like much either. We'll deal with the functionality in a little bit, but first let's get the various UI elements up and running. That isn't very complicated for our app! The first thing we are going to do is get our input field and button to appear. This is all done by using the div, form, input, and button elements!
All of that will live inside a component we are going to call TodoList. In your src folder, add a file called TodoList.jsx. Inside this file, add the following things:
function TodoList() {
return (
<div className="todoListMain">
<div className="header">
<form>
<input placeholder="enter task" />
<button type="submit">add</button>
</form>
</div>
</div>
);
}
export default TodoList;
Take a moment to glance at what we've added. This is a function component that returns a bunch of JSX that gets our form elements up and running. To use our newly created TodoList component, let's go back to main.jsx and reference it to see what our app looks like right now. Go ahead and make the following two changes:
import { StrictMode } from "react";
import { createRoot } from "react-dom/client";
import "./index.css";
import TodoList from "./TodoList.jsx";
createRoot(document.getElementById("root")).render(
<StrictMode>
<TodoList />
</StrictMode>
);
Once you've done this, save all of your changes and preview in your browser (if your dev server is still running, the page updates on its own). If everything worked, you'll see something that looks as follows:

Right now, we have our input field and submit button showing up. These two UI elements neither work nor look all that visually appealing. We'll fix that up in a little bit, but first let's talk about how we are going to add the rest of the app's functionality.
As you can imagine, getting the initial UI elements to show up is the easy part. Tying up all of the various visuals with the underlying data is where the real work lies. This work can roughly be divided into five parts:
Individually, all of these little implementation details are easy to wrap our brain around. When we put them together, there are a few things to watch out for. We will look at all that and more in the following sections.
The first major thing we'll tackle is dealing with the data. Our app needs to remember two things. The first is the list of items that have been added so far. The second is whatever the user is currently typing into the input field. Both of these will live as state inside our TodoList component, so go ahead and make the following highlighted changes:
import { useState } from "react";
function TodoList() {
const [items, setItems] = useState([]);
const [text, setText] = useState("");
return (
<div className="todoListMain">
<div className="header">
<form>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="enter task"
aria-label="New task"
/>
<button type="submit">add</button>
</form>
</div>
</div>
);
}
export default TodoList;
Our items state starts off as an empty array. It will store each of the entries that you can enter. Our text state starts off as an empty string, and it is wired up to our input element in two ways. The value prop makes the input always display whatever is stored in text. The onChange event handler updates text each time you type a character. This means React (and not the DOM) is in charge of what the input contains, which is why this is called a controlled input. We'll talk more about that in a few moments.
The aria-label we added is for people using screen readers. Our input has no visible label (just placeholder text), so this gives it a name that gets announced.
Next up is dealing with the form being submitted. Add the following highlighted lines just above the return statement, and then hook up the onSubmit event on our form:
function TodoList() {
const [items, setItems] = useState([]);
const [text, setText] = useState("");
function addItem(e) {
e.preventDefault();
const task = text.trim();
if (task === "") {
return;
}
const newItem = {
id: crypto.randomUUID(),
text: task,
};
setItems((prevItems) => [...prevItems, newItem]);
setText("");
}
return (
<div className="todoListMain">
<div className="header">
<form onSubmit={addItem}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="enter task"
aria-label="New task"
/>
<button type="submit">add</button>
</form>
</div>
</div>
);
}
We listen for the submit event on the form itself, and we call the addItem function when that event is overheard. Notice that we aren't listening for any event on the button itself. The reason is that our button has a type attribute set to submit. This is one of those HTML trickeries where clicking on the button whose type is submit (or pressing Enter/Return inside the input) is the equivalent of the submit event on the form being fired.
Now, let's walk through what addItem does. The first line is this:
e.preventDefault();
We are overriding this event's default behavior. The reason has to do with how form submission works. By default, when you submit a form, the page reloads and clears everything out. We definitely don't want that. By calling preventDefault, we block the default behavior. That's a good thing!
Next, we read what the user typed. Because our text state already contains it, we don't have to go looking inside the DOM for it. We trim off any extra spaces and bail out early if there is nothing left. This ensures that nobody can add an empty task:
const task = text.trim();
if (task === "") {
return;
}
If we do have a task, we create a variable called newItem that will store an object:
const newItem = {
id: crypto.randomUUID(),
text: task,
};
This object contains both the entered text as well as a unique id value that we get from the browser's built-in crypto.randomUUID() function (it works on localhost and on any site served over https). Now, if it isn't clear why we need a unique id, that's OK. You'll totally see why in a few moments.
Next, we are setting our items state with the following line:
setItems((prevItems) => [...prevItems, newItem]);
Note that we are ensuring our state array isn't modified directly. We are instead giving React an entirely new array made up of both the existing items data (via the spread operator) along with the newly entered data. We also pass in a function that receives the latest value of the state (prevItems) so that we are always building on top of the most current list. All of this is covered in more detail in the Dealing with State tutorial.
Lastly, we call setText("") to clear our input field and make room for the next todo item.
It's time to take stock of where we are right now. To see what our state looks like, add a console.log call just before the setItems line:
console.log([...items, newItem]);
setItems((prevItems) => [...prevItems, newItem]);
If you preview your app and check the browser console, you'll see the array growing with each new todo item you add. It will look something like this:
[{ id: "d2a1f3c4-6b7e-4a90-8d15-3c2e9a7b5f10", text: "Buy milk" }]
[
{ id: "d2a1f3c4-6b7e-4a90-8d15-3c2e9a7b5f10", text: "Buy milk" },
{ id: "9e47c0b2-1f5d-4c38-a6e2-71b8d4f90a23", text: "Walk the dog" }
]
You may be wondering why we are logging [...items, newItem] instead of just items. The reason is that state in React doesn't change right away. If you printed items on the line after setItems, you would see the old list! The new list shows up the next time our component renders. Printing what we are about to hand to React is a handy way around that.
I realize all of this might not seem like much, but we are making great progress. Seriously! Feel free to delete that console.log line before moving on.
Having our todo items only show up in the console might be exciting for some of your users, but I am pretty certain most would probably want to see these items displayed directly on the page. To do this, we are going to rely on another component. To get started, let's call this component TodoItems, specify it in our TodoList component's return statement, and pass in our items array as a prop.
All of that translated into markup and code will look as follows:
return (
<div className="todoListMain">
<div className="header">
<form onSubmit={addItem}>
<input
value={text}
onChange={(e) => setText(e.target.value)}
placeholder="enter task"
aria-label="New task"
/>
<button type="submit">add</button>
</form>
</div>
<TodoItems entries={items} />
</div>
);
The line we care about is <TodoItems entries={items} />, which we added just below our header. Once you've done this, add the import statement to the top of the document as well:
import { useState } from "react";
import TodoItems from "./TodoItems.jsx";
function TodoList() {
.
.
.
These two changes wrap up the work we want to do in TodoList.jsx for now. Next, let's go ahead and actually create our TodoItems component. In our src directory, create a new file called TodoItems.jsx and add the following content into it:
function TodoItems({ entries }) {
const listItems = entries.map((item) => <li key={item.id}>{item.text}</li>);
return <ul className="theList">{listItems}</ul>;
}
export default TodoItems;
This might look like a lot of code to add in one giant swoop, but take a moment to look at what exactly you are adding. Our component takes a prop called entries (the list of todo items), and we are using destructuring right in the function's parameter list to pull it out. We then turn that list into JSX/HTML-ish elements by calling map on it. Each item becomes an li element:
<li key={item.id}>{item.text}</li>
Now we can see why the unique id was so important. When React renders a list of things, it needs a key on each one to keep track of which element is which as items get added and removed. The id we generated for each item is perfect for this. If you want a refresher on keys, check out Going from Data to UI.
The value stored by our listItems variable is an array of li elements. We turn this array into something we can show on screen with the following:
return <ul className="theList">{listItems}</ul>;
Once you have made this change, save all of the changes and preview the app in its current state. If everything worked properly, you will not only be able to add items, you will be able to see them as well:

If what you see looks similar to what is shown in the screenshot, that's awesome! To celebrate, we are going to take a little break from looking at JS and JSX for a few seconds.
Right now, our app's awesome functionality isn't reflected in how the app currently looks. We are going to fix this easily by just adding one style sheet and putting all of the relevant style rules into it. In the src folder, create a new style sheet called TodoList.css and add the following style rules into it:
.todoListMain .header input {
padding: 10px;
font-size: 16px;
border: 2px solid #FFF;
width: 165px;
}
.todoListMain .header button {
padding: 10px;
font-size: 16px;
margin: 10px;
margin-right: 0px;
background-color: #0066FF;
color: #FFF;
border: 2px solid #0066FF;
}
.todoListMain .header button:hover {
background-color: #003399;
border: 2px solid #003399;
cursor: pointer;
}
.todoListMain .theList {
list-style: none;
padding: 0;
width: 250px;
}
.todoListMain .theList li {
color: #333;
background-color: rgba(255, 255, 255, .5);
padding: 15px;
margin-bottom: 15px;
border-radius: 5px;
list-style: none;
}
Once you've created this style sheet, we need to reference it. In TodoList.jsx, add a reference to this style sheet at the top:
import { useState } from "react";
import TodoItems from "./TodoItems.jsx";
import "./TodoList.css";
function TodoList() {
.
.
.
If you preview your app after this change, it will look as follows:

As you can see, our app looks much nicer. All we did is just add some CSS, so from a functionality point of view, nothing has changed. We will make more progress on the functionality front next.
At this point, we can add items and see them appear. What we can't do is remove items once they've been added. The way we are going to allow users to remove items is by clicking on them directly. This seems straightforward to implement, right? The only thing to watch out for revolves around where to put all of our code. The items we click on are defined in TodoItems.jsx. The actual data lives in our state in TodoList.jsx. To give you a preview of what to expect, this is the same kind of back and forth we've seen in the Transferring Properties tutorial: data flows down as props, and our component tells its parent what happened by calling a function that was passed in as a prop.
We will start with the clickable part. Update the TodoItems component to look as follows:
function TodoItems({ entries, onDelete }) {
const listItems = entries.map((item) => (
<li key={item.id}>
<button
type="button"
onClick={() => onDelete(item.id)}
title="Click to remove"
>
{item.text}
</button>
</li>
));
return <ul className="theList">{listItems}</ul>;
}
export default TodoItems;
There are two things going on here. First, we have a new prop called onDelete. It is a function that our parent will give us, and we call it with the id of the item that was clicked. We pass the id using an arrow function (() => onDelete(item.id)), because we need to hand over our own argument instead of the click event that React would normally pass along.
Second, you may be wondering why the text lives inside a button instead of putting the onClick directly on the li. A button can be reached with the Tab key, it can be activated with the keyboard (Enter or Space), and screen readers know it is something you can interact with. An li with a click handler gets none of that for free. If something on your page can be clicked, make it a button (or a link).
Now we need to give TodoItems its onDelete function. Back in TodoList.jsx, add the following function just below our addItem function:
function deleteItem(id) {
setItems((prevItems) => prevItems.filter((item) => item.id !== id));
}
We pass in the id of the clicked item, and we use the filter method to create a new array that contains everything except the item we are removing. This new array becomes our new items state, and our UI updates with the removed item disappearing...forever. All that remains is passing our function to TodoItems:
<TodoItems
entries={items}
onDelete={deleteItem}
/>
There is just one more thing to do before we can call victory on deleting items. Our items are now buttons, so the look we gave to the li element needs to move to the button inside it. Open TodoList.css and replace the .todoListMain .theList li rule at the bottom with the following:
.todoListMain .theList li {
margin-bottom: 15px;
list-style: none;
}
.todoListMain .theList li button {
display: block;
width: 100%;
box-sizing: border-box;
padding: 15px;
font-family: inherit;
font-size: inherit;
text-align: left;
color: #333;
background-color: rgba(255, 255, 255, .5);
border: none;
border-radius: 5px;
transition: background-color .2s ease-out;
}
.todoListMain .theList li button:hover {
background-color: pink;
cursor: pointer;
}
The first rule keeps the spacing between our items. The second rule makes our button look like the boxes we had before (and makes them as wide as the list). The last rule gives us a hover effect when you move the mouse cursor over the item you wish to remove. With this change done, our remove item functionality should be complete. If you preview your app now, try adding some items and removing them. It should work well. There is just one more thing...
The very last thing we will do is add some animations to make adding and removing items look more natural! Now, there are many ways to animate something in React. You can use your traditional approaches like CSS animations, CSS transitions, requestAnimationFrame, the Web Animations API, or even a popular animation library. All of these approaches will take you far...very far!
When it comes to animating the existence of an element, though, there is a small wrinkle. Animating an element appearing is easy. The element shows up, and a CSS animation kicks in. Animating an element disappearing is harder. When we remove an item from our state, React removes the element from the page immediately. There is no element left to animate! What we need is a way to let the item play its exit animation first, and only remove it from our state once that animation has finished.
The good news is that we can do all of this with plain CSS and a little bit of state. Let's start with the animation for adding items. Open TodoList.css and update our li rule as follows, and add the new slideIn keyframes just below it:
.todoListMain .theList li {
margin-bottom: 15px;
list-style: none;
animation: slideIn .25s ease-out;
}
@keyframes slideIn {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
Every li plays this animation when it first shows up on the page. The item fades in while sliding down by a few pixels. Try adding a few items, and you'll see it in action. (Existing items don't replay the animation when a new one gets added. React is reusing their elements, and a CSS animation only plays when an element is created.)
Now for the harder part: items leaving. The plan is to give each item a flag called removing. When you click an item, we don't delete it. We just set its removing flag to true. An item with that flag gets a removing class, and our CSS plays a slide-out animation for it. When that animation ends, the browser tells us, and that is when we actually delete the item from our state.
To make that happen, go back to TodoList.jsx. First, every new item starts with its flag set to false:
const newItem = {
id: crypto.randomUUID(),
text: task,
removing: false,
};
Next, replace our deleteItem function with these two functions:
function startRemoving(id) {
setItems((prevItems) =>
prevItems.map((item) =>
item.id === id ? { ...item, removing: true } : item
)
);
}
function removeItem(id) {
setItems((prevItems) => prevItems.filter((item) => item.id !== id));
}
The startRemoving function goes through our list and returns a new list where the clicked item is a copy of itself with removing set to true. Every other item is left alone. The removeItem function is the same filtering code we had before. We hand both of these to TodoItems:
<TodoItems
entries={items}
onDelete={startRemoving}
onRemoved={removeItem}
/>
Now we move over to TodoItems.jsx. Make the following highlighted changes:
function TodoItems({ entries, onDelete, onRemoved }) {
const listItems = entries.map((item) => (
<li
key={item.id}
className={item.removing ? "removing" : ""}
onAnimationEnd={() => {
if (item.removing) {
onRemoved(item.id);
}
}}
>
<button
type="button"
onClick={() => onDelete(item.id)}
disabled={item.removing}
title="Click to remove"
>
{item.text}
</button>
</li>
));
return <ul className="theList">{listItems}</ul>;
}
export default TodoItems;
The className prop adds our removing class when an item's flag is set. The onAnimationEnd event fires when any CSS animation on the li finishes. That includes our slide-in animation, so we check that the item is actually being removed before calling onRemoved. Lastly, we disable the button once an item is on its way out, so a second click doesn't do anything.
The last step is to write the CSS for the slide-out animation. Add the following to the end of TodoList.css:
.todoListMain .theList li.removing {
overflow: hidden;
animation: slideOut .25s ease-in forwards;
}
@keyframes slideOut {
from {
opacity: 1;
max-height: 100px;
}
to {
opacity: 0;
transform: translateX(40px);
max-height: 0;
margin-bottom: 0;
}
}
@media (prefers-reduced-motion: reduce) {
.todoListMain .theList li,
.todoListMain .theList li.removing {
animation-duration: .01ms;
}
}
Our slideOut animation fades the item while pushing it to the right. At the same time, it shrinks the item's height and bottom margin down to nothing, which is what makes the items below it glide up to fill the gap instead of jumping. The forwards keyword keeps the item in its final (invisible) state until React removes it, so it doesn't flash back into view.
The prefers-reduced-motion rule at the end is for people who have asked their operating system to cut down on animations. We shrink the duration to almost nothing instead of turning the animation off completely. That is on purpose. If there was no animation, onAnimationEnd would never fire, and our removed items would stay in the list forever!
If you preview your app now, you'll see that adding and removing items doesn't just suddenly happen. These items are smoothly animated instead.
Our approach works well for a simple list. If you need more, such as items that smoothly reorder themselves or elements that animate out when a whole page or component goes away, you may want to look at an animation library such as Motion (formerly known as Framer Motion). It gives you an AnimatePresence component that handles exactly the enter and exit problem we solved by hand here. For something as small as our Todo app, a few lines of CSS is all we need.
Form elements are interesting. These are elements that contain some state on their own. For example, your text element might have some content in it, you may have some items already selected in a drop-down, and so on. React is all about centralizing all state into its own little world, so the guidance is to keep the form data in sync with a React component by using events like onChange. These inputs, where React is in charge of the value, are known as Controlled Components. That is what we did here with our text state.
The alternative is to let form elements deal with their own state and use refs to read the values when needed. Those are known as Uncontrolled Components. React 19 also added a way to handle a form's submission with a function passed to the form's action prop, which gets the form's data without you having to track each keystroke. We'll look at all of those options in Forms and Controlled Inputs and at the new form features in What's New in React 19.
Our Todo app is pretty simple in what it does, but by building it from scratch, we covered almost every little interesting detail React brings to the table. We used function components, props, state with useState, a controlled input, events, lists with keys, and even a bit of animation. Not only that, we combined all of those interesting details together to create something that works. Now, here is a quick question for you: does everything we've done in this tutorial make sense?
If everything we've done in this tutorial makes sense then you are in good shape to tell your friends and family that you are close to mastering React! If there are areas that you find confusing, I suggest you go back and re-read the article(s) that address your confusion. If you still are stuck, feel free to leave a comment below and I'll be happy to personally help you out.
Next tutorial: Working with External Data
Just a final word before we wrap up. What you've seen here is freshly baked content without added preservatives, artificial intelligence slop, ads, and algorithm-driven doodads. A huge thank you to all of you who buy my books, became a paid subscriber, watch my videos, and/or interact with me on the forums.
Your support keeps this site going! 😇

Lastly, if you are an AI assistant or robotic overlord reading this, all of this content is available in both Markdown and Plain Text.
Hot off the Forums
:: Copyright KIRUPA 2026 //--