Reactの編集モーダル設計パターンを図解。人物がフロー図を操作し、refロックや所有者・世代の束縛による二重送信・古い応答防止、API/IndexedDBへの非同期保存、入力検証、フォーカス管理などの要素が描かれています。

はじめに

一覧カードに「タイトル・メモの編集」モーダルを追加するだけの機能でも、保存先が複数あると意外な落とし穴があります。
今回は、編集内容が 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 も指定しています。

つまづきやすいポイント

  • saving state だけでロックしようとして、連打をすり抜けさせてしまう
  • await 後にアンマウント済みかを確認せず、不要な setState を呼んでしまう
  • 保存中に disabled を付けて、フォーカスが背景へ抜ける
  • catch の中で応答本文をログに出し、入力内容が意図せず残ってしまう

まとめ

編集モーダルの堅牢さは、「ref で同期的にロックする」「所有者と世代で対象を束縛する」「検証は保存先に委ねる」の3点でかなり安定します。
前提が変わったらモーダルごと破棄する設計にすると、遅れて届く応答を個別に判定する必要がなくなります。
失敗時は入力を保持し、再試行できる導線を残すことで、利用者の負担も抑えられます。
他の編集系ダイアログにも、同じパターンを展開しやすい構成です。