지난 시간에는 필터링과 일괄 처리를 추가했습니다. 이번에는 입력한 할 일을 수정할 수 있게 만들겠습니다. 더블클릭으로 편집 모드 진입, Enter로 저장, Escape로 취소하는 인라인 편집을 구현합니다. 이 과정에서 기초 강좌에서 다루지 않았던 **useRef**가 처음 등장합니다.
이번 단계 목표
- 항목 텍스트를 더블클릭하면 입력창으로 변신 (인라인 편집)
- 입력창에 자동으로 포커스가 들어감
- Enter 또는 포커스 잃기(blur) 시 저장
- Escape로 취소
- 빈 텍스트로 저장하면 항목 삭제
편집 상태를 어디에 둘까?
먼저 편집 상태(editingId, 편집 중인 항목의 ID)를 어디에 둘지 결정해야 합니다. 두 가지 선택지가 있습니다.
옵션 A. 각 TodoItem이 자기 편집 상태를 가짐
- 장점: 단순. 부모와 무관하게 동작
- 단점: 동시에 두 항목이 편집 모드일 수 있음 (사용자 경험상 보통은 한 번에 하나만 편집하길 원함)
옵션 B. TodoApp이 editingId를 가짐
- 장점: 한 번에 한 항목만 편집 가능 (다른 항목 편집 시작하면 자동으로 이전 항목 종료)
- 단점: state가 하나 더 늘고 props 전달 필요
이번에는 옵션 B로 갑니다. 동시에 여러 항목이 편집되는 게 어색해서요.
텍스트 갱신 핸들러 추가
TodoApp에 updateTodoText 함수를 추가하고, 편집 상태(editingId)도 만듭니다.
src/TodoApp.jsx의 핵심 부분만 보여드리겠습니다 (전체는 다음 단계에서):
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
));
}빈 텍스트로 저장하려고 하면 자동 삭제되도록 했습니다. 흔한 UX 패턴이에요.
TodoItem — 편집 모드 분기
핵심은 TodoItem에서 현재 편집 중인지에 따라 다른 화면을 보여주는 부분입니다. 일반 모드와 편집 모드가 명확히 분리됩니다.
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); // 원본으로 되돌림
}
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)}>삭제</button>
)}
</li>
);
}
export default TodoItem;처음 보는 것들이 등장했는데 하나씩 풀어보겠습니다.
useRef — DOM 요소에 직접 접근하기
const inputRef = useRef(null);
// ...
<input ref={inputRef} ... />
// ...
inputRef.current?.focus();**useRef**는 변하지 않고 유지되는 "참조 상자"를 만들어줍니다. ref={inputRef}로 input 요소에 연결해두면, 렌더링 후 inputRef.current로 그 DOM 요소에 직접 접근할 수 있어요.
useState와의 차이점은:
| useState | useRef | |
|---|---|---|
| 값을 보관 | ✓ | ✓ |
| 값이 바뀌면 리렌더 | ✓ | ✗ |
| 사용 시점 | 화면에 영향을 주는 값 | 화면과 무관하게 보관할 값, DOM 참조 |
여기서 inputRef는 화면에 영향을 주는 값이 아니라 "이 input 요소"를 가리키는 핸들일 뿐이라 useRef가 적합합니다. useState로 만들면 ref를 갱신할 때마다 무의미한 리렌더가 일어나겠죠.
편집 모드 진입 시 자동 포커스
편집 모드로 전환되면 사용자가 일일이 input을 클릭하지 않아도 자동으로 키보드 포커스가 들어와야 합니다. 이건 #10에서 배운 useEffect로 처리합니다.
useEffect(() => {
if (isEditing) {
setDraft(todo.text);
inputRef.current?.focus();
inputRef.current?.select();
}
}, [isEditing, todo.text]);isEditing이true가 될 때 effect가 실행됨setDraft(todo.text)로 draft를 항목 원본으로 초기화 (지난번 편집 미완료 상태가 남아있지 않도록)focus()로 키보드 포커스 진입select()로 기존 텍스트를 통째로 선택 (사용자가 바로 새 텍스트를 입력하면 덮어쓰기됨)
?.(옵셔널 체이닝)을 쓰는 이유는 inputRef.current가 null인 경우(아직 input이 화면에 없을 때)를 안전하게 처리하기 위함이에요.
draft state — 편집 중간 값
편집 중인 텍스트(draft)는 TodoItem이 자기 안에 가집니다. 부모의 todo.text를 직접 바꾸지 않고, 따로 보관했다가 사용자가 Enter/blur로 "확정"했을 때만 부모에 알리는 거예요. 취소 가능한 변경을 만들기 위한 흔한 패턴입니다.
const [draft, setDraft] = useState(todo.text);이 패턴 덕분에 Escape 키를 누르면 변경된 draft는 그대로 버리고 원본을 유지할 수 있습니다.
키보드 처리
function handleKeyDown(e) {
if (e.key === 'Enter') commit();
else if (e.key === 'Escape') cancel();
}#6에서 다룬 키보드 이벤트 처리 그대로입니다. e.key는 누른 키의 이름 문자열이에요 ('Enter', 'Escape', 'a' 등).
onBlur로 자동 저장
<input onBlur={commit} ... />input 바깥을 클릭해서 포커스를 잃으면 자동으로 저장되도록 했습니다. 이게 없으면 사용자가 편집 후 다른 곳을 클릭했을 때 변경이 사라질 수 있어 답답한 UX가 됩니다.
다만 onBlur와 Escape의 충돌이 있습니다 — Escape를 누르면 cancel()이 실행되어 원본으로 되돌려놓는데, 그 직후 input이 화면에서 사라지면서 onBlur가 발화되어 commit()이 다시 실행됩니다. 결과적으로 cancel과 commit이 둘 다 실행되지만, 둘 다 같은 일(onFinishEdit 호출)을 하기 때문에 큰 문제는 없습니다. cancel은 원본으로 되돌리고, 그 직후 commit은 이미 원본으로 되어 있는 draft를 그대로 저장하므로 결과적으로 원본 그대로가 되거든요.
이런 미묘한 상호작용이 폼 작업의 어려운 점인데, 작은 단위로 동작을 검증하면서 만들면 점점 감이 옵니다.
TodoApp 통합
이제 모든 연결을 마치는 코드입니다.
src/TodoApp.jsx (전체):
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도 새 props를 받아 자식에 전달해줘야 합니다.
src/TodoList.jsx:
import TodoItem from './TodoItem';
const FILTER_LABEL = {
all: '할 일',
active: '미완료 항목',
completed: '완료 항목',
};
function TodoList({ todos, filter, totalCount, editingId, onToggle, onDelete, onStartEdit, onFinishEdit }) {
if (todos.length === 0) {
if (totalCount === 0) {
return <p style={{ color: '#888' }}>할 일이 없습니다. 새로 추가해보세요.</p>;
}
return <p style={{ color: '#888' }}>{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} — 자식에게 boolean으로 단순화해서 전달했습니다. 자식이 editingId === todo.id 비교를 매번 하지 않아도 되도록요. 자식은 자기가 편집 중인지 아닌지만 알면 됩니다.
동작 확인
저장하고 다음을 시도해보세요.
- 항목 추가 후 텍스트를 더블클릭 → 입력창으로 변신, 자동 포커스, 텍스트 전체 선택
- 글자 수정 후 Enter → 저장됨
- 다시 더블클릭 → 글자 수정 → Escape → 원본 복원
- 더블클릭 후 입력창 비우고 Enter → 항목 삭제됨
- 더블클릭 후 다른 곳 클릭(blur) → 변경된 내용으로 저장
- 한 항목 편집 중에 다른 항목 더블클릭 → 이전 편집은 자동 종료, 새 항목으로 편집 옮겨감
3, 5에서 보이는 것처럼, draft state 분리 덕분에 "확정 전 변경은 언제든 취소 가능"한 자연스러운 UX가 나옵니다.
흔한 함정
1. ref를 useState 대신 사용하기
const draftRef = useRef(todo.text);
return <input value={draftRef.current} onChange={(e) => { draftRef.current = e.target.value; }} />;draft가 화면에 보여야 하는 값이라면 useState여야 합니다. ref를 갱신해도 리렌더가 일어나지 않으므로 화면이 갱신되지 않습니다. ref는 화면에 안 나타나는 값(타이머 ID, 이전 prop 값 보관 등)이나 DOM 핸들에 한정해서 사용하세요.
2. effect 의존성 빠뜨리기
useEffect(() => {
if (isEditing) inputRef.current?.focus();
}, []);빈 배열이면 처음 한 번만 실행되니 isEditing이 나중에 true가 되어도 focus가 안 됩니다. 의존성에 isEditing을 꼭 포함하세요. 다행히 ESLint가 잡아줍니다.
3. 한 번에 두 항목이 편집되는 버그
editingId를 부모에 두지 않고 각 TodoItem이 자기 isEditing을 useState로 가지면 동시에 여러 항목이 편집 모드일 수 있습니다. UX상 어색하니 처음부터 부모에서 단일 ID로 관리하는 게 깔끔합니다.
실제 서비스에서 인라인 편집을 만들 때는 IME(한글 입력기)와 Enter 키의 상호작용도 신경 써야 합니다. 한글 조합 중 Enter는 조합을 끝내는 용도일 수도 있어서 즉시 commit하면 마지막 글자가 잘리는 일이 있습니다. onCompositionStart/onCompositionEnd 이벤트로 조합 중인지 추적해 commit 타이밍을 늦추는 처리가 흔합니다. 이번 시리즈는 단순화를 위해 생략했어요.
마무리
이번 글에서는 인라인 편집을 만들면서 새 도구들을 만났습니다.
useRef— 리렌더와 무관한 값/DOM 핸들 보관useEffect+focus()— 모드 전환 시 자동 포커스- draft state — 확정 전 변경의 취소 가능성을 만드는 패턴
- 키보드 처리 — Enter/Escape로 commit/cancel
onBlur자동 저장 — 사용자 친화적 UX
지금까지의 진척을 정리하면 추가 / 토글 / 삭제 / 필터 / 일괄 처리 / 편집까지 모두 됩니다. 다만 한 가지 큰 문제가 남았어요 — 새로고침하면 모든 데이터가 사라집니다. 다음 글이자 이 시리즈의 마지막인 "리액트로 Todo 앱 만들기 #5 영속화와 마무리"에서는 #13에서 만든 useLocalStorage 커스텀 훅을 적용해 데이터를 유지하고, 시리즈 전체를 회고하며 마무리하겠습니다.