Build a Todo App with React #4: Editing
Last time we added filtering and bulk operations. This time we’ll let you edit the text of a todo. Double-click to enter edit mode, Enter to save, Escape to cancel — inline editing. Along the way useRef, which we didn’t cover in the basics course, makes its first appearance.
Goals for this step #
- Double-click an item’s text to turn it into a text input (inline editing)
- The input automatically gets focus
- Enter or losing focus (blur) saves
- Escape cancels
- Saving with empty text deletes the item
Where should the editing state live? #
First we need to decide where the editing state (editingId, the ID of the item being edited) lives. Two options.
Option A. Each TodoItem keeps its own editing state
- Pros: simple. Works without any parent involvement
- Cons: two items can be in edit mode at the same time (UX usually wants one editor at a time)
Option B. TodoApp holds editingId
- Pros: only one item can be edited at a time (starting to edit another item ends the previous edit automatically)
- Cons: one more piece of state and more props to pass
We’re going with Option B here. Editing multiple items at once is awkward.
Add the text-update handler #
Add an updateTodoText function to TodoApp and create the editing state (editingId).
I’ll show only the changed parts of src/TodoApp.jsx (the full file is in the next step):
const [editingId, setEditingId] = useState(null);
function updateTodoText(id, newText) {
const trimmed = newText.trim();
if (!trimmed) {
deleteTodo(id);
return;
}
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, text: trimmed } : todo
));
}We made saving with empty text auto-delete the item. A common UX pattern.
TodoItem — branching on edit mode #
The core is the part of TodoItem that shows different views depending on whether it’s currently being edited. The normal mode and edit mode are clearly separated.
src/TodoItem.jsx:
import { useState, useRef, useEffect } from 'react';
function TodoItem({ todo, isEditing, onToggle, onDelete, onStartEdit, onFinishEdit }) {
const [draft, setDraft] = useState(todo.text);
const inputRef = useRef(null);
useEffect(() => {
if (isEditing) {
setDraft(todo.text);
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing, todo.text]);
function commit() {
onFinishEdit(todo.id, draft);
}
function cancel() {
onFinishEdit(todo.id, todo.text); // revert to the original
}
function handleKeyDown(e) {
if (e.key === 'Enter') commit();
else if (e.key === 'Escape') cancel();
}
return (
<li style={{
display: 'flex',
alignItems: 'center',
gap: '8px',
padding: '8px',
borderBottom: '1px solid #eee',
opacity: !isEditing && todo.completed ? 0.5 : 1,
}}>
<input
type="checkbox"
checked={todo.completed}
onChange={() => onToggle(todo.id)}
disabled={isEditing}
/>
{isEditing ? (
<input
ref={inputRef}
type="text"
value={draft}
onChange={(e) => setDraft(e.target.value)}
onBlur={commit}
onKeyDown={handleKeyDown}
style={{ flex: 1, padding: '4px' }}
/>
) : (
<span
onDoubleClick={() => onStartEdit(todo.id)}
style={{
flex: 1,
textDecoration: todo.completed ? 'line-through' : 'none',
cursor: 'text',
}}
>
{todo.text}
</span>
)}
{!isEditing && (
<button onClick={() => onDelete(todo.id)}>Delete</button>
)}
</li>
);
}
export default TodoItem;There are some new things here. Let’s unpack them one by one.
useRef — accessing a DOM element directly #
const inputRef = useRef(null);
// ...
<input ref={inputRef} ... />
// ...
inputRef.current?.focus();useRef creates a “reference box” that persists across renders without triggering them. Attaching it to an input via ref={inputRef} lets you reach the DOM element directly through inputRef.current after rendering.
How it differs from useState:
| useState | useRef | |
|---|---|---|
| Stores a value | ✓ | ✓ |
| Triggers a re-render on change | ✓ | ✗ |
| When to use | A value that affects the screen | A value unrelated to the screen, or a DOM reference |
Here inputRef isn’t a value that affects the screen but a handle pointing to “this input element”, so useRef fits. If you used useState, every ref update would trigger a meaningless re-render.
Auto-focus when entering edit mode #
When entering edit mode, the user shouldn’t have to click the input themselves — keyboard focus should arrive automatically. We handle this with the useEffect from #10.
useEffect(() => {
if (isEditing) {
setDraft(todo.text);
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing, todo.text]);- The effect runs when
isEditingbecomestrue setDraft(todo.text)resets the draft to the original (so leftover state from a previous incomplete edit doesn’t linger)focus()moves keyboard focus thereselect()selects the existing text (so typing immediately overwrites it)
We use ?. (optional chaining) to safely handle the case where inputRef.current is null (when the input isn’t on screen yet).
draft state — the in-progress value #
The text being edited (draft) lives inside TodoItem itself. Instead of changing the parent’s todo.text directly, we keep it separately and notify the parent only when the user “confirms” with Enter/blur. A common pattern for cancellable changes.
const [draft, setDraft] = useState(todo.text);Thanks to this pattern, hitting Escape can throw away the changed draft and keep the original.
Keyboard handling #
function handleKeyDown(e) {
if (e.key === 'Enter') commit();
else if (e.key === 'Escape') cancel();
}The same keyboard event handling we covered in #6. e.key is a string with the key’s name ('Enter', 'Escape', 'a', etc.).
Auto-save on blur #
<input onBlur={commit} ... />When the user clicks outside the input, the focus is lost and we save automatically. Without this, edits could disappear when the user clicks elsewhere after editing — frustrating UX.
There’s a subtle conflict between onBlur and Escape — pressing Escape calls cancel() and reverts to the original, but right after the input disappears from the screen, onBlur fires and commit() runs again. Both end up calling onFinishEdit, but since they both perform the same net operation, it doesn’t cause real problems. cancel reverts to the original, then commit saves the draft (which is already the original), so the final result is just the original.
These subtle interactions are what make form work tricky, but you build intuition by verifying behavior in small pieces.
Putting it together in TodoApp #
Now the wiring code.
src/TodoApp.jsx (full):
import { useState } from 'react';
import TodoForm from './TodoForm';
import TodoFilter from './TodoFilter';
import TodoStats from './TodoStats';
import TodoList from './TodoList';
function TodoApp() {
const [todos, setTodos] = useState([]);
const [filter, setFilter] = useState('all');
const [editingId, setEditingId] = useState(null);
function addTodo(text) {
const newTodo = {
id: crypto.randomUUID(),
text,
completed: false,
};
setTodos(prev => [newTodo, ...prev]);
}
function deleteTodo(id) {
setTodos(prev => prev.filter(todo => todo.id !== id));
}
function toggleTodo(id) {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, completed: !todo.completed } : todo
));
}
function clearCompleted() {
setTodos(prev => prev.filter(todo => !todo.completed));
}
function startEdit(id) {
setEditingId(id);
}
function finishEdit(id, newText) {
const trimmed = newText.trim();
if (!trimmed) {
deleteTodo(id);
} else {
setTodos(prev => prev.map(todo =>
todo.id === id ? { ...todo, text: trimmed } : todo
));
}
setEditingId(null);
}
const filteredTodos = todos.filter(todo => {
if (filter === 'active') return !todo.completed;
if (filter === 'completed') return todo.completed;
return true;
});
return (
<div style={{ maxWidth: '500px', margin: '0 auto', padding: '24px' }}>
<h1>Todo</h1>
<TodoForm onAdd={addTodo} />
<TodoFilter filter={filter} onChange={setFilter} />
<TodoStats todos={todos} onClearCompleted={clearCompleted} />
<TodoList
todos={filteredTodos}
filter={filter}
totalCount={todos.length}
editingId={editingId}
onToggle={toggleTodo}
onDelete={deleteTodo}
onStartEdit={startEdit}
onFinishEdit={finishEdit}
/>
</div>
);
}
export default TodoApp;TodoList also has to receive the new props and pass them down to its children.
src/TodoList.jsx:
import TodoItem from './TodoItem';
const FILTER_LABEL = {
all: 'todos',
active: 'active items',
completed: 'completed items',
};
function TodoList({ todos, filter, totalCount, editingId, onToggle, onDelete, onStartEdit, onFinishEdit }) {
if (todos.length === 0) {
if (totalCount === 0) {
return <p style={{ color: '#888' }}>No todos yet. Add a new one.</p>;
}
return <p style={{ color: '#888' }}>No {FILTER_LABEL[filter]}.</p>;
}
return (
<ul style={{ listStyle: 'none', padding: 0 }}>
{todos.map(todo => (
<TodoItem
key={todo.id}
todo={todo}
isEditing={todo.id === editingId}
onToggle={onToggle}
onDelete={onDelete}
onStartEdit={onStartEdit}
onFinishEdit={onFinishEdit}
/>
))}
</ul>
);
}
export default TodoList;isEditing={todo.id === editingId} — we simplified the prop to a boolean for the child. The child no longer has to do editingId === todo.id itself. The child only needs to know whether it’s the one being edited.
Verify it works #
Save and try the following.
- Add an item, then double-click the text → it turns into an input with auto-focus and the text fully selected
- Edit and press Enter → saved
- Double-click again → edit → press Escape → original restored
- Double-click, clear the input, press Enter → item deleted
- Double-click, then click elsewhere (blur) → saved with the new text
- While editing one item, double-click another item → the previous edit ends automatically and editing moves to the new item
As steps 3 and 5 show, separating the draft state gives you a natural UX where “any change before confirmation can be cancelled.”
Common pitfalls #
1. Using ref instead of useState #
const draftRef = useRef(todo.text);
return <input value={draftRef.current} onChange={(e) => { draftRef.current = e.target.value; }} />;If draft is a value that needs to appear on screen, it must be useState. Updating a ref doesn’t trigger a re-render, so the screen won’t update. Restrict refs to values that don’t appear on screen (timer IDs, stored previous prop values) or DOM handles.
2. Missing effect dependencies #
useEffect(() => {
if (isEditing) inputRef.current?.focus();
}, []);With an empty array, this runs only once at mount, so when isEditing becomes true later, focus doesn’t happen. Make sure to include isEditing in the dependency array. Thankfully, ESLint catches this.
3. The bug where two items are edited at once #
If you don’t keep editingId in the parent and each TodoItem holds its own isEditing with useState, multiple items can be in edit mode at the same time. UX-wise that’s awkward, so it’s cleaner to manage a single ID in the parent from the start.
onCompositionStart/onCompositionEnd and delay the commit. For simplicity, this series omits that handling.Wrap-up #
This post built inline editing and met some new tools.
useRef— store values/DOM handles unrelated to re-renderinguseEffect+focus()— auto-focus on mode switch- draft state — a pattern that makes pre-confirm changes cancellable
- Keyboard handling — Enter/Escape for commit/cancel
onBlurauto-save — user-friendly UX
Adding up our progress: add / toggle / delete / filter / bulk operation / edit are all working. But one big problem remains — all data is lost on a refresh. In the next and final post in this series, “Build a Todo App with React #5 Persistence and wrap-up,” we’ll apply the useLocalStorage custom hook from #13 to keep the data and look back over the whole series.