ユーザー名、クーポン、振込先の確認は非同期になりやすい。A を入力してすぐ B に変えた場合、遅れて返る A の結果で B を「使用不可」と表示してはならない。結果がどの入力に属するかを管理する。
入力時点と検証結果を結び付ける
入力が変わるたびに世代番号を進め、短い待機後に検証する。応答が来たら世代と現在のテキストを再確認し、違えば捨てる。ローカルの正規表現だけで送信可能とせず、必要な非同期検証を待ち、最終判断はサーバーでも行う。
画面破棄時はタイマーを止める。通信障害を「名前が使えない」と誤表示せず、再試行できる状態を示す。
int generation = 0;
Future<void> validateName(String name) async {
final current = ++generation;
final result = await repository.checkName(name);
if (!mounted || current != generation || controller.text != name) return;
setState(() => nameError = result.isAvailable ? null : 'Name unavailable');
}
送信は別の状態として扱う
送信中は連打を防ぎ、冪等キーを付ける。サーバーから項目別エラーが来たら該当欄へ戻し、他の入力は保持する。失敗なら修正と再試行を許し、成功時は一度だけ画面を移る。
応答の順序逆転、画面離脱、回転、送信中の断線を試験する。
入力を変えたら古い結果は無効
登録フォームのユーザー名確認を考える。alice の要求 A が処理中に利用者が alice2 へ変え、要求 B を出す。A が後で「使用中」と返しても、そのエラーを alice2 に付けてはいけない。非同期検証ごとに開始時の値と連番を保存し、表示前に現在値と最新番号の両方を比較する。HTTP の中断は通信量を節約するが、中断と応答が競合するので、この検査も必要だ。
必須・長さ・形式などのローカル規則は入力時に確認できる。ユーザー名の空きやクーポンの有効性などサーバーが必要な検証は、入力が少し止まってから行う。画面を離れたらタイマーと購読を解放する。送信時には数秒前の「使用可能」を信用せず、サーバーが一意性を原子的に検査する。返された項目別エラーを対応する欄に置き、入力値は残す。通信障害はフォーム全体の問題であり、欄の値が不正という意味ではない。
final valueAtStart = controller.text;
final ticket = ++latestValidation;
final available = await api.isUsernameAvailable(valueAtStart);
if (!mounted || ticket != latestValidation || controller.text != valueAtStart) return;
setState(() => usernameError = available ? null : 'Name is taken');
フォームの生存期間全体を試す
高速入力、削除後に同じ値を再入力、遅い応答の逆転、画面回転、離脱後の応答、送信中の切断、連打を試す。成功後の画面遷移は一回だけで、失敗後にはボタンが再び使えることを確認する。StatefulWidget ではなく状態管理器を使うなら mounted を対応する生存期間検査に置き換える。重要なのは、結果が現在の値と現在の要求に属することだ。
