🤔

React Hook Form の制御コンポーネントについて考える

に公開

はじめに

こちらは e-dash advent calendar 2025 の 14 日目の記事です。

はじめまして。 今年の 8 月にフロントエンドエンジニアとして join しました maeda です。
React のアプリケーション下で複雑なフォームの実装する場合、皆さんはどのような構成を採用されていますか?
弊社では React Hook Form と Zod を組み合わせて実装していますが、型安全性やコンポーネント分離・共通化に関して頭を悩ませることが多いので、それについての記事を書きます。

動作環境

  • React: 19.2.1
  • TypeScript: 5.9.3
  • react-hook-form: 7.68.0
  • zod: 3.25.76, 4.1.13
  • @hookform/resolvers: 5.2.2

サンプルコードの要件

この記事内のサンプルコードは以下の要件をベースとして実装しています。

  • 年齢を入力するフォームを作る
    • 年齢は数字ではなく生年月日から計算すべきだが、今回はサンプルの都合上数字を入力させる
  • 初期値は空文字列
  • 入力値に応じて、メッセージを表示する
  • 入力値が空文字列の時はメッセージを表示しない
  • submit 時は age を number として扱う

React Hook Form と Zod 3 によるサンプル

まずは前述の要件に従って、React Hook Form, Zod 3 で小さなサンプルプログラムを書きました。

account-form.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import z from 'zod';

const ageSchema = z.coerce.number().min(0).max(150);
const formSchema = z.object({ age: ageSchema });

type FormSchema = z.infer<typeof formSchema>;

export function AccountForm() {
  const form = useForm<FormSchema>({
    mode: 'onChange',
    resolver: zodResolver(formSchema),
  });

  const onSubmit = (values: AccountFormSchemaOut) => {
    values.age;
    //     ^?(property) age: number
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <Controller
        control={form.control}
        name='age'
        render={({ field }) => {
          return (
            <>
              {field.value && <p>{field.value}歳です。</p>}
              {/*    ^?(property) value: number */}
              <input {...field} />
            </>
          );
        }}
      />
    </form>
  );
}

このコンポーネントは要件を満たして動作します。型エラーもないし問題なさそう?

このサンプルの落とし穴

残念ながらこのサンプルコードは非常に脆弱で、小さな機能変更によってランタイムエラーを引き起こす危険性があります。
試しにいくつかのコード追記してみると、以下のような問題を見つけることができました。

account-form.tsx
// ...中略
export function AccountForm() {
  // ...中略
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <Controller
        control={form.control}
        name='age'
        render={({ field }) => {
          return (
            <>
              {typeof field.value}
              {/* 初回レンダリング時は "undefined", 更新後は "string" と表示される */}

              {field.value}
              {/*    ^?(property) value: number */}
              {/* defaultValues が未指定なので初期値は undefined だが、型は number となっている */}

              <p>{field.value.toLocaleString()}</p>
              {/* Cannot read properties of undefined (reading 'toLocaleString') */}
              {/* undefined なので Object.prototype.toLocaleString() を使えない */}

              <p>{field.value?.toFixed()}</p>
              {/* field.value.toFixed is not a function */}
              {/* オプショナルチェーンで undefined を回避したとしても、Number.prototype のメソッドを呼び出せない  */}

              <input {...field} />
            </p>
          );
        }}
      />
    </form>
  );
}

useForm の resolver に zod のスキーマを渡しており、fieldState.error でバリデーションエラーを取得できます。
そのため field.value は型の通り number であると期待していましたが、ランタイムでは string, undefined を取りうるようです。

また、制御コンポーネントとして扱いたいコンポーネントの value に undefined を渡すと、初回更新時に以下の Warning がコンソールに出力されます。

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components

これは useForm の defaultValues を指定することで回避が可能ですが、今度は下記の問題に遭遇します。

account-form.tsx
// ...中略
export function AccountForm() {
  const form = useForm<FormSchema>({
    defaultValues: { age: "" },
    //               ^?(property) age?: number | undefined
    // Type 'string' is not assignable to type 'number'.ts(2322)
    mode: 'onChange',
    resolver: zodResolver(formSchema),
  });
}

前述の要件の内、「初期値は空文字列」という項目を維持するために空文字列を指定しましたが、型エラーになってしまいました。
年齢入力フォームであれば、「最もユーザー層の多い年齢をデフォルト値とする」という仕様に変更して回避できます。
しかし、デフォルト値を指定したことによってユーザーが誤った値を送信しやすくなるという懸念もあります。

