検証エラーは閉じたタブの中にあった
更新画面で送信ボタンを押しても画面が何も変わらない、という報告を受けました。通知も出ないので、利用者からはボタンが効いていないように見えます。調べると検証は正しく走っていて、その結果を表示する場所が画面上に存在していないだけでした。
前提として、画面はReactで作っていて、フォームの状態はreact-hook-formが持ち、値の検証はzodのスキーマで行っています。項目数が多いのでRadix UIのタブで3つに分けており、上から「基本情報」「担当者」「明細」の順に並んでいます。
検証は走っていた
react-hook-form の handleSubmit はフォーム全体の値を検証し、適さない値があれば送信処理を呼ばずに終わります。つまり検証も判定も正しく行われていて、失敗したという事実だけが画面に出ていませんでした。
原因はタブの描画方法です。Radixのタブは選択中のタブの中身しか描画しません。選択されていない間もDOMに残す forceMount を付けていない限り、開いていないタブの中身は存在しません(Radix Primitives「Tabs」)。
<TabsContent value='client'>
<ClientTabContent /> {/* 「担当者」タブを開いている間しか存在しない */}
</TabsContent>
メッセージは各入力欄の下に出す作りなので、入力欄ごと存在しなければ出しようがない。formState.errors にはエラーが入っているのに、それを読む側が描画されていない状態です。
画面にすると、こうなっています。

エラーを持っているのはフォームの状態で、それを文字にして出す <ErrorText> は下の枠の中にあります。上の枠を見ている限り、押しても何も起きません。
利用者から見ると「送信されなかった」と「何も起きなかった」は区別できません。ボタンが壊れていると受け取られるのが、いちばんつらい。
送信の時点で、該当するタブへ切り替える
handleSubmit は第2引数に検証失敗時のハンドラを取れます(react-hook-form「handleSubmit」)。ここでタブを切り替えます。
const onSubmit = form.handleSubmit(
(data) => { /* 送信処理 */ },
(errors) => focusInvalidTab(Object.keys(errors)),
);
errors のキーは最上位のフィールド名です。配列項目(items.0.quantity など)は items に畳まれるため、フィールド名とタブの対応表は最上位の名前だけを持てば足ります。
切り替え先の候補が複数あるときは、タブの表示順で最も先のものを選びます。errors の列挙順は項目の登録順や描画順に左右されるので、そちらを信じるとスキーマや描画の並びを変えただけで切り替え先が変わってしまいます。
focusInvalidTab は行き先を決めてから切り替えます。行き先を決める側だけを取り出すと、こうなります。
export const findInvalidFieldTab = (invalidFieldNames: string[]): TabValue | null =>
tabOrder.find((tab) =>
invalidFieldNames.some((name) => fieldNamesByTab[tab].includes(name)),
) ?? null;
送信を押してからメッセージが見えるまでは、この経路をたどります。
[ 更新 ] を押す
│
└─ handleSubmit
├─ 検証OK → 第1引数の送信処理へ
└─ 検証NG → 第2引数のハンドラへ
errors = { email, items }
│
│ 基本情報 → statusId, holdReason
│ 担当者 → email
│ 明細 → items
│
└─ tabOrder の順に探し、最も先に該当する
「担当者」タブへ切り替えてから描画する
検証の経路が2つあると、切り替え先が定まらない
これで直ったつもりでいたのですが、切り替わらない場合が残りました。「理由」の項目だけ、条件付きの必須をzodのスキーマではなく送信処理の中で手動で検証していました。ステータスが一部の値のときだけ入力を求める、という条件です。
// 直す前
const onSubmit = form.handleSubmit((data) => {
if (isHoldReasonRequired({...})) {
const result = HoldReasonSchema.safeParse({...});
if (!result.success) {
form.setError('holdReason', {...});
return;
}
}
// 送信
});
この経路はzodの検証をすべて通過した後にしか走りません。「理由」(基本情報タブ)と「メールアドレス」(担当者タブ)が両方とも誤っているとき、切り替え先はzod側のエラーだけで決まるので担当者タブへ飛びます。利用者はメールアドレスを直してもう一度押すまで、理由の誤りに気付けません。しかもそのときは手動の検証が走るだけなのでタブは切り替わらず、また何も起きない画面に戻ります。
送信の成否を決めているのは、react-hook-formに渡したzodのスキーマだけです。そこを通過した時点で第1引数の側に入っているので、あとから setError を呼んでも第2引数のハンドラはもう走りません。切り替えの判定に載せるには、条件付きの必須も同じスキーマに置くしかありません。マスタのIDが要る場合はスキーマを関数にして受け取ります。
export const createPutSchema = (holdReasonRequiredStatusIds: readonly string[]) =>
PutSchema.superRefine((values, ctx) => {
if (
holdReasonRequiredStatusIds.includes(values.statusId) &&
values.holdReason === ''
) {
ctx.addIssue({ code: 'custom', path: ['holdReason'], message: '理由は必ず入力してください' });
}
});
ただし、superRefine を付けたスキーマは .partial() や .omit() での加工ができなくなります。キーを削ったり任意にしたりすると、そのキーを見ている検証を保てないためです。テストで .partial() を使っていると、実行時に落ちます。
.partial() cannot be used on object schemas containing refinements
基準となる値の組を1つ用意して、テストごとに必要なキーだけ上書きする書き方に変えました。
まとめ
- 「検証が走らない」ように見えて、実際は走っていて表示先のDOMが無いだけ、という壊れ方がある。閉じたタブは入力欄ごと存在しない
handleSubmitの第2引数で失敗を受け取り、該当するタブへ切り替える。切り替え先はタブの表示順で選ぶ。errorsの列挙順は描画順や登録順に左右される- 検証の経路を分けると切り替え先が定まらなくなる。条件付きの必須もスキーマに寄せる。ただし
superRefineを足したスキーマは.partial()で加工できなくなる - 選択中しか描画されない領域(タブ・アコーディオン・ダイアログ)に検証対象の入力欄を置くなら、検証の結果をどこに出すかを実装の前に決める
- 検証されるフィールドとタブの対応が漏れていないかは機械的に確かめる。スキーマのキー集合と対応表の差分を突き合わせるテストが有効だった
タブで項目を分ける判断そのものは変えていません。分けた瞬間に「見えない場所に出るメッセージ」が生まれるという副作用が付いてくるだけで、それを引き受ける置き場所を先に用意しておけば済む話でした。