😱

React Hook Form × Zodで表示中項目のバリデーションがスキップされた原因は非表示項目のfatal:trueでした

に公開

こんにちは! 株式会社メドレーでエンジニアをしています、FY25新卒のringchangと申します。

この記事は「Medley (メドレー) Advent Calendar 2025」の10日目の記事です。
https://qiita.com/advent-calendar/2025/medley


はじめに

私が所属する 医療プラットフォームでは、医療機関向けカルテの設定画面のフォーム管理には React Hook Form(以降RHF) を採用し、フォームの状態管理とバリデーションを一元的に行っています。特に、バリデーションには Zod を用いることで保守性を高めています。

しかし開発中、「表示しているフィールドのバリデーションが実行される前に、検証処理が中断されてしまう」という問題に遭遇しました。原因を調べると、非表示フィールドでの fatal: true の利用が影響していることが分かりました。

この記事では、先輩エンジニアの方々が築き上げたフォーム管理の仕組みを紹介しながら、自分自身の理解不足によって生じたこの問題をどのように解決し、どんな検討ポイントがあったのかを振り返ります。

使用しているライブラリとバージョンはこちらです。

{
  "react": "^19.1.0",
  "react-hook-form": "7.54.2",
  "zod": "3.24.1"
}

現状説明

基本構成

先述の通り、フォーム実装では RHF をベースにし、Zodを型定義兼バリデーションスキーマとして組み合わせています。フォームのスキーマ定義とロジックは /schemas 階層に集約し、UI 層は /components/forms に分離しています。実際のプロダクトでは条件分岐や依存関係が複雑なため、Zod スキーマは複雑なネスト構造を取っています。以下は、今回の検証に必要な最小限の構成をデフォルメしたものです。

src/
 ├── components/
 │   └── forms/
 │        ├── AppForm.tsx    # コンポーネント内部で useServiceForm を呼び出し、
 │        │                   # その中で useForm の control を生成している
 │        │                   # 生成された control を Controller に渡している
 │        └── useAppForm.ts   # フォームロジック層:useForm 呼び出し、trigger、部分
 │
 └── schemas/
      └── viewModels/
           ├── appForm/
           │    └── appForm.ts       # フォーム全体の Zod スキーマ定義
           │
           └── medicalForm/
              └── medicalForm.ts     # 共通スキーマ


/schemas 階層:Zodによるスキーマ定義

appForm.ts

appForm.ts はフォーム全体を統合するスキーマです。
画面に表示させる初期値やデフォルト値も定義しています。
実際のカルテ設定画面では、契約プラン・診療方法などの条件によってフォーム項目を「表示・非表示」を切り替える必要があり、その状態を displayFields オブジェクトで管理しています。

appForm.ts
// src/schemas/viewModels/appForm/appForm.ts
import { z } from "zod";
import { medicalForm } from "../medicalForm/medicalForm";

export const appForm = z.object({
  morning: medicalForm,
  afternoon: medicalForm,
  displayFields: z.object({
    morning: z.object({
      basicInfo: z.boolean(),
    }),
    afternoon: z.object({
      basicInfo: z.boolean(),
    }),
  }),
});

export type AppForm = z.infer<typeof appForm>;

/**
 * 画面に表示されているフィールドだけを検証対象にする
 */
export function selectValidateFields(displayFields: AppForm["displayFields"]) {
  const fields: string[] = [];

  if (displayFields.morning.basicInfo) {
    fields.push("morning.estimateTime", "morning.sideEffect");
  }

  if (displayFields.afternoon.basicInfo) {
    fields.push("afternoon.estimateTime", "afternoon.sideEffect");
  }

  return fields;
}

export const defaultValues: AppForm = {
  morning: { xxxxx: "", yyyyy: "" },
  afternoon: { xxxxx: "", yyyyy: "" },
  // ...
};

selectValidateFields() は「どのフィールドが表示されているか」を判定し、RHF の trigger() に渡すための配列を返します。

medicalForm.ts

Zod の .superRefine() を利用して、複数項目にまたがる整合性チェックを行います。

https://github.com/colinhacks/zod/blob/main/packages/docs-v3/README.md#superrefine

medicalForm.ts
// src/schemas/viewModels/medicalForm/medicalForm.ts
import { z } from "zod";

export const medicalForm = z
  .object({
    estimateTime: z.union([z.number(), z.literal("")]),
    sideEffect: z.string().min(1, "副作用を入力してください"),
  })
  .superRefine((value, ctx) => {
    if (value.estimateTime === "") {
      ctx.addIssue({
        code: z.ZodIssueCode.custom,
        message: "診察時間を入力してください",
        path: ["estimateTime"],
        fatal: true,
      });
    }
  });

export type MedicalForm = z.infer<typeof medicalForm>;

/components/forms 階層:useFormとUIの分離

useAppForm.ts

useAppForm.ts はフォーム全体の状態管理を行うカスタムフックです。

RHF の useForm()resolver: zodResolver(appForm) を渡すことでフォーム全体を管理します。
さらに、displayFields の状態に応じて、表示されている項目だけをtrigger()に渡すようになっています。

trigger()は React Hook Form が提供する手動バリデーション関数です。
https://react-hook-form.com/docs/useform/trigger
RHF は trigger() の引数に応じて「どのフィールドのエラーを formState.errors に反映するか」を決定します。

useAppForm.ts
// src/components/forms/useAppForm.ts
import { useCallback, useState } from "react";
import { useForm, type FieldPath } from "react-hook-form";
import { zodResolver } from "@hookform/resolvers/zod";
import {
  appForm,
  defaultValues,
  type AppForm,
  selectValidateFields,
} from "@/schemas/viewModels/appForm/appForm";

