React Hook Form + DatepickerのonBlurバリデーションでハマった件
はじめに
お疲れさまです!昨夏に異業種からWeb業界に転職し、フロントエンドエンジニアになりたてのMeloです。
むずかしーーーー
日々こんな感想を抱きながら開発をしています。特にフォーム周りの実装は「思ったより泥沼だな…」と何度も感じる今日この頃。
この度、弊社では社員の技術発信としてZenn Publicationを立ち上げ、私も記事を書くことになりました。第一弾として、最近ハマったReact DatepickerとReact Hook Formの相性問題について共有します。
注意: この記事では、問題の本質を理解するために必要な部分のみを抽出してコードを掲載しています。実際の実装では、スタイリングやエラーハンドリングなど、より多くのコードが必要になります。
使用している技術スタック
今回紹介するプロジェクトで使用している技術スタックをざっくりまとめておきます。
フレームワーク・言語
- React 18: UIライブラリの大定番
- TypeScript: 型安全に開発するための必須アイテム
- Chakra UI: デザインシステムを簡単に実装できるUIコンポーネント集
フォーム関連
- React Hook Form: フォーム管理とバリデーションの救世主
- Zod: スキーマ定義とバリデーションが型安全にできる優れもの
- React Datepicker: カレンダーから日付を選択するUIコンポーネント
- @hookform/resolvers/zod: React Hook FormとZodを繋ぐアダプター
その他
- date-fns: 日付操作ライブラリ。Datepickerでも使用
実装したかったフォームの概要
シンプルな情報入力フォームです。以下の項目を入力できるようにしました:
- 名前(テキスト入力)
- メールアドレス(テキスト入力)
- 生年月日(日付選択)
- 電話番号(テキスト入力)
React Hook FormとZodを使って各フィールドにバリデーションを設定し、フォーカスが外れたとき(onBlur)にバリデーションが実行されるようにしています。
問題点:Datepickerだけバリデーションが動かない!
フォームの実装を進めていたところ、うまくいかない箇所に遭遇しました。通常のテキスト入力(名前、メールアドレス、電話番号)では問題なくonBlurでバリデーションが動作するのに、Datepickerを使った生年月日の入力要素だけ、フォーカスを外してもバリデーションが発火しないのです。
当初のDateInput実装
まず、問題のあるDateInputコンポーネントの実装を見てみましょう:
// Datepickerをラップするカスタムコンポーネント
export const DateInput = ({ value, onChange, label, error, ...props }: DateInputProps) => {
// Datepickerの内部状態を管理
const [startDate, setStartDate] = useState<Date | null>(value ? new Date(value) : null);
// 日付が選択されたときのハンドラー
// Datepickerから受け取ったDate型の値を文字列に変換して親コンポーネントに通知
const handleDateChange = (date: Date | null) => {
setStartDate(date);
if (onChange) {
if (date) {
const dateString = format(date, 'yyyy-MM-dd'); // 日付をフォーマットする処理
onChange(dateString);
} else {
onChange('');
}
}
};
return (
<Box>
<Text>{label}</Text>
<Datepicker
selected={startDate}
onChange={handleDateChange}
{...props}
/>
{error && <Text color="red.500">{error}</Text>}
</Box>
);
};
この状態で、React Hook Formのバリデーションは以下のように設定していました:
// React Hook Formの初期化
// onBlurモードでバリデーションを実行するように設定
const { register, handleSubmit, formState: { errors }, setValue } = useForm<FormData>({
resolver: zodResolver(schema),
mode: 'onBlur',
});
そして、FormInputsコンポーネント内での使用部分:
// DateInputコンポーネントをReact Hook Formと連携
const { onBlur: registerOnBlur, ...registerProps } = register('birthdate');
<DateInput
label="Birthdate"
{...registerProps}
onBlur={registerOnBlur}
onChange={(value: string) => setValue('birthdate', value)}
trigger={() => trigger('birthdate')}
error={errors.birthdate?.message}
/>
このように、register
から受け取ったonBlur
ハンドラーをDateInput
コンポーネントに渡すことで、React Hook Formのバリデーション機能を正しく利用することができます。
問題の原因
調査の結果、以下のような状況が確認できました:
-
React Hook Formの
register
関数は、通常のDOM要素に対して以下のようなイベントハンドラーを設定:
// React Hook Formのregister関数が返すオブジェクト
{
onChange: ChangeHandler;
onBlur: ChangeHandler;
name: string;
ref: RefCallback;
}
これらのハンドラーは、<input {...register("fieldName")} />
のように、標準
のHTML要素に直接適用することを想定しています。
-
一方、React DatePickerは独自のコンポーネントを使用しており、以下のようにイベントハンドラーを設定しています:
return cloneElement(customInput, { [customInputRef]: (input: HTMLElement | null) => { this.input = input; }, value: inputValue, onBlur: this.handleBlur, onChange: this.handleChange, // ... その他のプロパティ });
- DatePickerは
cloneElement
を使用して独自のイベントハンドリングを実装しています - このため、React Hook Formの
register
から提供されるイベントハンドラーが正しくDatePickerのイベントをキャッチできない状況が発生します
- DatePickerは
解決策
いくつかの方法を試した結果、最も効果的だったのは以下のアプローチです:
React Hook FormのControllerを使用する
React Hook Formは、カスタムコンポーネントやサードパーティのコンポーネントを扱う際にController
コンポーネントの使用を推奨しています。Controller
を使用することで、React Hook Formの状態管理とカスタムコンポーネントを適切に合わせることができます。
以下のように実装を変更します:
// FormInputsコンポーネント内での実装
<Controller
name="birthdate"
control={control}
render={({ field }) => (
<DateInput
label="Birthdate"
value={field.value}
onChange={field.onChange}
onBlur={field.onBlur}
error={errors.birthdate?.message}
/>
)}
/>
そして、DateInput
コンポーネントは以下のようにシンプルになります:
type DateInputProps = {
value?: string;
onChange?: (value: string) => void;
onBlur?: (event: React.FocusEvent<HTMLInputElement>) => void;
label: string;
error?: string;
};
export const DateInput = ({ value, onChange, onBlur, label, error, ...props }: DateInputProps) => {
const [startDate, setStartDate] = useState<Date | null>(value ? new Date(value) : null);
const handleDateChange = (date: Date | null) => {
setStartDate(date);
if (onChange) {
if (date) {
const dateString = format(date, 'yyyy-MM-dd');
onChange(dateString);
} else {
onChange('');
}
}
};
return (
<Box>
<Text>{label}</Text>
<Datepicker
selected={startDate}
onChange={handleDateChange}
onBlur={onBlur}
{...props}
/>
{error && <Text color="red.500">{error}</Text>}
</Box>
);
};
Controller
を使用したこの実装には以下のような利点があります:
-
React Hook Formの標準的なパターンに従っている
- カスタムコンポーネントとの統合に推奨される公式の実装パターン
-
フォームの状態管理がより確実
- Controllerが内部でフォームの状態を自動的に同期
- 手動での状態管理によるバグのリスクを軽減
-
バリデーションの制御が自動的に行われる
- onBlurイベントとDatepickerの独自な相性問題を解決
- Zodとの連携が容易で、型安全なバリデーションが実現可能
-
コードがよりシンプルで保守しやすい
- イベントハンドラーの個別実装が不要で、コードが簡潔
- UIコンポーネントの差し替えにも柔軟に対応可能
これにより、通常の入力フィールドと同様のバリデーションをすることができるようになりました。
考察:なぜこの問題が発生したのか
この問題の本質は、React Hook FormとReact Datepickerの実装アプローチの違いにあると考えます:
-
React Hook Formは通常のHTML要素を想定しており、DOM要素の標準イベント(onBlur, onChange)を使ってバリデーションを制御します。
-
React Datepickerはカスタムコンポーネントであり、独自のイベントハンドリングを行っています。特に日付選択ポップアップは、通常のfocus/blurとは少し異なる動作をします。
この問題を通して、異なるライブラリを組み合わせる際には、それぞれのイベントモデルやコンポーネントの働き方を理解することが重要だと感じました。
まとめ
今回は、React Hook FormとReact Datepickerを組み合わせた際に起こるonBlur時バリデーションの問題と、その解決策について紹介しました。
React Hook FormのControllerコンポーネントを使用することで、Datepickerを用いたカスタムコンポーネントとの統合を適切に行うことができました。
フロントエンド開発、特にフォーム周りは「見た目よりずっと複雑」なことが多いです。しかし、一つ一つ問題を解決していくことで、知識が積み重なっていくのを実感しています。
React Hook FormとZodの組み合わせは非常に強力で、型安全なフォーム開発ができるようになります。また、React DatePickerは直感的な日付選択UIを提供してくれる優れたライブラリです。今回のような実装の違いによる課題はありますが、Controllerを使用することで適切に統合でき、使いやすいフォームを実現できます。ぜひ皆さんも試してみてください!
弊社では一緒に働く仲間を募集しています!
株式会社コードユニットは 「エンジニアが自由に挑戦し、成長できる環境を創る」 をビジョンに掲げるIT企業です。
モダンな技術スタックを使った開発や、このような技術的課題に日々チャレンジできる環境で、楽しく開発をしています。
私のようなプログラミング初心者も、先輩方のサポートのおかげで日々成長できる環境があります。そんな弊社では以下のような方を募集しています:
- 新しい技術に挑戦したい方
- 現状に満足せず、常にスキルアップを目指せる方
- 知らない情報にアンテナを張っている方
- ビジョンに共感し、会社と共に成長してくれる方
興味を持っていただけた方は、ホームページからご連絡ください。カジュアル面談も実施していますので、お気軽にお問い合わせください!
Discussion