.....。
ここは仕様を優先し、型に嘘をついて先に進むとしましょう。

account-form.tsx
// ...中略
export function AccountForm() {
  const form = useForm<FormSchema>({
    defaultValues: { age: "" as unknown as number },
    mode: 'onChange',
    resolver: zodResolver(formSchema),
  });
}

useForm のジェネリクスで型安全性を維持する

このままでは寝覚めが悪いので、正しい型を指定していきます。
useForm には実はジェネリクスが 3 つあり、1 番目に入力型、3 番目に出力型を指定可能です。
ちなみに 2 番目はコンテキストの型なので、使用しない場合は undefined を指定します。

account-form.tsx
import { zodResolver } from '@hookform/resolvers/zod';
import { Controller, useForm } from 'react-hook-form';
import z from 'zod';

const ageSchema = z.coerce.number().min(0).max(150);
const inputSchema = z.object({ age: z.unknown() });
const outputSchema = z.object({ age: ageSchema });

type InputSchema = z.infer<typeof inputSchema>;
type OutputSchema = z.infer<typeof outputSchema>;

export function AccountForm() {
  const form = useForm<InputSchema, undefined, OutputSchema>({
    defaultValues: { age: '' },
    mode: 'onChange',
    resolver: zodResolver(outputSchema),
  });

  const onSubmit = (values: AccountFormSchemaOut) => {
    values.age;
    //     ^?(property) age: number
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <Controller
        control={form.control}
        name='age'
        render={({ field }) => {
          return (
            <>
              {field.value && (
                {/*  ^? unknown */}

                <p>{field.value}歳です。</p>
                {/* Type '{}' is not assignable to type 'string | number | bigint | boolean | ReactElement<any, string | JSXElementConstructor<any>> | Iterable<ReactNode> | ReactPortal | Promise<...> | null | undefined'. */}
                {/* unknown を型絞り込みしたことで {} と評価されるが、JSX の子要素として使用可能な型ではないので型エラー */}
              )}

              <input {...field} />
              {/* Types of property 'value' are incompatible. Type 'unknown' is not assignable to type 'string | number | readonly string[] | undefined'. */}
              {/* ComponentProps<'input'>['value'] は 'string | number | readonly string[] | undefined' なので型エラー */}
            </>
          );
        }}
      />
    </form>
  );
}

素晴らしいですね 👏
field.value は unknown 型として評価され、無事 静的に検知できました。
この型エラーを修正すると以下のような記述になります。

account-form.tsx
// ...中略
const ageSchema = z.coerce.number().min(0).max(150);
// ...中略
export function AccountForm() {
  // ...中略
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <Controller
        control={form.control}
        name='age'
        render={({ field }) => {
          // メッセージ用の number 型の値
          const age = ageSchema.safeParse(field.value).data;
          // ComponentProps<'input'>['value'] の型エラーを防ぐための値
          const value = typeof field.value === 'string' ? field.value : '';

          return (
            <>
              {age !== undefined && value !== "" && (<p>{age}歳です。</p>)}
              <input {...field} value={value} />
            </>
          );
        }}
      />
    </form>
  );
}

入力型・出力型の定義を簡略化

前章では型安全性を維持できるようになりましたが、代償として入力型・出力型を二重管理する必要が発生しました。
このサンプル程度の規模であれば問題はありませんが、業務では複雑なスキーマ定義を要求されることが少なくありません。
フォームのフィールドが追加・変更・変更されるたびに、入力型と出力型の整合性を気にすることは避けたい所です。

しかし、これは Zod 4 を使うことにより、少し簡潔に書けるようになりました。
Zod 3 では z.corece を使用した場合には z.input で推論される型は number でしたが、 Zod 4 では unknown に変更されているためです。
z.preprocess を使用した場合は Zod 3 でも unknown と推論されます。Zod 4 へのアップグレードが困難な場合は z.coerce を使用しない選択肢も良いでしょう。
(その場合、 unknown のキャスト処理を自前で書く必要がありますが)

import z from "zod";

const ageSchema = z.coerce.number().min(0).max(150);

const optionalString = z.preprocess(
  (v) => (v === "" ? undefined : v),
  z.string().optional()
);

const numericString = z.string().transform((val) => Number(val));

