입력창이 사라질 때 값이 멋대로 저장되는 이유

controlled input unmount 시 blur가 발생하는 이유

reactdombrowserevents

난이도: ★★★☆☆


핵심 요약

  1. setState는 즉시 DOM을 바꾸지 않는다 — 상태 업데이트 큐에 등록만 한다.
  2. 이벤트 핸들러 종료 후 React 커밋 단계에서 input이 DOM에서 제거될 때, 브라우저가 자동으로 blur를 dispatch한다.
  3. onKeyDown에서 input을 사라지게 하면 onBlur 저장 로직이 이중 실행되는 이유가 여기에 있다.
  4. 해결은 useRef로 “이미 처리됨” 플래그를 동기적으로 세우는 것이다.

1. 무슨 일이 벌어지는가

// 문제가 된 코드
{editingId === id ? (
  <input
    onKeyDown={(e) => {
      if (e.key === "Enter")  handleSave(id);    // (A) 저장
      if (e.key === "Escape") setEditingId(null); // (B) 취소
    }}
    onBlur={() => handleSave(id)}  // (C) blur 시 저장
  />
) : null}
시나리오기대실제
Enter저장 1회저장 2회 (A → C)
Escape취소저장 1회 (B가 unmount 유발 → C 실행)

2. setState는 “즉시 DOM 변경”이 아니다

자주 헷갈리는 부분. setState 호출 ≠ DOM 변경.

setState 호출


React 업데이트 큐에 "렌더 예약" 등록

    │  (이벤트 핸들러 계속 실행)


이벤트 핸들러 종료 (Call Stack 비워짐)


React가 큐를 처리: render() 실행 → Virtual DOM diff → DOM commit


DOM에서 input 제거 ← 여기서 실제로 사라짐

setState1번에서 멈춘다. DOM은 훨씬 나중인 마지막 단계에서 바뀐다.
그 사이에 이벤트 핸들러가 모두 끝나고 나서야 React가 실제 작업을 한다.


3. blur-on-unmount 전체 시퀀스

Enter 눌렀을 때 (저장 2회 문제)

┌─────────────────────────────────────────────────────────┐
│                   Call Stack (동기 실행)                  │
│                                                          │
│  1. onKeyDown(Enter) 실행                                │
│     ├─ handleSave(id) 호출                               │
│     │    ├─ setEditingId(null)  → 큐에 예약 ①            │
│     │    └─ fetch() ... (이후 async 구간)                │
│     └─ 핸들러 종료                                        │
│                                                          │
└──────────────────────────┬──────────────────────────────┘
                           │ Call Stack 비워짐

┌─────────────────────────────────────────────────────────┐
│               React 배치 처리 (큐 소화)                   │
│                                                          │
│  2. 큐 ① 처리: editingId = null 로 re-render             │
│  3. commit: input을 DOM에서 제거                          │
│                                                          │
└──────────────────────────┬──────────────────────────────┘
                           │ 포커스 갖고 있던 요소가 사라짐

┌─────────────────────────────────────────────────────────┐
│              브라우저 blur dispatch (스펙)                 │
│                                                          │
│  4. 브라우저: "포커스 갖던 input이 사라졌다 → blur 발생"   │
│  5. onBlur → handleSave(id) 호출 ← 이게 두 번째 저장     │
│                                                          │
└─────────────────────────────────────────────────────────┘

Escape 눌렀을 때 (취소인데 저장 문제)

┌─────────────────────────────────────────────────────────┐
│  1. onKeyDown(Escape)                                    │
│     └─ setEditingId(null)  → 큐에 예약 ①               │
│        (handleSave는 호출 안 됨 — 취소 의도)              │
└──────────────────────────┬──────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  2. React commit → input DOM 제거                        │
└──────────────────────────┬──────────────────────────────┘

┌─────────────────────────────────────────────────────────┐
│  3. 브라우저 blur dispatch                                │
│     → onBlur → handleSave(id) ← 취소인데 저장됨 !!!     │
└─────────────────────────────────────────────────────────┘

4. 왜 ref가 해결책인가

useRef는 렌더링 사이클 밖에 있다.
.current 변경이 즉시, 동기적으로 반영된다 — 큐에 예약하지 않는다.

useState setter  →  큐에 등록 → 나중에 반영  (비동기)
useRef .current  →  즉시 반영                (동기)

이 차이를 이용해 “이미 처리됨” 플래그를 세운다.

Enter 시나리오 (ref 적용 후)

onKeyDown(Enter)
  ├─ renameCommittedRef.current = true   ← 동기, 즉시 반영
  └─ handleSave(id) → setEditingId(null) ← 큐 예약

React commit → input 제거
브라우저 blur dispatch
  → onBlur → handleSave(id)
      └─ if (renameCommittedRef.current) return  ← true이므로 차단 ✓

Escape 시나리오 (ref 적용 후)

onKeyDown(Escape) → handleCancel()
  ├─ renameCommittedRef.current = true   ← 동기, 즉시 반영
  └─ setEditingId(null)                  ← 큐 예약

