フロントエンド

入力を編集するボタンがブラーも引き起こし、フォーカスガードを破壊する

ボタンが入力に書き込むと、フォーカスベースのダーティガードは暗黙的に失敗します。ここでは `blur` が先に発火する理由と、ライブ更新フォームで代わりに何を使用すべきかを解説します。

この記事は英語の原文をAIモデルが翻訳したものです。表現が原文と異なる場合があります。 英語の原文を読む

フォームがサーバーからライブアップデートを受け取る場合、ユーザーが入力している内容を上書きしないようにする方法が必要です。一般的な解決策はフォーカスフラグです。focus 時に editing = true を設定し、blur 時にそれをクリアし、それが true の間は同期をスキップします。これはタイピングには有効です。しかし、同じ入力フィールドに書き込むボタンを追加した瞬間に破綻します。

その理由は些細で見落としやすいものです。フォーカスされている入力フィールドの隣にあるボタンをクリックすると、まずその入力フィールドがブラーされます。あなたのガードはまさに必要な瞬間にオフになり、もし blur ハンドラーが遅延された処理もフラッシュする場合、更新処理にゴーサインを出してしまったことになります。

私は管理パネルの有効期限フィールドに「+31日」ボタンを追加しているときにこの問題に遭遇しました。そのボタンは入力フィールドの数値を増やし、保存は既存の保存ボタンに任せるものでした。テストでは問題ないように見えました。しかし、ライブフィード下では、編集内容がほぼ毎回失われました。

この失敗は決定論的なものであり、競合状態ではありません

以下が、ブラウザが実行する順序でのシーケンスです。

  1. ボタンでの mousedown により、フォーカスが入力から外れます。
  2. 入力が blur を発行します。ハンドラは editing = false を設定し、多くのコードベースでは、編集中に遅延されていた更新をフラッシュします。
  3. click が発行されます。あなたのハンドラは新しい値を入力に書き込みます。
  4. 数百ミリ秒後にサーバープッシュが到着します。editingfalse なので、同期が実行され、入力が保存された値に置き換えられます。

ここには、タイミングの運に依存するものは何もありません。ステップ2は常にステップ3に先行するため、ボタンが書き込むときにはフラグは常に下がっています。唯一の変数は、ユーザーが保存を押す前に更新が到着するかどうかであり、ビジーな管理画面ではそれはほぼ確実です。

さらに単純な2つ目のパスがあります。ユーザーが入力にまったくフォーカスせず、単に行を選択してボタンをクリックした場合、editing は最初から true になることはありませんでした。

ボタンが書き込むときにフォーカスガードが下がる理由 1 2 3 ガードオフ mousedown blur click が書き込む sync が上書きする // blur は常に click の前に実行されるため、値が設定されるときにはフラグは下がっています ## Why a dirty flag beats a focus flag Focus answers "is the cursor in this field right now." That is not the question. The question is "does this field hold a value the server does not have yet." Those two coincide while someone types, which is why the focus flag survives so long, and they come apart as soon as anything other than the keyboard writes to the field. So track the second thing directly: ```js // 状態 durationDirty: false, // ボタン addDays(n) { const s = String(this.durationInput ?? '').trim() if (s === '') { notify('最初に基準値を入力してください'); return } if (!/^\d+$/.test(s)) { notify('整数のみ'); return } const next = Number(s) + n if (next > MAX) { notify(`最大値は ${MAX} です`); return } this.durationInput = next this.durationDirty = true // 値が実際に変更された後でのみ } // 同期 if (!this.editingDuration && !this.durationDirty) { this.durationInput = fresh.durationDays ?? '' }

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.