// z.coerce は Zod 3, Zod 4 で動作が異なる
type AgeSchemaIn = z.input<typeof ageSchema>;
//   ^?:number   (Zod 3)
//   ^?:unknown  (Zod 4)
type AgeSchemaOut = z.output<typeof ageSchema>;
//   ^?:number   (Zod 3)
//   ^?:number   (Zod 4)
type AgeSchema = z.infer<typeof ageSchema>;
//   ^?:number   (Zod 3)
//   ^?:number   (Zod 4)

// preprocess で入力型を変換する場合、 Zod 3, 4 共に同じ推論結果
type OptionalStringIn = z.input<typeof optionalString>;
//   ^?unknown
type OptionalStringOut = z.output<typeof optionalString>;
//   ^?string | undefined
type OptionalString = z.infer<typeof optionalString>;
//   ^?string | undefined

// transform で出力型を変換する場合、Zod 3, 4 共に同じ推論結果
type NumericStringIn = z.input<typeof numericString>;
//   ^?string
type NumericStringOut = z.output<typeof numericString>;
//   ^?number
type NumericString = z.infer<typeof numericString>;
//   ^?number

実装を進める

ようやく型安全なフォームコンポーネントを実装できたので、前述の要件に従って開発を進めます。
まずは事前準備として「入力値に応じて、メッセージを表示する」で利用するメッセージ出力用コンポーネントを作ります。

jp-era.tsx
interface JpEraProps {
  age: number;
}

export function JpEra({ age }: JpEraProps) {
  const era = getJapaneseEra(age);

  return (
    <p>
      現在{age.toFixed()}歳で、生まれた年は{era}です
    </p>
  );
}

function getJapaneseEra(age: number): string {
  const currentYear = 2025;
  const birthYear1 = currentYear - age;
  const birthYear2 = currentYear - age - 1;

  const getEra = (year: number): string => {
    if (year >= 2019) return "令和";
    if (year >= 1989) return "平成";
    return "昭和";
  };

  const era1 = getEra(birthYear1);
  const era2 = getEra(birthYear2);

  if (era1 === era2) {
    return era1;
  }

  return `${era2}か${era1}`;
}

フォームフィールドのコンポーネントを分離する

これまでの例で使用していた AccountForm コンポーネントでは Controller コンポーネントを利用して直接年齢入力フォームを実装しました。
しかし、 AccountForm の実装が進むにつれてコンポーネントが肥大化して可読性が落ちることは想像に難くないため、コンポーネントを分離しておきましょう。

age-field.tsx
import { type Control, useController } from "react-hook-form";
import { JpEra } from "./jp-era";
import { type AccountFormSchemaIn, ageSchema } from "./schema"

interface AgeFieldProps {
  control: Control<AccountFormSchemaIn>;
}

export function AgeField({ control }: AgeFieldProps) {
  const { field, fieldState } = useController({ control, name: "age" });
  const age = ageSchema.safeParse(field.value).data;
  const value = field.value === "" ? "" : age;

  return (
    <>
      <input {...field} value={value} />
      {age !== undefined && field.value !== "" && <JpEra age={age} />}
    </>
  );
}
account-form.tsx
function AccountForm() {
  // ...中略

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      <AgeField control={form.control} />
    </form>
  );
}

AccountForm の記述がすっきりし、年齢フォームに関する責務を分離できました。
フィールド単位でコンポーネントを切り出す場合は useController を使用することで記述のネストを浅く保ち、可読性を維持できます。

汎用的なフィールドコンポーネント

実装を進めていると年齢フォームに以下の要件が追加されました。

ユーザーの一覧を表示し、ユーザーそれぞれに年齢フォームを表示したい。
フォームの仕様は現行を踏襲する。

このような要件の場合、 useFieldArray を使って複数のフォームを表示できます。

users-form.tsx
import { useForm, useFieldArray } from "react-hook-form";
import z from "zod";
import { zodResolver } from "@hookform/resolvers/zod";
import { ageSchema } from "./schema"

const usersFormSchema = z.object({
  users: z.array(z.object({ age: ageSchema })),
});

type UsersFormSchemaIn = z.input<typeof usersFormSchema>;

type UsersFormSchemaOut = z.output<typeof usersFormSchema>;

export function UsersForm() {
  const form = useForm<UsersFormSchemaIn, undefined, UsersFormSchemaOut>({
    defaultValues: { users: [{ age: "" }] },
    mode: "onChange",
    resolver: zodResolver(usersFormSchema),
  });

  const { fields } = useFieldArray({ control: form.control, name: "users" });

  const onSubmit = (values: UsersFormSchemaOut) => {
    console.log(values);
  };

  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        /* ここに年齢フォームを表示 */
      ))}
    </form>
  );
}

