👏

Reactで数値入力フォームを型安全に実装する方法

に公開

作成日時: 2025-11-12

はじめに

Reactでフォーム開発をしていると、数値入力の扱いに悩むことがあります。この記事では、TanStack FormとZodを使った数値入力フォームの実装方法について紹介します。

問題の背景

Reactの<input />要素では、valueundefinednullを渡すと、フォームコンポーネントがControlled(制御されたコンポーネント)からUncontrolled(非制御コンポーネント)に変わってしまいます。

// NG: undefinedやnullを渡すとUncontrolledになる
<input value={user?.name} /> // user?.nameがundefinedの可能性がある

// OK: 空文字で初期化
<input value={user?.name ?? ''} />

文字列の場合は空文字('')で初期化すれば問題ありませんが、数値の場合はどうすれば良いのでしょうか? 数値型には「空」を表現する値がありません。

この記事では、この問題に対する解決策を紹介します。

解決策: フォームでは全てstringで扱う

解決策はシンプルです。フォーム上では数値も含めて全て文字列で扱い、バリデーション時に数値に変換するという方法です。

基本的な実装

Userのフォームを例に、具体的な実装を見ていきましょう。Zodのtransformを使うことで、フォーム入力(文字列)と出力(数値)で異なる型を扱えます。

user-form.ts
export const formSchema = z.object({
  name: z.string().min(1),
  age: z.string().min(1).transform(Number).pipe(z.number()),
})

export type FormInputSchema = z.input<typeof formSchema>;
export type FormOutputSchema = z.output<typeof formSchema>;


// フォームで扱いやすい形に変換
export function createDefaultFormValue(user: User | undefined): FormInputSchema {
  return {
    name: user?.name ?? '',
    age: String(user?.age ?? ''),
  }
}
UserForm.tsx
function UserForm({ user }: { user: User | undefined }) {
  const form = useForm({
    defaultValues: createDefaultFormValue(user),
    validators: { onChange: formSchema },
    onSubmit: ({ value }) => {
      // value の型は FormOutputSchema (name: string, age: number)
      console.log(value.age); // number型として扱える
    },
  });

  return (
    <form
      onSubmit={(e) => {
        e.preventDefault()
        e.stopPropagation()
        form.handleSubmit()
      }}
    >
      <form.Field name="name">
        {(field) => (
          <input
            value={field.state.value}
            onChange={(e) => field.handleChange(e.target.value)}
          />
        )}
      </form.Field>
      <form.Field name="age">
        {(field) => (
          <input
            value={field.state.value}
            onChange={(e) => field.handleChange(e.target.value)}
          />
        )}
      </form.Field>
    </form>
  )
}

ポイント

この実装のポイントは以下の通りです:

  1. 入力型と出力型の分離: z.input<typeof formSchema>z.output<typeof formSchema>を使い、入力側はstring、出力側はnumberとして扱える
  2. 型安全な変換: transformでバリデーションと型変換を同時に行い、型の不整合を防ぐ
  3. 空文字の扱い: 数値フィールドも文字列として初期化できるため、Controlled Componentの問題を回避できる

任意項目の場合

<input />が任意項目(optional)の場合は、空文字をnullに変換するヘルパー関数を用意すると便利です。

user-form.ts
function toNumber(value: string | null | undefined): number | null {
  if (value == null || value.length === 0) return null;
  return Number(value);
}

export const formSchema = z.object({
  name: z.string().min(1),
  age: z.string().transform(toNumber).pipe(z.number().nullable()),
})

export type FormInputSchema = z.input<typeof formSchema>;
// { name: string, age: string }

export type FormOutputSchema = z.output<typeof formSchema>;
// { name: string, age: number | null }

この場合、ageフィールドが空の場合はnullとして扱われ、出力型ではnumber | nullとなります。必須項目と任意項目で適切に使い分けることで、より堅牢なフォーム実装が可能になります。

まとめ

Reactの<input />で数値を扱う際の問題と、その解決策について紹介しました。

この記事のポイント

  • 問題: Reactの<input />undefinednullを渡すと、ControlledからUncontrolledに変わってしまう
  • 課題: 数値型には「空」を表現する値がないため、初期化が難しい
  • 解決策: フォーム上では文字列で扱い、Zodのtransformを使ってバリデーション時に数値に変換する
  • メリット: 入力型と出力型を分離でき、型安全性を保ちながら柔軟なフォーム実装が可能

この方法の利点

  1. 型安全性: 入力時はstring、出力時はnumberと、明確に型が分かれている
  2. バリデーションの一元化: Zodスキーマで型変換とバリデーションを同時に定義できる
  3. Controlled Componentの維持: 空文字で初期化できるため、常にControlledな状態を保てる
  4. 拡張性: 任意項目への対応も、ヘルパー関数を追加するだけで簡単に実装できる

TanStack FormとZodを使うことで、型安全で保守性の高いフォーム実装が可能になります。同様の課題に直面している方の参考になれば幸いです。

GENDA

Discussion