TypeScriptでreact-hook-formを使ってみた
Reactで独自にフォームを実装しようとすると、CustomHooksに大量のロジックが必要になり、コード量もかなりの量になることがあります。
多くの人がこの面倒な部分に遭遇しているかと思います。
そのため、react-hook-formやFormikなどの有名なライブラリが利用されることがあります。
今回はreact-hook-formを使用してみましたが、ハマったポイントなどをここにまとめておきたいと思います。
まず、パッケージをインストールしましょう。
$ yarn add -D react-hook-form
では、ざっくりとした今回のコードを紹介します。
UI部分はMaterial UIを使用しています。
import React, { ReactElement } from 'react';
import { useForm } from 'react-hook-form';
import { Box, Grid, makeStyles, InputBase } from '@material-ui/core';
export const Form = (): ReactElement => {
const { handleSubmit, errors, register, formState, reset } = useForm<IFormDatas>({
mode: 'onChange',
reValidateMode: 'onBlur',
});
const { isDirty, isValid } = formState;
const [_isChecked, _setChecked] = useState<boolean>(false);
/**
* フォームをサブミットするときのコールバック
*/
const onSubmitHandler = () => {
console.log('onSubmit!')
}
/**
* チェックボックスのON/OFFを行うハンドラー
*/
const onCheckHandler = (): void => {
_setChecked(!_isChecked);
};
<form onSubmit={handleSubmit(onSubmitHandler)}>
// ----------------------------------------
// firstName
// ----------------------------------------
<Box mt="30px">
<FormLabel label="firstName" />
<InputBase
name="firstName"
inputRef={register(errorMessages.firstName)}
className={`${classes.field} ${errors.firstName ? classes.errorField : ''}`}
fullWidth
placeholder="YAMADA"
/>
{errors.firstName && (
<Box>
<FormError errorField={errors.firstName} message={errors.firstName.message} />
</Box>
)}
</Box>
// ----------------------------------------
// lastName
// ----------------------------------------
<Box mt="30px">
<FormLabel label="lastName" />
<InputBase
name="lastName"
inputRef={register(errorMessages.lastName)}
className={`${classes.field} ${errors.lastName ? classes.errorField : ''}`}
fullWidth
placeholder="TARO"
/>
{errors.lastName && (
<Box>
<FormError errorField={errors.lastName} message={errors.lastName.message} />
</Box>
)}
</Box>
// ----------------------------------------
// email
// ----------------------------------------
<Box mt="30px">
<FormHeading label="email" required />
<InputBase
name="email"
inputRef={register(errorMessages.email)}
className={`${classes.field} ${errors.email ? classes.errorField : ''}`}
fullWidth
/>
{errors.email && (
<Box>
<FormError errorField={errors.email} message={errors.email.message} />
</Box>
)}
</Box>
// ----------------------------------------
// inquiry
// ----------------------------------------
<Box mt="30px">
<FormHeading label="inquiry" required />
<InputBase
name="body"
inputRef={register(errorMessages.body)}
className={`${classes.field} ${errors.body ? classes.errorField : ''}`}
rows={6}
rowsMax={6}
fullWidth
multiline
/>
{errors.body && (
<Box>
<FormError errorField={errors.body} message={errors.body.message} />
</Box>
)}
</Box>
// ----------------------------------------
// checkbox
// ----------------------------------------
<Box>
<label htmlFor="agree" className={classes.label}>
<input
name="agree"
ref={register(errorMessages.agree)}
type="checkbox"
className={classes.check}
/>
<span className={classes.labelText}>個人情報のお取り扱いに同意する</span>
</label>
{errors.agree && (
<Box>
<FormError errorField={errors.agree} message={errors.agree.message} />
</Box>
)}
</Box>
// ----------------------------------------
// submit
// ----------------------------------------
<Box mt="48px" display="flex" justifyContent="center" alignItems="center">
<Button
color="primary"
type="submit"
disabled={!(isDirty && isValid)}
/>
送信する
</Button>
</Box>
</form>
今回のフォームの要素は以下の通りです。
まず、Hooksの部分を見ていきましょう。
const { handleSubmit, errors, register, formState, reset } = useForm<IFormDatas>({
mode: 'onChange',
reValidateMode: 'onBlur',
});
const { isDirty, isValid } = formState;
const [_isChecked, _setChecked] = useState<boolean>(false);
まず、行っているのはuseFormを初期化することです。
ここではいくつかのオブジェクトを分割代入しています。
初期化時に、useFormにジェネリクスとオブジェクトを設定して初期化しています。
// エラーオブジェクトの型をジェネリクスで指定するとerrors.firstNameのように型補完がきく
export type IFormDatas = {
firstName: string;
lastName: string;
email: string;
agree: string;
body: string;
};
useForm<IFormDatas>({
mode: 'onChange',
reValidateMode: 'onBlur',
});
modeやreValidateModeは、こちらの説明にもあるように、バリデーションを実行するタイミングを指定できるオプションです。
では、次に実際のテキストフィールドを見ていきましょう。
// ----------------------------------------
// firstName
// -----------------------------------------
<Box mt="30px">
<FormLabel label="firstName" />
<InputBase
name="firstName"
inputRef={register(errorMessages.firstName)}
className={`${classes.field} ${errors.firstName ? classes.errorField : ''}`}
fullWidth
placeholder="YAMADA"
/>
{errors.firstName && (
<Box>
<FormError errorField={errors.firstName} message={errors.firstName.message} />
</Box>
)}
</Box>
ここでは、Material UIのInputBaseコンポーネントを使用しています。
なぜTextFieldコンポーネントではなくInputBaseを使用しているかというと、単純にスタイルをカスタマイズしやすいからです。
そして、ここでのポイントはregisterです。
inputRef={register(errorMessages.firstName)}
先程の説明の通り、registerではバリデーションの条件を指定することができます。
<input name="singleErrorInput" ref={register({ required: "This is required." })} />
<input name="name" ref={register({ required: true })} />
このように、オブジェクトでrequiredやrequiredに文字列を指定するとエラーメッセージを出力することも可能です。
さらに、maxLengthやminLengthなどの標準的なバリデーションを適用したり、正規表現を使用してパターンマッチングを行うこともできます。
ただし、複数のバリデーションを追加していくとコンポーネントが大きくなり、可読性が悪くなる可能性があります。
そのため、バリデーション情報を別のファイルに切り出して使用する方法も取られています。
// validation.ts
export const firstName = {
required: { value: true, message: '姓は必ず入力してください。' },
minLength: { value: 3, message: 'タイトルは3文字以上入力してください。' },
maxLength: { value: 100, message: 'タイトルは100文字以内で入力してください。' },
};
export const lastName = {
required: { value: true, message: '名は必ず入力してください。' },
minLength: { value: 3, message: '名は3文字以上入力してください。' },
maxLength: { value: 100, message: '名は100文字以内で入力してください。' },
};
このように、バリデーション情報を別のファイルに切り出すことで、その情報をregisterに渡すことができます。
inputRef={register(validation.firstName)}
次に、エラーハンドリングを行ってみましょう。
useFormの初期化時にフォーム内のフィールドの型をジェネリクスとして渡しておくと、errors.まで入力すると型補完が効くようになると思います。
registerに適切なバリデーション情報を渡している場合、バリデーションが適用され、errorsオブジェクトでエラーを取得することができます。
エラーが存在する場合は、簡単にエラーメッセージを表示するなどのハンドリングが可能になります。
{errors.firstName && (
<Box>
<FormError errorField={errors.firstName} message={errors.firstName.message} />
</Box>
)}
他の項目もほとんど同じ要領で可能ですが、今回のチェックボックスだけはMaterial UIのチェックボックスではなく、通常のHTMLのチェックボックス要素を使用しています。
そのため、inputRefではなくrefを使用してregisterを渡している点に注意してください。
最後のハンドリングでは、formStateから取得したisDirtyとisValidを使用しています。
isDirtyとisValidがTrueの場合、ボタンをdisabledに設定しています。
この仕様により、ユーザーがフォームのフィールドを一度は触っている状態で、かつエラーがひとつも存在しない場合にボタンがアクティブになるようになっています。
// ----------------------------------------
// submit
// ----------------------------------------
<Box mt="48px" display="flex" justifyContent="center" alignItems="center">
<Button
color="primary"
type="submit"
disabled={!(isDirty && isValid)}
/>
送信する
</Button>
</Box>
最後に、最も気をつけたいポイントとして、各フィールドにはdefaultValueというプロパティを設定することができます。
<InputBase
name="firstName"
inputRef={register(errorMessages.firstName)}
defaultvalue=""
/>
こうすることで、フィールドにデフォルト値を設定することができます。
デフォルト値が絶対に必要な値ではない場合は、削除しても問題ありません。
しかし、defaultvalueが入っていたために謎の挙動にハマったことがあるかもしれません。そのような状況に遭遇した場合、以下の条件が該当します。
この2つの条件が揃った場合、少し厄介な動作が発生します。
例えば、minLengthなどの文字数制限のバリデーション条件が設定されている場合、入力時の最初の1文字目がdefaultValueの空文字に対してバリデーション判定が行われます。
バリデーションとdefaultValueの相互作用により、予期しない動作が発生し、結構ハマりやすいポイントになります。
そのため、defaultValueに注意を払ってください。
以上が、react-hook-formについての説明でした。
それでは。