I'm aware that there are likely hundreds of thousands of todo list tutorials out there.
One night I was motivated to code a todo list from memory for practice. The only requirement I had, is that it have full create, read, update and delete capabilities.
However long this tutorial ends up taking me to write, and you to complete, the React code itself is concise. Here's a Codesandbox link with the completed code.
I don't know who this tutorial is for. I definitely don't want to waste too much time explaining beginner concepts, but it is a todo list tutorial after all!
How much React and JavaScript should one know at this stage? I don't know the answer, but I do feel like to start, a fair assessment of this tutorial would be to judge it on whether or not someone could follow along and reproduce the same results.
Until I find out exactly who, apart from myself, I am writing for, I will keep practicing.
Create a blank React project
Very quickly, create a blank application with Create React App.
# make a project directory and cd into itmkdir todo-list && cd $_
# create a react app using CRAnpx create-react-app .
# overwrite the contents of README.mdecho "# Todo List" > README.md
# get rid of files we don't needrm src/App.test.js src/App.css src/logo.svg src/reportWebVitals.js src/setupTests.js
Remove these lines of code from index.js
:
import reportWebVitals from './reportWebVitals';
// ...
// If you want to start measuring performance in your app, pass a function// to log results (for example: reportWebVitals(console.log))// or send to an analytics endpoint. Learn more: https://bit.ly/CRA-vitalsreportWebVitals();
Refactor App.js
to display an H1 title and remove the import App.css
.
function App() { return ( <div> <h1>Todo List</h1> </div> );}
export default App;
Check to see if our bare bones app is working by running npm run start
.
Listing todos
Let's setup the foundation for what will be our list of todos and import the useState()
hook. useState()
takes a single argument, which is the initial state (in our case an array of strings), and returns both the state and a setter function.
The initial state can be any JavaScript primitive or non-primitive type, and you can call useState()
as many times as you need in a component.
import { useState } from 'react';
const App = () => { const [todos, setTodos] = useState(['Gym', 'Tan', 'Laundry']);
return( <div> <h1>Todo List<h1> {todos.map((todo, i) => ( <div key={i}>{todo}</div> ))} </div> )};
Here we are using Array.map()
to print our todos. React requires a unique key for each item in a list, so in addition to the todo, we are passing the index argument to our list item's key
prop.
Adding todos
From the root of your project, install the uuidv4
library - it will generate a unique ID for each todo that is created. We require a unique ID for a few reasons: 1) instead of the index argument, we will use it as the unique key for each item in the list, 2) we need it to reference individual todos for update and delete actions (but more on that later on).
npm install uuidv4
Create a components
directory in the src
folder. Create a file called Form.js
inside components
.
import { useState } from 'react';import { v4 as uuidv4 } from 'uuid';
const Form = ({ addTodo }) => { const [title, setTitle] = useState('');
const handleSubmit = e => { e.preventDefault();
const todo = { id: uuidv4(), title, complete: false, };
addTodo(todo); setTitle(''); };
return ( <form onSubmit={handleSubmit}> <input type="text" value={title} onChange={e => setTitle(e.target.value)} required /> <input type="submit" value="add todo" style={{ marginLeft: '5px' }} /> </form> );};
export default Form;
Let's quickly break down our Form component. We are taking in a prop - addTodo()
- a function we have yet to create. We are also creating a state variable for the todo's title with useState()
. Our form will use what is referred to as "controlled inputs", where the value of the input is derived from the state.
const Form = ({ addTodo }) => { const [title, setTitle] = useState(''); // ...};
export default Form;
Controlled inputs are achieved by passing the setState()
function to the input's onChange
event listener. When someone types in the input, the state will update to reflect what they typed.
const Form = ({ addTodo }) => { const [title, setTitle] = useState(''); // ... return ( <form onSubmit={handleSubmit}> <input type="text" value={title} onChange={e => setTitle(e.target.value)} required /> <input type="submit" value="add todo" style={{ marginLeft: '5px' }} /> </form> );};
export default Form;
You might have noticed that we are passing a handleSubmit()
function to the form's onSubmit
event listener.
const Form = ({ addTodo }) => { // ... const handleSubmit = e => { e.preventDefault();
const todo = { id: uuidv4(), title, complete: false, };
addTodo(todo); setTitle(''); }; // ...};
export default Form;
handleSubmit()
creates a todo object and with the title from state, an id
property (using the uuidv4
library), and a complete
property, which starts as false. The todo is then passed to the addTodo()
function the Form component took in as a prop before setting the title to a blank string.
import Form from './components/Form';
const App = () => { // ... const addTodo = todo => { setTodos(prevState => [...prevState, todo]); }; // ...};
export default App;
In React, the child component (in this case Form) cannot directly update the state of the parent (App) without having direct access to the parent's setState()
. This is why we pass addTodo()
to the child component. We are not passing setState()
as a prop, because IRL we would probably want to do more data manipulation or validation before updating the state.
React's setState()
function returns in its callback, the argument for current (or "previous state" as it is known) - right before update. We are using Javascript's spread syntax to populate a new array with a copy of the pre-update state, and with the new todo object. We then set the state to be our modified copy of the todos array. This is in line with React's philosophy on immutability.
const App = () => { const [todos, setTodos] = useState([]); // ... return ( <div> <h1>Todo List</h1> <Form addTodo={addTodo} /> {todos.map(todo => ( <div key={todo.id}>{todo.title}</div> ))} </div> );};
export default App;
Let's also adjust the Array.map()
to render a todo object instead of a string. We can get rid of the index argument now and pass the id
to the key
prop.
Todo component
Create the file Todo.js
in the components
directory. It looks a little unwieldy but there is a lot of functionality that is going into this one component.
import { useState } from 'react';
const buttonStyle = { marginLeft: '5px',};
const Todo = ({ todo }) => { const [editable, setEditable] = useState(false); const [isChecked, setIsChecked] = useState(todo.complete);
const todoItem = !editable ? ( <span style={todo.complete ? { textDecoration: 'line-through' } : {}}>{todo.title}</span> ) : ( <input type="text" defaultValue={todo.title} name="title" /> );
return ( <div> {todoItem}
{!todo.complete && ( <button onClick={() => setEditable(!editable)} style={buttonStyle}> {editable ? 'cancel' : 'edit'} </button> )}
{editable ? ( <button style={buttonStyle}>save</button> ) : ( <> {!todo.complete && <button style={buttonStyle}>delete</button>}
<label> <input type="checkbox" name="complete" checked={isChecked} onChange={() => setIsChecked(!isChecked)} /> {!todo.complete ? 'incomplete' : 'complete'} </label> </> )} </div> );};
Let's break down this component:
First we import the useState()
hook. Second, we ensure the component accepts a todo as a prop.
import { useState } from 'react';
const Todo = ({ todo }) => { const [editable, setEditable] = useState(false); // ...};
We are coding a single page application and if we want CRUD, we need to be able to edit the todo's title "in place". Let's create a state called editable
.
When editable
is false we render todo.title
as text between a span, and add a css strike through if todo.complete
is true. When editable
is true we render an input field where the user can edit the title.
const Todo = ({ todo }) => { const [editable, setEditable] = useState(false);
// ...
const todoItem = !editable ? ( <span style={todo.complete ? { textDecoration: 'line-through' } : {}}>{todo.title}</span> ) : ( <input type="text" defaultValue={todo.title} name="title" /> );
// ...};
We need a button to toggle editing on the todo, but only if todo.complete
is false (it doesn't make sense to edit a completed todo). So we pass setEditable()
to the button's onClick
prop and display "cancel" if editable is true, and "edit" if false, as the button text.
const Todo = ({ todo }) => { // ... return( // ... {!todo.complete && ( <button onClick={() => setEditable(!editable)} style={buttonStyle}> {editable ? 'cancel' : 'edit'} </button> )} // ... );};
Moving on. When the form is editable display the "save" button. If the form is not editable and todo.complete
is false, display the "delete" button AND display the checkbox input that marks a todo complete.
const Todo = ({ todo }) => { const [isChecked, setIsChecked] = useState(todo.complete); // ... return( // ... {editable ? ( <button style={buttonStyle}>save</button> ) : ( <> {!todo.complete && ( <button style={buttonStyle}>delete</button> )}
<label> <input type="checkbox" name="complete" checked={isChecked} onChange={() => setIsChecked(!isChecked)} /> {!todo.complete ? "incomplete" : "complete"} </label> </> )} // ... );}
To toggle the checked/unchecked state of the the complete
field, we create a state called isChecked
and pass the setIsChecked
to the field's onChange
prop. The initial state of isChecked
is the value of todo.complete
which starts off as false. The checked
prop on a checkbox input requires a boolean value to display its state.
In the App component lets import the Todo component and fix our Array.map()
to render the Todo component instead of a div.
import Todo from './components/Todo';
const App = () => { // ...
return ( <div> <h1>Todo List</h1> <Form addTodo={addTodo} /> {todos.map(todo => ( <Todo key={todo.id} todo={todo} /> ))} </div> );};
We can add todos, and toggle editing the title on and off, but neither editing the title nor marking a todo complete saves yet. We also have a "delete" button which does nothing.
Deleting a todo
Create a deleteTodo()
function in App.js
. We will use Array.filter()
to find the todos with IDs that do not match the ID passed into the function. We then take the new array and set the state with it.
// App.jsconst App = () => { // ...
const deleteTodo = id => { setTodos( prevTodos => prevTodos.filter(t => t.id !== id) ); };
// ...
return ( <div> <h1>Todo List</h1> <Form addTodo={addTodo} /> {todos.map(todo => ( <Todo key={todo.id} todo={todo} deleteTodo={deleteTodo} /> ))} </div> );};
Don't forget to pass the deleteTodo()
function as a prop to the Todo component.
const Todo = ({ todo, deleteTodo }) => { // ...
return ( // ... {!todo.complete && <button onClick={() => deleteTodo(todo.id)} style={buttonStyle}>delete</button>; } // ... )};
Then when the user clicks the "delete" button, call deleteTodo()
while passing in the todo ID as an argument.
Updating a todo
In App.js
create the updateTodo()
function and pass it as a prop to the Todo component.
const App = () => { // ...
const updateTodo = todo => { setTodos(prevTodos => prevTodos.map(t => (t.id === todo.id ? todo : t))); };
// ... return ( <div> <h1>Todo List</h1> <Form addTodo={addTodo} /> {todos.map(todo => ( <Todo key={todo.id} todo={todo} deleteTodo={deleteTodo} updateTodo={updateTodo} /> ))} </div> );};
updateTodo()
takes an updated todo object as its argument. We map through the current todos, and if a todo ID matches the ID of the updated todo we passed to the function, we replace the todo with the matching ID with the updated todo.
import { useState, useRef } from 'react';
const Todo = ({ todo, deleteTodo, updateTodo }) => { // ...
const titleRef = useRef();
const todoItem = !editable ? ( <span style={todo.complete ? { textDecoration: 'line-through' } : {}}>{todo.title}</span> ) : ( <input type="text" defaultValue={todo.title} name="title" ref={titleRef} onKeyDown={e => handleUpdateOnKeyDown(e)} /> );
// ...};
Unlike our Form component, we won't be using a controlled input for the title field when editing "in-place". We will use the useRef()
hook instead. useRef()
lets us directly access DOM elements and their properties. useRef()
returns a mutable object - the current
property - and doesn't re-render our component unlike when we set the state with useState()
.
Create a ref - titleRef
- with the hook and then pass it to the ref
prop of our title input. Now we will have access to titleRef.current.name
, and titleRef.current.value.
name
and value
are properties of HTML input DOM elements.
Why on earth are you using useRef?
The use of useRef()
is very opinionated. It is not in line with the "React way" of the UI being driven by the state. useRef()
however is perfectly fine for simple fields that do not require reactivity. You can debate me on my reasoning (and I will happily listen) but I am using useRef()
because I don't want the user input to persist in state. When the user hits "cancel", editable
gets set to false and the component re-renders (because we are using useState()
for editable
). The same goes for when the user saves their todo after editing, the whole component is re-rendered with the updated todo. There is no need to persist what that user is typing into state, forcing a re-render on every keystroke.
Back to our Todo component
const Todo = ({ todo, deleteTodo, updateTodo }) => { // ...
const handleUpdate = () => { const { name, value } = titleRef.current; updateTodo({ ...todo, [name]: value }); setEditable(false); };
const handleUpdateOnKeyDown = e => { if (e.key === 'Enter' || e.keyCode === 13) { handleUpdate(); } };
// ...};
Create a handleUpdate()
function. Inside the function, we destructure the current
property and create variables for name
and value
. We then create a new object; using the spread syntax we populate the object with the properties of the current todo, and update the property that matches the name
with value
. Then we pass that new todo object to updateTodo()
before setting editable to false
.
For better UX we will also create a function to accept the "Enter" key as confirmation of the user input instead of relying solely on the "save" button. We also pass this function to the onKeyDown
prop of our text input and pass the event argument to it to access the key
and keyCode
properties.
const Todo = ({ todo, deleteTodo, updateTodo }) => { // ... return ( // ... <button onClick={handleUpdate} style={buttonStyle}> save </button> // ... );};
Pass the handleUpdate()
function to the "save" button's onClick
event listener.
Marking a todo complete
We need to be able to mark a todo complete which is different from deleting a todo.
const Todo = ({ todo, deleteTodo, updateTodo }) => { const [isChecked, setIsChecked] = useState(todo.complete); // ...
return ( // ... <label> <input type="checkbox" name="complete" checked={isChecked} onChange={() => setIsChecked(!isChecked)} /> {!todo.complete ? 'incomplete' : 'complete'} </label> // ... );};
At present we have an isChecked
state that stores a true or false value and is responsible for showing the checked/unchecked state of the complete
field.
Create a handleCheck()
function. When the function is invoked, create a new todo object with the properties of the current todo (again using the spread syntax), and toggle the value of complete
using the logical "not" operator, which reverses a boolean and returns the opposite. Pass this new object to updateTodo()
before toggling isChecked
on setIsChecked()
.
const Todo = ({ todo, deleteTodo, updateTodo }) => { // ...
const handleUpdate = () => { const { name, title } = titleRef.current; updateTodo({ ...todo, [name]: value }); setEditable(false); };
const handleUpdateOnKeyDown = (e) => { if (e.key === "Enter" || e.keyCode === 13) { handleUpdate(); } };
const handleCheck = () => { updateTodo({ ...todo, complete: !isChecked }); setIsChecked(!isChecked); };
return( // ... {editable ? ( <button onClick={handleUpdate} style={buttonStyle}>save</button> ) : ( <> {!todo.complete && ( <button onClick={() => deleteTodo(todo.id)} style={buttonStyle}>delete</button> )}
<label> <input type="checkbox" name="complete" checked={isChecked} onChange={() => handleCheck()} /> {!todo.complete ? "incomplete" : "complete"} </label> </> )} // ... )};
Remove setIsChecked()
from the checkbox's onChange
prop and replace it with handleCheck()
.
There you have it, a simple todo list app in React using hooks and capable of full CRUD.