しかし、ここで問題が発生します。
先程実装した AgeField は AccountFormSchemaIn に依存した実装となっています。
つまり name が age の場合にしか使うことができません。
というわけで、AgeField を汎用的なコンポーネントに改造してみます。

age-field.tsx
import {
  useController,
  type Control,
  type FieldValues,
  type FieldPathByValue,
} from "react-hook-form";
import { JpEra } from "./jp-era";
import type { AgeSchemaIn } from "./schema";

interface AgeFieldProps<T extends FieldValues> {
  control: Control<T>;
  name: FieldPathByValue<T, AgeSchemaIn>;
}

export function AgeField<T extends FieldValues>({
  control,
  name,
}: AgeFieldProps<T>) {
  const { field } = useController({ control, name });

  return (
    <>
      <input {...field} />
      {field.value !== undefined && field.value !== "" && (
        <JpEra age={field.value} />
      )}
    </>
  );
}
users-form.tsx
export function UsersForm() {
  // ...中略
  return (
    <form onSubmit={form.handleSubmit(onSubmit)}>
      {fields.map((field, index) => (
        <AgeField
          key={field.id}
          control={form.control}
          name={`users.${index}.age`}
        />
      ))}
    </form>
  );
}

name を使用側から渡すようにしたことにより、より汎用的に呼び出し可能となりました。
FieldPathByValue<T, AgeSchemaIn> を使用することで UsersFormSchemaIn の内 AgeSchemaIn と一致する name の集合を得られます。
これにより UsersFormSchemaIn の変更に型レベルで追従できます。

AgeField コンポーネントの課題

ぱっと見良さそうに見えますが、残念ながらこのプログラムを実行するとランタイムでエラーとなります。

Uncaught TypeError: age.toFixed is not a function
at JpEra (jp-era.tsx:10:18)

エラーを見ると、 JpEra の age に number ではない値が渡されているようです。
これは AgeField を改造したときに ageSchema.safeParse(field.value) を入れ忘れたことによって発生した問題ですが、いったいなぜ型エラーが出なかったのでしょうか?

const { field } = useController({ control, name });
field.value;
//    ^?(property) value: PathValueImpl<T, FieldPathByValue<T, unknown>>

field.value の型は PathValueImpl となっていますが、これは React Hook Form が提供する型ユーティリティ PathValue<T, P> の内部実装です。

PathValue<T, P> は T の内、 P のパスの型を再帰的に解析する型ユーティリティです。
共通コンポーネント側で使用している T には FieldValues を指定しています。
FieldValues は react-hook-form から import した型なので、定義を調べてみましょう。

https://github.com/react-hook-form/react-hook-form/blob/master/src/types/fields.ts#L26

Record<string, any> なので、全てのフィールドを any として扱うようです。
これにより、任意のパス P に対して any を推論するようになってしまい、型安全性が失われていた という状況のようです。
これを解決するには、任意のパス P に対して AgeSchemaIn を要求する型を指定すれば良さそうに見えます。

age-field.tsx
import {
  useController,
  type Control,
  type FieldPathByValue,
} from "react-hook-form";
import { JpEra } from "./jp-era";
import { type AgeSchemaIn, ageSchema } from "./schema";

type AgeFieldValues = Record<string, AgeSchemaIn>;

interface AgeFieldProps<T extends AgeFieldValues> {
  control: Control<T>;
  name: FieldPathByValue<T, AgeSchemaIn>;
}

export function AgeField<T extends AgeFieldValues>({
  control,
  name,
}: AgeFieldProps<T>) {
  const { field } = useController({ control, name });
  const age = ageSchema.safeParse(field.value).data;
  const value = field.value === "" ? "" : age;

  return (
    <>
      <input {...field} value={value} />
      {age !== undefined && field.value !== "" && <JpEra age={age} />}
    </>
  );
}

まとめ

今回は型安全に制御コンポーネントを扱う方法について考えてみました。
これまで React Hook Form と Zod を組み合わせて使う場面で、「なんか型が弱いのだけど実装優先するか......。」とその場しのぎで書いていました。
今回、そのプログラムについて理解を深められて良かったです。

また、後半ではフォームフィールドのコンポーネント共通化手法について触れましたが、コンポーネントを共通化すべきかどうかについては別の議論が必要だと考えています。
共通化の手法について知識を広げつつ、コンポーネントの責務とシステムのドメインへの理解を深めてより良いプロダクト開発に活かしていきます。

Discussion