/**
 * useAppForm
 * フォーム全体の状態管理を行うカスタムフック。
 * displayFields に応じて動的にバリデーションを切り替える。
 */
export function useAppForm(initialValues?: AppForm) {
  
  const form = useForm<AppForm>({
    resolver: zodResolver(appForm),
    defaultValues: initialValues ?? defaultValues,
  });

  const { trigger, getValues } = form;

  const validate = useCallback(async () => {
    // displayFieldsの値を取得
    const displayFields = getValues("displayFields");

    // 表示されているフィールドだけを抽出
    const validateFields = selectValidateFields(displayFields);

    // 表示中フィールドのみtriggerに渡す
    const isValid = await trigger(validateFields, { shouldFocus: true });

    return { success: isValid };
  }, [getValues, trigger]);

  return {
    ...form,
    validate,
  };
}

export type UseAppFormReturn = ReturnType<typeof useAppForm>;

AppForm.tsx(UI層)

AppForm.tsx
import { UseAppFormReturn } from "./useAppForm";

export const AppForm = ({ control, validate }: UseAppFormReturn) => (
  <form noValidate>
    <RhfNumberInput control={control} name="morning.estimateTime" />
    <button type="button" onClick={() => {
      const success = validate();
      if (!success) return;
      // フォーム送信処理を実装
    }}>
      入力内容を送信
    </button>
  </form>
);

RHFの場合、useFormから返されるhandleSubmitを使ってバリデーションを実行するケースが多いと思います。
しかし、「現在表示されているフィールドだけを部分的に検証したい」ため、handleSubmit の代わりに先ほどuseAppForm.tsで定義した validate()をボタン押下などの任意のタイミングで呼び出す設計にしています。

今回起こったこと

displayFields に「afternoon: medicalForm(午後診療設定)」のみを残すことで、午前の設定フォームを非表示にしたところ、appForm 内のその他のバリデーションが行われなくなりました。

通常、バリデーションエラーはデバッグの際にも UI 上に赤字等で表示されるため、該当箇所を修正すれば自然に解決します。
しかし今回のケースでは、

  • displayFields に含まれない(=非表示)フォームが裏でバリデーションされていた
  • UI上にエラーメッセージが出ず、原因箇所が特定できない

という状態になっており、結果としてデバッグに時間がかかってしまいました。

実際のカルテ画面にて .superRefine() 内に console.log() を仕込んでみると、非表示のフィールドでも同じ分だけバリデーションが呼ばれていることが確認できました。

午前・午後両方displayFields に含む(=表示)の場合

午後がdisplayFields に含まれない(=非表示)の場合

当時の自分はまず、「RHFのtriggerにdisplayFieldsを渡していたら、その対象のバリデーションのみ発火するはずなのに、なぜ?」と思いました。

原因

前提:displayFieldsに関わらず、superRefineは呼び出される

Zod の内部では、useForm()zodResolver(appForm)を指定した時点で、スキーマ全体を常に評価する仕組みになっています。

resolver: zodResolver(appForm);

Zod は「スキーマ全体の型整合性を保証する」という思想に基づいているため、フォームの一部のフィールドだけを trigger() で検証したとしても、Zod 側では内部的に常にスキーマ全体を次の手順で評価します。

  1. すべてのフィールド の通常バリデーションを実行

  2. すべての .superRefine() を実行

  3. すべてのエラーを収集

RHF 側では、trigger() に指定されたフィールド名だけをformState.errors に反映します。superRefineの失敗時には ctx.addIssue() を通じて各フィールドに紐づくエラーメッセージが蓄積されます。RHF はその結果を選択して UI に反映します(例errors.name.message など)。

このため、Zod 上ではすべてのバリデーションが実行されているのに、UI 上では非表示のフィールドに対応するエラーが見えない、というズレが発生していました。

fatal:trueを使っていたことが、バリデーションが停止した直接的な原因

バリデーションが停止した直接的な原因は、superRefine() 内で fatal: true を指定していた点にあります。このフラグによって、Zod の検証処理が 「エラーが発生した時点で強制的に中断される」 状態になっていました。

つまり、非表示フィールド(=displayFields が false の箇所)でも内部的にsuperRefine() が呼び出され、その中で fatal: true に到達すると、以降のフォーム全体のバリデーション処理まで止まってしまう事象が発生していたということです。

https://github.com/colinhacks/zod/blob/main/packages/docs-v3/README.md#abort-early

Abort early
By default, parsing will continue even after a refinement check fails. For instance, if you chain together multiple refinements, they will all be executed. However, it may be desirable to abort early to prevent later refinements from being executed. To achieve this, pass the fatal flag to ctx.addIssue and return z.NEVER.

.superRefine() 内に console.log() でも検証しましたが、UI 上では非表示のフィールドに対してもバリデーションが実行されていました。
fatal: trueは、そのようなパフォーマンス対策として導入され、「以降の superRefine や他フィールドのバリデーションをスキップする」目的で使われます。

解決策の検討

非表示フォームでもバリデーションが実行される以上、displayFields で「どの項目を表示・検証するか」を管理している構成の中では、fatal: true によって強制的に動作を停止させるのは避けるべきと判断しました。

今回はReact Hook Form × Zodを使っていてハマったところについてまとめてみました。
チーム内でも今後注意していきたいです。

終わりに

メドレーでは様々な職種で人材を募集しているので、興味のある方はぜひチェックしてみてください!
https://www.medley.jp/jobs/

Medley Advent Calendar 2025、明日は @bbrfkr さんです!

株式会社メドレー

Discussion