React Hook Form
フォームの作成・バリデーションが簡単にできる便利なライブラリです。
Uncontrolled Components(非制御コンポーネント)を採用しており、stateの変更に伴う再レンダリングの数を減らし、アプリのパフォーマンスを向上させることができます。
制御コンポーネント
入力値をstateなどで保持するように制御されたもの
非制御コンポーネント
HTMLのinputやtextareaのように、入力値をDOM自身が保持しているもの
Yup
バリデーションのためのライブラリです。
JavaScriptで
スキーマを定義して値の解析・検証・変換などを行うことができます。
React Hook Formだけでもバリデーションを行うことは可能ですが、Yupを使用すればバリデーションに関する記述を一箇所にまとめたり、より複雑で表現力のあるバリデーションを行うことができます。
React Hook Formが標準でサポートしていて導入も簡単なので、今回はこちらを使用したいと思います。
やりたいこと
入力が2画面、確認用を1画面用意。
値を検証して問題がなければ、入力した値をまとめて送信
使い方
React Hook Formの基本的な使用方法
以下は公式から拝借したコードです。
registerを使用してinputなどの要素を登録し、検証ルールをReact Hook Formに適用することができます。
import { useForm } from "react-hook-form";
export default function App() {
const { register, handleSubmit, watch, formState: { errors } } = useForm();
const onSubmit = data => console.log(data);
return (
<form onSubmit={handleSubmit(onSubmit)}>
<input defaultValue="test" {...register("example")} />
<input {...register("exampleRequired", { required: true })} />
{errors.exampleRequired && <span>This field is required</span>}
<input type="submit" />
</form>
);
}
registerを呼び出すことで、下記の参照をまとめて行うことができます
const { onChange, onBlur, name, ref } = register('firstName');
<input
onChange={onChange}
onBlur={onBlur}
name={name}
ref={ref}
/>
// 同上
<input {...register('firstName')} />
Registerを使用したバリデーションは下記のように行うことができます。
<input
{...register("test", {
required: true
})}
/>
<input
{...register("test", {
pattern: /[A-Za-z]{3}/
})}
/>
簡単な使い方がわかったところで、実装していきましょう!
いざ実装
Yupを使ってスキーマを定義する
objectにスキーマを定義していきます。
import { object, string, number } from 'yup';
const Schema = object({
name: string().required('お名前の入力は必須です。'),
age: number().typeError('年齢は数字を入力してください').integer('整数を入力してください'),
email: string().email('メールアドレスの形式で入力してください。').required('メールアドレスの入力は必須です。'),
phone: string().matches(/^[0-9]*$/, { message: '半角数字で入力してください。' }),
favorite: string(),
});
export type SchemaType = InferType<typeof Schema>;
エラー時の文言は、それぞれの引数に設定しています。
スキーマはインターフェースを生成します。InferTypeはそのインターフェイスを抽出し、型生成を行ってくれます。
親ページの作成
今回はNextjsを利用しているため、pages配下に親となるページを作成します。
import { yupResolver } from '@hookform/resolvers/yup';
import { Container, Typography } from '@mui/material';
import type { NextPage } from 'next'
import { useState } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import Form1 from '../components/form1';
import Form2 from '../components/form2';
import Confirm from '../components/confirm';
import { Schema, SchemaType } from '../utils/schema';
const Form: NextPage = () => {
const methods = useForm<SchemaType>({
resolver: yupResolver(Schema),
mode: 'onBlur',
});
const [pageNumber, setPageNumber] = useState(0);
const onNext = () => {
setPageNumber(state => state + 1);
};
const onPrev = () => {
setPageNumber(state => state - 1);
};
return (
<>
<Container maxWidth="md" sx={{ marginTop: 7 }}>
<Typography variant="h3" gutterBottom component="div">
フォーム
</Typography>
<FormProvider {...methods}>
<form>
{pageNumber === 0 && <Form1 onNext={onNext} /> }
{pageNumber === 1 && <Form2 onNext={onNext} onPrev={onPrev} /> }
{pageNumber === 2 && <Confirm onPrev={onPrev} /> }
</form>
</FormProvider>
</Container>
</>
)
}
export default Form;
ページ切り替えをuseStateで行ってますが、Layoutsとか他の方法でも問題ありません。
useFormのresolverを使用することでYup等、外部検証ライブラリを利用できます。
バリデーションはBlurのタイミングで行われるように設定しています。
<FormProvider {...methods}>
// 〜〜〜
</FormProvider>
FormProviderは、 ReactのContext APIに基づいて構築されています。
これを使うことで、propsのバケツリレーを回避することができます。
入力画面の作成
import { useFormContext } from "react-hook-form";
import { Box, Button, TextField } from '@mui/material';
import { Schema, SchemaType } from '../utils/schema';
type Props = {
onNext: () => void
}
const Form1 = ({ onNext }: Props) => {
const methods = useFormContext<SchemaType>();
const {
register,
formState: { errors },
} = methods;
return (
<>
<TextField
id="outlined-required"
label="お名前"
fullWidth
{...register("name")}
margin="normal"
error={Boolean(errors.name)}
helperText={errors.name?.message}
/>
<TextField
id="outlined-required"
label="年齢"
fullWidth
margin="normal"
{...register("age")}
error={Boolean(errors.age)}
helperText={errors.age?.message}
/>
<TextField
id="outlined-required"
label="メールアドレス"
fullWidth
margin="normal"
{...register("email")}
error={Boolean(errors.email)}
helperText={errors.email?.message}
/>
<Box display="flex" justifyContent="center" gap={2} mt={2}>
<Button variant="contained" onClick={onNext}>
次へ
</Button>
</Box>
</>
)
}
export default Form1;
import { Schema, SchemaType } from '../utils/schema';
import { useFormContext } from "react-hook-form";
import { Box, Button, TextField } from '@mui/material';
type Props = {
onNext: () => void
onPrev: () => void
}
const Form2 = ({ onNext, onPrev }: Props) => {
const methods = useFormContext<SchemaType>();
const {
register,
formState: { errors },
} = methods;
return (
<>
<TextField
label="電話番号"
variant="outlined"
fullWidth
{...register("phone")}
margin="normal"
error={Boolean(errors.phone)}
helperText={errors.phone?.message}
/>
<TextField
label="好きな食べ物"
variant="outlined"
fullWidth
{...register("favorite")}
error={Boolean(errors.favorite)}
helperText={errors.favorite?.message}
margin="normal"
/>
<Box display="flex" justifyContent="center" gap={2} mt={2}>
<Button variant="contained" onClick={onPrev}>
戻る
</Button>
<Button variant="contained" onClick={onNext}>
確認
</Button>
</Box>
</>
)
}
export default Form2;
useFormContextで、ネストされたコンポーネントからReact Hook Formへ接続します。
先程呼び出したuseFormのメソッドを子要素のコンポーネントから簡単に呼び出すことができます。
propsを受け取る必要がないのでラクですね!
MaterialUIとの連携
入力フォームのスタイリングにはMaterialUIを使用しています。
ちなみにMaterialUIは「制御コンポーネント」です。
React Hook Formには、このような外部制御されているコンポーネントと簡単に統合を行うためのラッパーコンポーネントも提供されています。
Controller
RadioボタンやCheckboxを利用するときにはControllerを使う必要があるのですが、
TextFieldであれば普通にregisterを使用しても問題ありません。
Radioボタンなどを使いたいときには、Controllerを利用して下記のように対応できます。
<Controller
name="fruit"
control={control}
render={({ field }) => {
return(
<RadioGroup
row
{ ...field }
>
<FormControlLabel value="apple" control={<Radio />} label="Apple" />
<FormControlLabel value="orange" control={<Radio />} label="Orange" />
</RadioGroup>
);
}}
/>
Controllerを使うのであれば、TextFieldもControllerで制御できるように記述方法を統一したほうがきれいかなーと思います。
しかしそうなると、いよいよ非制御コンポーネントの恩恵を受けられなくなってしまうのでは..!?って気もしますが、
React Hook Formは制御されたコンポーネントの再レンダリングも最適化をおこなってくれるようです。
非制御コンポーネントと混合された制御
確認画面の実装
import { useFormContext, SubmitHandler, SubmitErrorHandler } from "react-hook-form";
const Confirm = ({ onPrev }: Props) => {
const methods = useFormContext<SchemaType>();
const {
handleSubmit,
getValues,
formState: { errors },
} = methods;
const [sendData, setSendData] = useState<SchemaType | null>(null);
const onSubmit: SubmitHandler<SchemaType> = data => {
console.log('Success', data)
setSendData(data)
};
const onErr: SubmitErrorHandler<SchemaType> = err => console.log('Error', err);
useEffect(() => {
handleSubmit(onSubmit, onErr)();
}, [])
return (
<>
<TableContainer>
<Table>
<TableBody>
<TableRow>
<TableCell component="th" scope="row">
お名前
</TableCell>
<TableCell>
{getValues('name')}
<span style={{ color: 'red' }}>{errors.name?.message}</span>
</TableCell>
</TableRow>
<TableRow>
<TableCell component="th" scope="row">
年齢
</TableCell>
<TableCell>
{getValues('age')}
<span style={{ color: 'red' }}>{errors.age?.message}</span>
</TableCell>
</TableRow>
~~繰り返す~~
</TableBody>
</Table>
</TableContainer>
<Box display="flex" justifyContent="center" gap={2} mt={2}>
<Button variant="contained" onClick={onPrev}>
戻る
</Button>
<Button
variant="contained"
onClick={() => alert(JSON.stringify(sendData))}
disabled={!Boolean(sendData)}
>
送信
</Button>
</Box>
</>
)
}
export default Confirm;
値の表示は、getvaluesで行っています。
{getValues('age')}
エラー時には下記のerrorsオブジェクトにエラーの内容が入るので、それを表示しています。
formState: { errors },
初回レンダリング時にhandleSubmit(onSubmit, onErr)()を実行し、すべての値を検証しています。
バリデーションに問題がなければonSubmitが実行され、エラーがあればonErrが実行されます。
onSubmitでdataをuseStateに格納します。
const onSubmit: SubmitHandler<SchemaType> = data => {
console.log('Success', data)
setSendData(data)
};
sendDataに値が入れば、送信ボタンが活性になります。
onClick内で送信すれば完了です。
まとめ
基本的な機能のみの使用ですが、簡単にフォームが作成できました。
どちらのライブラリもシンプルで使いやすく拡張性もあるので、入力が多くてもっと規模の大きいフォームだと開発スピードの面、パフォーマンス面でもかなり恩恵を受けられそうです。
利用者も多くWeb上にも情報がたくさんある&公式ドキュメントも見やすいので導入のハードルも低いところも良いと思います。