입력을 수정하는 버튼이 블러 처리도 하여 포커스 가드를 깨뜨림
포커스 기반 더티 가드는 버튼이 입력란에 값을 쓸 때 조용히 실패합니다. blur가 먼저 발생하는 이유와 실시간으로 업데이트되는 폼에서 대신 무엇을 사용해야 하는지 설명합니다.
폼이 서버로부터 실시간 업데이트를 받는다면, 사용자가 입력 중인 내용을 덮어쓰지 않도록 할 방법이 필요합니다. 일반적인 해법은 포커스 플래그를 사용하는 것입니다. 즉, focus 시 editing = true로 설정하고, blur 시 해제하며, 이 플래그가 true인 동안에는 동기화를 건너뛰는 것입니다. 이 방법은 타이핑에는 효과가 있습니다. 하지만 동일한 입력 필드에 값을 쓰는 버튼을 추가하는 순간 무너집니다.
그 이유는 사소해서 놓치기 쉽습니다. 포커스된 입력 필드 옆의 버튼을 클릭하면 먼저 해당 입력 필드가 블러(blur) 처리됩니다. 보호 장치는 바로 필요한 그 순간에 꺼지게 되고, 만약 블러 핸들러가 지연된 작업까지 처리한다면, 리프레시(refresh)가 실행되도록 허용해 버린 셈입니다.
저는 관리자 패널의 만료일 필드에 "+31 days" 버튼을 추가하다가 이 문제를 겪었습니다. 이 버튼은 입력 필드의 숫자를 늘리고, 저장은 기존의 저장 버튼에 맡기는 식이었습니다. 테스트 환경에서는 괜찮아 보였습니다. 하지만 실시간 피드가 있는 환경에서는 거의 매번 수정 내용이 사라졌습니다.
실패는 경쟁 상태가 아니라 결정적입니다
다음은 브라우저가 실행하는 순서입니다.
- 버튼에서
mousedown이 발생하면 입력 필드에서 포커스가 벗어납니다. - 입력 필드에서
blur이벤트가 발생합니다. 핸들러는editing = false로 설정하고, 많은 코드베이스에서는 편집 중에 지연되었던 새로고침을 즉시 실행합니다. click이벤트가 발생합니다. 핸들러는 새 값을 입력 필드에 씁니다.- 수백 밀리초 후에 서버 푸시가 도착합니다.
editing이false이므로 동기화가 실행되어 입력 필드의 값을 저장된 값으로 대체합니다.
여기에는 타이밍 운에 좌우되는 것이 아무것도 없습니다. 2단계는 항상 3단계보다 먼저 실행되므로, 버튼이 값을 쓸 때는 플래그가 항상 내려가 있습니다. 유일한 변수는 사용자가 저장을 누르기 전에 업데이트가 도착하는지 여부이며, 바쁜 관리자 화면에서는 거의 확실하게 발생합니다.
훨씬 더 간단한 두 번째 경로도 있습니다. 만약 사용자가 입력 필드에 전혀 포커스하지 않고 그저 행을 선택하고 버튼을 클릭하기만 한다면, editing은 애초에 true가 된 적이 없습니다.
Two details in there matter more than they look.
Set the flag after the write, not before. If you set it at the top of the handler, a rejected click still marks the form dirty. The user sees an "unsaved" badge for a value that never changed, and the next sync is blocked for no reason.
Reject instead of clamping. An earlier version capped the result at the maximum. Press the button at 3640 with a cap of 3650 and the value moves to 3650. Press again and nothing happens, with no message. The button looks broken. Refusing with a reason is shorter to write and easier to trust.
Do not put the dirty flag in the refresh gate
The obvious next step is to reuse the flag everywhere you already check the focus flag. Resist that for the scheduler that decides whether to refresh at all.
A focus flag clears itself. The user clicks somewhere else and it is gone. A dirty flag is sticky by design: it stays until the value is saved or discarded. If you put a sticky flag in the "should I refresh" condition, then a user who types a value and walks away freezes the whole screen behind a "changes available" banner, indefinitely.
Keep the two scopes separate:
- The refresh scheduler may use focus flags, so a refresh is deferred for a second or two while someone types.
- The field sync inside the refresh uses the dirty flag, so the one input with unsaved work is skipped while everything else updates.
The list, the badges, and the rest of the panel keep moving. Only the field the user is editing holds still.
Let the stale value stay visible next to the new one
Once the input stops syncing, it will disagree with the server. Do not hide that. Show both.
In the panel I was working on, the input showed the pending value while a line under it kept showing the stored one:
[ 84 ] [저장] [영구] [+31일]
현재 53일, 2026-08-30에 만료 * 저장되지 않음, 저장을 누르세요
Two numbers on screen sounds like a bug report waiting to happen, and the first instinct is to freeze the caption too. That instinct is wrong. While the field is dirty, that line is the only channel telling the user what the server actually holds. If another operator extends the same license to 90 days, the caption moves to 90 and the input stays at 84. The user can see that something changed underneath and decide whether to overwrite it.
Freeze the caption and you get the worst version: a screen that looks internally consistent while being wrong.
Sticky state needs a lifecycle
A flag that clears itself needs no cleanup. A sticky one does, and the cleanup is where the second round of bugs lives.
The one that bit me: the panel is rendered conditionally on a selected record. Delete the record and the subtree is removed from the DOM. Removal does not fire blur. So an input that was focused when its panel disappeared leaves its focus flag true forever, and every later update gets deferred into a banner that nobody clicks.
The fix is a single function that owns clearing the selection, so no caller sets it to null directly:
clearSelection() {
this.selected = null
this.durationInput = ''; this.durationDirty = false; this.editingDuration = false
this.tierInput = ''; this.editingTier = false; this.editingNote = false
}
Call it from delete, and from the refresh path when the selected record is no longer in the returned list. Two more rules worth writing down:
- On failure, keep the flag. If a save returns 400 or 502, the value in the field is still the only copy of the user's intent. Clearing dirty there invites the next sync to erase it.
- On success, order the writes. Assign the server response to the field, then clear dirty, then refetch. Clearing first opens a window where the refetch can touch the field before you have put the confirmed value in it.
What about preventing the blur?
There is a tempting one-liner: block the default action of mousedown on the button so focus never leaves the input.
<button onmousedown="event.preventDefault()" onclick="addDays(31)">+31일</button>
It does keep focus, and for a user who was already typing it does keep the focus flag up. It is still the wrong fix, for two reasons.
It only preserves focus that already exists. It does not create it. The user who selects a row and clicks the button without ever touching the input is still unguarded, and that is the exact path that started this whole investigation.
And when it does work, it works by leaving the focus flag up for a long time. If your refresh scheduler defers on that flag, you have traded a lost edit for a screen that stops updating while a user stares at a form. That is the sticky-flag problem from the previous section, arriving through a side door.
Fix the model, not the event.
What this does not fix
A dirty flag protects the edit from your own refresh. It does nothing about a second person editing the same record.
The scenario is the mirror image of the one we started with. You raise the value to 84 and step away. Someone else extends the same record to 90 and saves. Your caption updates to 90, your input still reads 84, and if you come back and press Save without reading the caption, you overwrite their change with no warning. Before the dirty flag, the refresh would have quietly replaced your 84 and the conflict would never have existed, because your edit would already be gone.
That is not a regression so much as a trade. You moved from "the system silently discards your work" to "the system silently discards someone else's, if you ignore what is on screen." The second is better, because at least the information is visible, but it is not the same as being safe.
The real fix is a compare-and-set: capture the stored value when the edit begins, send it with the save, and let the server reject the write if the record moved. That is a change to the API contract, not a front-end tweak, so it deserves its own decision rather than being smuggled in. What is worth doing cheaply is deciding, in advance, which of the two failures you would rather have, and making the visible one visible enough that a careful person can catch it.
Two smaller traps in the same neighborhood
While wiring this up, two unrelated-looking things came from the same root of assuming the framework does more than it does.
Number inputs give you strings. <input type="number"> constrains what the user can enter. It does not change the type of the bound value. Several frameworks only cast when you ask them to, with an explicit modifier. So the value you send is "1", not 1, and a server that strictly parses integers rejects it. The irony in my case was sharp: "1" was on my own list of inputs the API should refuse, and the front end was the thing sending it.
Text-setting directives erase children. A directive that sets an element's text content replaces everything inside it on every evaluation. Put a conditional badge inside that element and it vanishes the first time the text is recomputed. Split the container: one child for the bound text, a sibling for the badge.
Neither is deep. Both cost a debugging round, because the symptom appears at runtime in a path you were not looking at.
Questions this usually raises
Does this apply outside a specific framework?
Yes. The order of mousedown, blur, and click is defined by the browser, not by your view layer. Anything that clears an editing flag on blur inherits the problem.
What if I have several fields with buttons? One flag per field. A single shared flag means an unsaved edit in one place blocks syncing in another, and finding that later is unpleasant.
Is losing one edit worth this much machinery? The machinery is one boolean, one condition, and one cleanup function. The failure it prevents is silent: the value reverts, the save succeeds, and the confirmation message reports the old number as if it were the new one. The user has no signal that anything went wrong. Silent wrong is expensive to find later.
Should the button just save immediately instead? That is a real option and it removes the problem entirely. It is the wrong trade when the button sits next to other buttons with different effects, because a misclick becomes a committed change with an audit entry. Editing the field and requiring a separate confirm keeps a misclick recoverable.
How do I test it? Turn the flag off and watch the failure appear. If disabling the guard does not reproduce the loss, the guard is not the thing protecting you and something else is masking it. That check took me a minute and was worth more than the rest of the testing, because it proved the code was earning its place.
A useful second check is the refresh path you did not think about. Most live screens have more than one way to reload: the push handler, a polling fallback when the socket drops, and usually a "changes available, click to refresh" banner. In my case that banner called the reload function directly, skipping the scheduler entirely. Because the guard lives inside the sync rather than in the scheduler, it covered that path too. But I only learned that by clicking the banner while an edit was pending, not by reading the code. List every entry point into your reload function and try each one with a dirty field.
Does the guard belong in the component or in the store? Wherever the sync happens. The point of the pattern is that the field-level skip sits at the exact place where server data is written into form state. If that write lives in a store action, the flag goes there. Putting it in the component and hoping the store cooperates gives you two sources of truth about who owns the input.