
はじめに
一覧カードに「タイトル・メモの編集」モーダルを追加するだけの機能でも、保存先が複数あると意外な落とし穴があります。
今回は、編集内容が API(アカウント保存)とブラウザ内ストレージ(IndexedDB)の2系統に保存される実運用のサービスで、次の問題に直面しました。
- 保存中の二重送信
- 保存中の誤クローズ
- 削除やユーザー切替のあとに遅れて届く応答
- 保存先ごとに異なる検証ルール
この記事では、これらを「ref ベースのロック」「所有者と世代による束縛」「検証は保存先に委ねる」の3つで解いた設計パターンを、コピペできるコードとともに紹介します。
こんな人におすすめ
- 編集モーダルで二重送信や連打対策を入れたい方
- 非同期処理の完了後に、すでに不要になった画面へ反映してしまう不具合に悩んでいる方
- モーダルのアクセシビリティ(フォーカス管理、
role属性)を整えたい方 - Playwright で「別タブのログアウト」のような競合状態を再現したい方
入力の正規化は保存先に委ねる
最初に決めたのは、フロントで trim や文字数の切り捨てを足さないことです。
フロントに検証を持たせると、保存先(API 側のサニタイズや DB の列長検証)と二重管理になり、ルールがずれたときに原因を追いにくくなります。
代わりに UI では、保存先ごとの挙動を文言で明示する方針にしました。
- 空欄でも保存できる
- API は255文字まで
- 前後の空白やタグは除去される
画面側は「入力をそのまま渡し、成否だけを受け取る」薄い層に保てます。
二重実行ロックは useRef、表示は useState
saving という state だけで二重送信を防ごうとすると、同じイベントループ内の連打をすり抜ける可能性があります。
state の更新は再レンダリングを待つため、2回目のクリックが処理される時点では、まだ false のままかもしれないからです。
そこで、同期的に値を読み書きできる useRef をロックに使い、saving はボタンの無効化や表示専用に分けました。
const lock = useRef(false);
const active = useRef(true);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
useLayoutEffect(() => {
active.current = true;
return () => {
active.current = false;
};
}, []);
const save = async () => {
if (lock.current) return;
lock.current = true;
setSaving(true);
setError(null);
dialogRef.current?.focus(); // disabled でフォーカスが背景へ逃げないように
let success = false;
try {
success = await onSave(item.id, { title, memo });
} catch {
// 応答に入力内容が含まれる可能性があるため、ログや通知へ転記しない
}
if (!active.current) return; // アンマウント後は何もしない
lock.current = false;
setSaving(false);
if (success) onClose();
else setError("保存できませんでした。入力内容は保持されています。再度保存してください。");
};
ポイントは3つあります。
onSaveは成否を boolean で返す契約にし、例外も失敗として同じ経路で扱っていますawaitのあとにactive.currentを確認し、アンマウント済みの画面の state には触れません- 失敗時は入力を保持したまま、再試行できる状態に戻します
編集対象を「所有者 + 世代」で束縛する
保存中に別タブでログアウトされたり、一覧が再取得されたりすると、遅れて届いた応答が古い前提のまま画面に反映されてしまいます。
これを編集画面の中で個別に判定すると、条件が増えて漏れやすくなります。
そこで、編集対象を state に持つときに「誰の」「どの世代の」「どの ID か」をまとめて束縛しました。
const [editing, setEditing] = useState<{
id: ItemId;
owner: typeof update;
revision: number;
trigger: HTMLElement;
} | null>(null);
const editingItem =
editing?.owner === update && editing.revision === revision && !isLoading
? items.find((item) => item.id === editing.id)
: undefined;
owner は利用者(セッション)に結び付いた安定した関数の参照で、revision はリスト再取得の世代番号です。
どちらかが変われば editingItem が undefined になり、モーダルは自然に閉じます。
さらに、モーダルに key={${revision}:${id}} を付けておくと、再取得やユーザー切替のたびに内部 state が確実に捨てられます。
trigger を保持しているので、閉じたときに元のボタンへフォーカスを戻すこともできます。
フォーカスと通知のアクセシビリティ
保存開始時にボタンを disabled にすると、フォーカスが背景へ抜けてしまいます。
対策として、次の3点を押さえました。
- 保存開始の直後に、ダイアログ本体(
tabIndex={-1})へfocus()を移し、フォーカストラップを維持する - 入力欄は
disabledではなくreadOnlyにして、読み上げやフォーカスが途切れないようにする - 失敗の通知は
role="alert"、進行中の表示はrole="status"にする
<div ref={dialogRef} role="dialog" aria-modal="true" tabIndex={-1}>
<input value={title} readOnly={saving} onChange={(e) => setTitle(e.target.value)} />
<textarea value={memo} readOnly={saving} onChange={(e) => setMemo(e.target.value)} />
{saving && <p role="status">保存しています…</p>}
{error && <p role="alert">{error}</p>}
</div>
E2E で「他タブのログアウト」を再現する
このパターンが本当に効いているかは、Playwright で確かめられます。
保存リクエストを route で保留し、その間に別タブでトークンを消して、遅れた応答を解放する流れです。
await page.route("**/items**", async (route) => {
if (route.request().method() === "PATCH") {
pending = true;
await new Promise<void>((resolve) => { finish = resolve; });
await route.fulfill({ headers, json: { item: updated } });
return;
}
await route.fallback(); // GET / OPTIONS は通常応答
});
// 1. 保存ボタンを押す(PATCH が保留される)
// 2. 別タブで localStorage のトークンを削除する
// 3. モーダルが閉じることを確認する
// 4. finish() で遅延応答を解放する
// 5. 一覧にも「非公開メモ」にも反映されていないことを確認する
モバイルでヘッダーや固定ボタンがモーダルを覆う不具合は、z-index だけでは再発を防ぎきれませんでした。
elementFromPoint で右上・右下の座標にある要素が、モーダルの子孫かどうかを検証するテストを加えています。
あわせて max-height: calc(100dvh - 2rem) と overflow-y: auto も指定しています。
つまづきやすいポイント
savingstate だけでロックしようとして、連打をすり抜けさせてしまうawait後にアンマウント済みかを確認せず、不要なsetStateを呼んでしまう- 保存中に
disabledを付けて、フォーカスが背景へ抜ける catchの中で応答本文をログに出し、入力内容が意図せず残ってしまう
まとめ
編集モーダルの堅牢さは、「ref で同期的にロックする」「所有者と世代で対象を束縛する」「検証は保存先に委ねる」の3点でかなり安定します。
前提が変わったらモーダルごと破棄する設計にすると、遅れて届く応答を個別に判定する必要がなくなります。
失敗時は入力を保持し、再試行できる導線を残すことで、利用者の負担も抑えられます。
他の編集系ダイアログにも、同じパターンを展開しやすい構成です。

