React 入力フォームを作る⑤ 保存競合と入力内容の保護
保存が競合したとき、最新データを取得してフォームへ上書きすると、利用者が入力した内容が消えてしまいます。
今回は、入力を残したまま最新値を別に取得し、利用者が比較してから編集を再開する画面を作ります。比較のために取得することと、入力を置き換えることを分けるのがポイントです。
Versionで古い保存を拒否する
第2回のUPDATEには、利用者IDとVersionの両方を条件にしています。
保存時に確認する条件WHERE user_id = @user_id
AND version = @expected_version同じVersionを持つ2つの画面から保存しても、一方がVersionを増やした後は、もう一方の更新条件が一致しません。
| 順番 | タブA | タブB |
|---|---|---|
| 1 | Version 12を取得 | Version 12を取得 |
| 2 | 表示名を変更して保存 | 自己紹介を編集中 |
| 3 | Version 13になる | Version 12を送って保存 |
| 4 | 保存成功 | 更新件数0件で409になる |
この条件は、事前のSELECTだけではなく、UPDATE自身に含める必要があります。PostgreSQLのRead Committedでは、同じ行の更新が競合した場合に待機し、更新された行に対して条件を再評価します。PostgreSQLのトランザクション分離の説明を参照してください。
この例は1行の更新なので、編集開始時からロックを保持しません。HTTPヘッダーで条件付き更新を表す場合は、ETagとIf-Matchを使う方法も選択肢になります。
最新値を別のstateに取得する
第3回のフォームが使う、比較用コンポーネントです。
ProfileComparison.tsximport { 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クライアントやエラー表示部品を共通化できます。
一方、表示名の文字数や空欄の意味は、このフォームの仕様です。共通化する際も、項目固有のルールまで万能な部品へ押し込めないようにします。
入力中の値を保持し、サーバーが検証した保存結果を受け取り、競合時は比較してから続ける。この流れを基準にすると、フォームが増えても保存の動作をそろえやすくなります。