리액트로 Todo 앱 만들기 #4 편집 기능

17 분 소요

지난 시간에는 필터링과 일괄 처리를 추가했습니다. 이번에는 입력한 할 일을 수정할 수 있게 만들겠습니다. 더블클릭으로 편집 모드 진입, Enter로 저장, Escape로 취소하는 인라인 편집을 구현합니다. 이 과정에서 기초 강좌에서 다루지 않았던 **useRef**가 처음 등장합니다.

이번 단계 목표

  • 항목 텍스트를 더블클릭하면 입력창으로 변신 (인라인 편집)
  • 입력창에 자동으로 포커스가 들어감
  • Enter 또는 포커스 잃기(blur) 시 저장
  • Escape로 취소
  • 빈 텍스트로 저장하면 항목 삭제

편집 상태를 어디에 둘까?

먼저 편집 상태(editingId, 편집 중인 항목의 ID)를 어디에 둘지 결정해야 합니다. 두 가지 선택지가 있습니다.

옵션 A. 각 TodoItem이 자기 편집 상태를 가짐

  • 장점: 단순. 부모와 무관하게 동작
  • 단점: 동시에 두 항목이 편집 모드일 수 있음 (사용자 경험상 보통은 한 번에 하나만 편집하길 원함)

옵션 B. TodoAppeditingId를 가짐

  • 장점: 한 번에 한 항목만 편집 가능 (다른 항목 편집 시작하면 자동으로 이전 항목 종료)
  • 단점: state가 하나 더 늘고 props 전달 필요

이번에는 옵션 B로 갑니다. 동시에 여러 항목이 편집되는 게 어색해서요.

텍스트 갱신 핸들러 추가

TodoAppupdateTodoText 함수를 추가하고, 편집 상태(editingId)도 만듭니다.

src/TodoApp.jsx의 핵심 부분만 보여드리겠습니다 (전체는 다음 단계에서):

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:

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와의 차이점은:

useStateuseRef
값을 보관
값이 바뀌면 리렌더
사용 시점화면에 영향을 주는 값화면과 무관하게 보관할 값, DOM 참조

여기서 inputRef는 화면에 영향을 주는 값이 아니라 "이 input 요소"를 가리키는 핸들일 뿐이라 useRef가 적합합니다. useState로 만들면 ref를 갱신할 때마다 무의미한 리렌더가 일어나겠죠.

편집 모드 진입 시 자동 포커스

편집 모드로 전환되면 사용자가 일일이 input을 클릭하지 않아도 자동으로 키보드 포커스가 들어와야 합니다. 이건 #10에서 배운 useEffect로 처리합니다.

useEffect(() => {
  if (isEditing) {
    setDraft(todo.text);
    inputRef.current?.focus();
    inputRef.current?.select();
  }
}, [isEditing, todo.text]);
  • isEditingtrue가 될 때 effect가 실행됨
  • setDraft(todo.text)로 draft를 항목 원본으로 초기화 (지난번 편집 미완료 상태가 남아있지 않도록)
  • focus()로 키보드 포커스 진입
  • select()로 기존 텍스트를 통째로 선택 (사용자가 바로 새 텍스트를 입력하면 덮어쓰기됨)

?.(옵셔널 체이닝)을 쓰는 이유는 inputRef.currentnull인 경우(아직 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 (전체):

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:

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 비교를 매번 하지 않아도 되도록요. 자식은 자기가 편집 중인지 아닌지만 알면 됩니다.

동작 확인

저장하고 다음을 시도해보세요.

  1. 항목 추가 후 텍스트를 더블클릭 → 입력창으로 변신, 자동 포커스, 텍스트 전체 선택
  2. 글자 수정 후 Enter → 저장됨
  3. 다시 더블클릭 → 글자 수정 → Escape → 원본 복원
  4. 더블클릭 후 입력창 비우고 Enter → 항목 삭제됨
  5. 더블클릭 후 다른 곳 클릭(blur) → 변경된 내용으로 저장
  6. 한 항목 편집 중에 다른 항목 더블클릭 → 이전 편집은 자동 종료, 새 항목으로 편집 옮겨감

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 커스텀 훅을 적용해 데이터를 유지하고, 시리즈 전체를 회고하며 마무리하겠습니다.