입력창이 사라질 때 값이 멋대로 저장되는 이유
controlled input unmount 시 blur가 발생하는 이유
난이도: ★★★☆☆
핵심 요약
setState는 즉시 DOM을 바꾸지 않는다 — 상태 업데이트 큐에 등록만 한다.- 이벤트 핸들러 종료 후 React 커밋 단계에서 input이 DOM에서 제거될 때, 브라우저가 자동으로 blur를 dispatch한다.
onKeyDown에서 input을 사라지게 하면onBlur저장 로직이 이중 실행되는 이유가 여기에 있다.- 해결은
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 제거 ← 여기서 실제로 사라짐
setState는 1번에서 멈춘다. 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 변경이 아닌 이유”의 뿌리
→ 확장 개념 (여기서 더 나아가면)
- XState / Finite State Machine — useRef 플래그보다 명시적인 상태 전이 모델링. 섹션 7의 useReducer + enum을 선언적으로 발전시킨 것
- React Strict Mode의 double invoke — 개발 모드에서 컴포넌트를 두 번 실행해 side effect 감지. useRef reset 누락 버그를 조기에 발견하는 데 활용 가능
- sessionStorage에서 읽어온 값이 useCallback 안에서 이전 값을 보여주는 이유 — 클로저 포획 타이밍 문제가 useCallback deps 누락으로 이어지는 같은 원리의 다른 케이스
- useLayoutEffect가 SSR에서 경고를 내는 이유와 useIsomorphicLayoutEffect — React commit phase 3단계(beforeMutation → mutation → layout)를 정확히 이해하는 심화. 이 노트에서 “DOM 제거 → blur dispatch” 순서를 이해했다면 다음 단계
↔ 같은 원리가 적용되는 곳
- Optimistic UI 패턴 — setState 비동기성을 이용해 서버 응답 전 미리 상태를 업데이트. 같은 비동기 타이밍, 다른 UX 전략
- sessionStorage 대신 URL에 상태를 담는 이유 — React 렌더 사이클 이해가 hydration flash 이해로 이어짐
참고
- MDN: blur event — blur가 focusout과 다른 점 (버블링 없음)
- React: useRef — “ref는 렌더 사이에 정보를 보존하지만 렌더를 trigger하지 않는다”
- React: useState batching — React 18 자동 배치 동작