React commit → input 제거
브라우저 blur dispatch
  → onBlur → handleSave(id)
      └─ if (renameCommittedRef.current) return  ← true이므로 차단 ✓

편집 시작 시 (ref reset)

pencil 클릭 → startEdit()
  ├─ renameCommittedRef.current = false  ← 반드시 reset
  ├─ setEditingId(id)
  └─ setValue(currentName)

※ 빠뜨리면 두 번째 편집부터 ref가 true인 채로 시작 → 저장이 안 됨

5. state로 같은 걸 시도하면 왜 안 되는가

// 이렇게 하면 안 됨
const [committed, setCommitted] = useState(false);

const handleSave = () => {
  if (committed) return;      // ← 체크 시점에 committed는 아직 false
  setCommitted(true);         // ← 큐에 예약. 아직 반영 안 됨!
  setEditingId(null);
};
handleSave 첫 번째 호출 (Enter)
  committed = false → if문 통과
  setCommitted(true) → 큐 예약 (아직 false)
  setEditingId(null) → 큐 예약

React commit → input 제거 → blur → handleSave 두 번째 호출
  committed = false → if문 통과  ← 이미 큐에 true가 있어도 아직 반영 전
  setCommitted(true) → 또 큐 예약
  → 이중 저장 발생

useState의 setter는 “나중에 반영”이라 blur가 실행되는 시점에 아직 false다.
useRef.current는 “즉시 반영”이라 blur 시점에 이미 true다.


6. 최종 구현 패턴

const renameCommittedRef = useRef(false);

// 편집 시작
const startEdit = (id: string, currentName: string) => {
  renameCommittedRef.current = false;  // ← reset 필수
  setEditingId(id);
  setValue(currentName);
};

// 저장 (Enter / 외부 클릭 blur)
const handleSave = async (id: string) => {
  if (renameCommittedRef.current) return;
  renameCommittedRef.current = true;
  const trimmed = value.trim();
  setEditingId(null);
  if (!trimmed) return;
  await fetch(`/api/items/${id}`, {
    method: "PATCH",
    body: JSON.stringify({ name: trimmed }),
  });
  await fetchData();
};

// 취소 (Escape)
const handleCancel = () => {
  renameCommittedRef.current = true;
  setEditingId(null);
};

// JSX
<input
  onKeyDown={(e) => {
    if (e.key === "Enter")  handleSave(id);
    if (e.key === "Escape") handleCancel();
  }}
  onBlur={() => handleSave(id)}
/>

패턴 이름: committed flag with useRef


7. 더 나은 접근: 상태 머신

ref 플래그는 동작하지만 암묵적이다.
”현재 어떤 상태인가”가 분산되어 버그가 숨기 쉽다.
규모가 커지면 useReducer + 상태 enum으로 명시적으로 모델링한다.

type EditState = "idle" | "editing" | "saving" | "cancelled";

// "saving" / "cancelled" 상태에서 blur가 와도 저장 로직을 실행하지 않음
// 상태 전이가 명확해서 버그가 숨을 곳이 없음
// ref 플래그처럼 reset 타이밍을 실수할 여지가 없음

부록 A: onBlur가 필요한 이유

Enter/Escape만 있으면 마우스로 외부 클릭 시 변경 내용이 유실된다.
onBlur는 “마우스 클릭으로 편집 종료” 케이스를 처리하기 위한 fallback이다.

마우스로 외부 클릭
  → input blur 발생 (unmount 아닌 단순 포커스 이탈)
  → onBlur → handleSave → 저장  ✓

부록 B: 브라우저가 blur를 자동 dispatch하는 이유

스펙: 포커스를 갖고 있던 요소가 DOM에서 제거되면 브라우저가 blur를 자동 dispatch한다.
포커스가 갈 곳을 잃었으니 포커스를 정리하는 것.

이는 React 고유 동작이 아닌 HTML 스펙 (W3C) 에 정의된 동작이다.
Vue, Angular, Svelte 어디서든 동일하게 발생한다.


커넥팅 닷

← 선행 개념 (이걸 알아야 이해된다)

  • 비동기 반복문에서 React 상태가 꼬이는 이유 — setState 배치 타이밍과 React 렌더 사이클. 이 버그의 전제 개념
  • W3C Focus Management 스펙 — 포커스를 갖던 요소가 DOM에서 제거되면 blur가 자동 dispatch된다. React 고유 동작이 아닌 HTML 스펙
  • React Reconciliation — Virtual DOM diff와 commit 단계의 분리. setState가 “즉시 DOM 변경이 아닌 이유”의 뿌리

→ 확장 개념 (여기서 더 나아가면)

↔ 같은 원리가 적용되는 곳

  • Optimistic UI 패턴 — setState 비동기성을 이용해 서버 응답 전 미리 상태를 업데이트. 같은 비동기 타이밍, 다른 UX 전략
  • sessionStorage 대신 URL에 상태를 담는 이유 — React 렌더 사이클 이해가 hydration flash 이해로 이어짐

참고