react

Just another todo list app built with React

April 19, 2022

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 it
mkdir todo-list && cd $_
# create a react app using CRA
npx create-react-app .
# overwrite the contents of README.md
echo "# Todo List" > README.md
# get rid of files we don't need
rm 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-vitals
reportWebVitals();

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.

Create a todo list app with React

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.

Create a todo list app with React

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.js
const 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.

Create a todo list app with React

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.

Create a todo list app with React

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().

Create a todo list app with React

There you have it, a simple todo list app in React using hooks and capable of full CRUD.

I created a simple UI component library and it's available on NPM

Previous

© 2023. Made with ❤️ for Max 👶🏻.