zukucode
主にWEB関連の情報を技術メモとして発信しています。

React 入力フォームを作る⑤ 保存競合と入力内容の保護

保存が競合したとき、最新データを取得してフォームへ上書きすると、利用者が入力した内容が消えてしまいます。

今回は、入力を残したまま最新値を別に取得し、利用者が比較してから編集を再開する画面を作ります。比較のために取得することと、入力を置き換えることを分けるのがポイントです。

  1. 入力値と保存値を設計する
  2. サーバーで入力を検証する
  3. 入力を保持するReactフォーム
  4. API接続とエラー表示
  5. 保存競合と入力内容の保護(この記事)

Versionで古い保存を拒否する

第2回のUPDATEには、利用者IDとVersionの両方を条件にしています。

保存時に確認する条件
WHERE user_id = @user_id
  AND version = @expected_version

同じVersionを持つ2つの画面から保存しても、一方がVersionを増やした後は、もう一方の更新条件が一致しません。

順番タブAタブB
1Version 12を取得Version 12を取得
2表示名を変更して保存自己紹介を編集中
3Version 13になるVersion 12を送って保存
4保存成功更新件数0件で409になる

この条件は、事前のSELECTだけではなく、UPDATE自身に含める必要があります。PostgreSQLのRead Committedでは、同じ行の更新が競合した場合に待機し、更新された行に対して条件を再評価します。PostgreSQLのトランザクション分離の説明を参照してください。

この例は1行の更新なので、編集開始時からロックを保持しません。HTTPヘッダーで条件付き更新を表す場合は、ETagとIf-Matchを使う方法も選択肢になります。

最新値を別のstateに取得する

第3回のフォームが使う、比較用コンポーネントです。

ProfileComparison.tsx
import { useRef, useState } from 'react';
import { toDraft, type Draft, type Profile } from './profileTypes';

type Props = {
  draft: Draft;
  loadLatest: () => Promise<Profile>;
  onResolve: (latest: Profile, keepDraft: boolean) => void;
};
export function ProfileComparison({ draft, loadLatest, onResolve }: Props) {
  const [latest, setLatest] = useState<Profile | null>(null);
  const [busy, setBusy] = useState(false);
  const [message, setMessage] = useState('');
  const running = useRef(false);
  async function load() {
    if (running.current) return;
    running.current = true;
    setBusy(true); setMessage(''); setLatest(null);
    try { setLatest(await loadLatest()); }
    catch { setMessage('最新の内容を取得できませんでした。入力を控え、ログイン状態や通信を確認してください。'); }
    finally { running.current = false; setBusy(false); }
  }
  const serverDraft = latest ? toDraft(latest) : null;
  const labels = { displayName: '表示名', bio: '自己紹介', weeklyGoal: '週の目標日数' };
  return <section aria-label="保存内容の比較">
    <button type="button" disabled={busy} onClick={load}>最新の内容を取得する</button>
    <p role="status">{message}</p>
    {latest && serverDraft && <>
      <table>
        <caption>まだ保存は行いません。内容を比較してください。</caption>
        <thead><tr><th scope="col">項目</th><th scope="col">現在の入力</th><th scope="col">サーバーの最新値</th></tr></thead>
        <tbody>{(Object.keys(labels) as (keyof Draft)[]).map(key =>
          <tr key={key}><th scope="row">{labels[key]}</th>
            <td style={{ whiteSpace: 'pre-wrap' }}>{draft[key] || '(空欄)'}</td>
            <td style={{ whiteSpace: 'pre-wrap' }}>{serverDraft[key] || '(空欄)'}</td></tr>)}</tbody>
      </table>
      <p>入力を残す場合、次の保存では上の「現在の入力」全体で更新します。</p>
      <button type="button" onClick={() => onResolve(latest, false)}>入力を破棄して最新値を使う</button>
      <button type="button" onClick={() => onResolve(latest, true)}>入力全体を残して編集を続ける</button>
    </>}
  </section>;
}

取得しただけではonResolveを呼びません。そのため、元の入力が突然書き換わることはありません。

最新値の取得に失敗したら、保存は止めたままにします。古いVersionを勝手に外して保存する処理は用意しません。

どちらの内容を使うか、明示的に選ぶ

比較後には2つの選択肢があります。

選択フォーム側の処理
入力を破棄して最新値を使うbaseとdraftを最新値で更新する
入力全体を残して編集を続けるbaseとVersionだけ最新値にし、draftを残す

後者は自動マージではありません。表示名だけを編集したつもりでも、フォーム内に残っている古い自己紹介を次の保存で送り直す可能性があります。

そのため、比較表には全項目を出し、入力全体で更新することを明示しています。必要な最新値を手で取り込んでから保存してください。項目数の多い業務フォームなら、項目ごとに「自分の入力」「最新値」を選べるUIへ広げる方法があります。

比較後に編集を続けても、実際の保存は別の操作です。その間にまた更新されたら、次の保存もVersion不一致で拒否します。

Versionだけを取り直して自動保存しない

次のような処理にはしません。

避けたい再送
409を受け取る
    ↓
最新のVersionだけ取得する
    ↓
古い入力を自動で再送する

これでは競合を検出しても、結局は他の画面の変更を上書きしてしまいます。Versionを新しくする前に、利用者が比較する段階を設けます。

通信失敗の場合も同じ比較画面を使えます。最新値が入力と同じなら、保存はすでに成功していた可能性があります。最新値を採用すれば、余分な再送をせずに保存済みの状態へ戻れます。

再認証時の入力の扱いを決める

認証切れでも、コンポーネントが残っている間は入力を保持します。ただし、ログイン画面へ移動するとstateが失われる構成もあります。再認証する前に入力を控えてもらう案内など、アプリの画面遷移に合わせて用意してください。

同じ利用者として再認証できた場合は最新値を取り直します。別の利用者へ切り替わった場合は、前のフォームを破棄します。前回の記事で示した、認証済み利用者IDによるページのkeyもこのためです。

入力から保存まで通して確認する

確認すること期待する結果
表示名に前後の空白を付けて保存保存結果の表示では空白が除かれる
任意項目を空欄にして保存DBではnull、再表示では空欄
表示名を空欄にして送信項目エラーが出て、他の入力は残る
不正な目標日数をAPIへ直接送るサーバーが拒否し、DBは変わらない
保存ボタンを続けて押す同じフォームから送信を重ねない
2つのタブで同じVersionを編集後の保存は409になり、入力が残る
比較用の取得が失敗する保存を再開せず、入力を残す
比較で最新値を採用する入力と基準値がそろい、未保存表示が消える
入力を残して再編集する最新Versionで、明示的に保存し直せる
保存後の応答だけを遮断する再取得して結果を確認できる

DBの競合は、保存層のテストでも確認します。別の接続から同じVersionで更新し、片方だけが更新行を取得できることを確かめてください。

画面の操作確認では、キーボードだけで入力・送信・比較の選択へ進めるか、エラーの場所を読み上げで理解できるかも確認します。

フォームが増えてから共通化するもの

この連載では小さなフォームを1つ作りました。別のフォームにも同じ失敗分類やCSRF処理が必要になったら、HTTPクライアントやエラー表示部品を共通化できます。

一方、表示名の文字数や空欄の意味は、このフォームの仕様です。共通化する際も、項目固有のルールまで万能な部品へ押し込めないようにします。

入力中の値を保持し、サーバーが検証した保存結果を受け取り、競合時は比較してから続ける。この流れを基準にすると、フォームが増えても保存の動作をそろえやすくなります。


関連記事