👏
Reactで数値入力フォームを型安全に実装する方法
作成日時: 2025-11-12
はじめに
Reactでフォーム開発をしていると、数値入力の扱いに悩むことがあります。この記事では、TanStack FormとZodを使った数値入力フォームの実装方法について紹介します。
問題の背景
Reactの<input />要素では、valueにundefinedやnullを渡すと、フォームコンポーネントが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>
)
}
ポイント
この実装のポイントは以下の通りです:
-
入力型と出力型の分離:
z.input<typeof formSchema>とz.output<typeof formSchema>を使い、入力側はstring、出力側はnumberとして扱える -
型安全な変換:
transformでバリデーションと型変換を同時に行い、型の不整合を防ぐ - 空文字の扱い: 数値フィールドも文字列として初期化できるため、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 />にundefinedやnullを渡すと、ControlledからUncontrolledに変わってしまう - 課題: 数値型には「空」を表現する値がないため、初期化が難しい
-
解決策: フォーム上では文字列で扱い、Zodの
transformを使ってバリデーション時に数値に変換する - メリット: 入力型と出力型を分離でき、型安全性を保ちながら柔軟なフォーム実装が可能
この方法の利点
-
型安全性: 入力時は
string、出力時はnumberと、明確に型が分かれている - バリデーションの一元化: Zodスキーマで型変換とバリデーションを同時に定義できる
- Controlled Componentの維持: 空文字で初期化できるため、常にControlledな状態を保てる
- 拡張性: 任意項目への対応も、ヘルパー関数を追加するだけで簡単に実装できる
TanStack FormとZodを使うことで、型安全で保守性の高いフォーム実装が可能になります。同様の課題に直面している方の参考になれば幸いです。
Discussion