はじめに TextField RadioGroup SelectForm CheckboxGroup DatePicker コンポーネント使用側実装例 おわりに 本記事を執筆するにあたって、 マナリンク Tech Blog運営 さんの React Hook Form(v7)を使ったコンポーネント設計案 piyoko さんの MUI v5 + React Hook Form v7 で、よく使うコンポーネント達を連携してみる という記事を参考にさせていただきました。いつも非常にわかりやすい記事をありがとうございます。 はじめに こんにちは、 ラク スフロントエンド開発課の斉藤です。 React Hook Form v7 + MUI v5 + zod v3を使ったよく使う コンポーネント の実装例を調査しており、 こちらの記事 を参考に実装を進めてみました。しかし RadioGroup や DatePicker を atom 化しようとすると何点かハマりポイントがあったので、どなたかの参考になればと思い本記事を執筆するに至りました。 実装例を紹介する前に各 コンポーネント を実装するにあたって考慮したことをまとめておきます。React Hook Form(以下RHF)を用いたうえで扱いやすい コンポーネント はどういったものかを考えたとき、以下の条件を満たすと良いのではないかと考えました。 コンポーネント とバリデーションロジックが切り離されている マナリンクさんの記事のように コンポーネント がview層とロジック層に分かれている コンポーネント を使用する側はnameとRHFのcontrolプロパティを渡すだけで値を管理できる CheckboxGroupはチェックされたCheckboxの値をstring型の配列で返すようにする RadioGroup、SelectFormは選択された値をstring型で返すようにする DatePickerは値をDate型で返す これらを満たすように各 コンポーネント を実装してみたので、紹介していきます。 今回紹介する環境はcreate viteで作っています。 yarn create vite yarn add react-hook-form @hookform/resolvers @mui/material @emotion/react @emotion/styled zod TextField TextField に関しては 参考記事 とほぼ同じ実装です。 import { FormHelperText , TextField as MuiTextField , TextFieldProps as MuiTextFieldProps , } from "@mui/material" ; export type TextFieldProps = MuiTextFieldProps & { inputRef?: MuiTextFieldProps [ "ref" ] ; errorMessage?: string ; } ; export const TextField: React.FC < TextFieldProps > = ( { inputRef , errorMessage , ...rest } ) => { return ( <> < MuiTextField ref = { inputRef } error = { !! errorMessage } { ...rest } / > { !! errorMessage && < FormHelperText error > { errorMessage } < /FormHelperText > } < / > ); } ; import { DeepMap , FieldError , FieldValues , useController , UseControllerProps , } from "react-hook-form" ; import { TextField , TextFieldProps } from "./TextField" ; export type RhfTextFieldProps < T extends FieldValues > = TextFieldProps & UseControllerProps < T >; export const RhfTextField = < T extends FieldValues >( props: RhfTextFieldProps < T > ) => { const { name , control } = props ; const { field: { ref , ...rest } , fieldState: { error } , } = useController < T >( { name , control } ); return ( < TextField inputRef = { ref } { ...rest } { ...props } errorMessage = { ( error && error.message ) || props.errorMessage } / > ); } ; RadioGroup RadioGroup にはRadioPropsListとして value とlabelをプロパティに持つ配列を渡せるようにします。labelが画面に表示される ラジオボタン 横の文言で、 value が実際にRHFが受け取る値になります。例えば ラジオボタン で「りんご」を選択したとき、バックエンド側には「 apple 」と英名で情報を送信したい場合が良くあるのでこのように分けています。 import { FormControl , FormControlLabel , FormHelperText , Radio , RadioGroup as MuiRadioGroup , } from "@mui/material" ; import type { RadioGroupProps as MuiRadioGroupProps } from "@mui/material" ; type RadioProps = { value: string ; label: string ; } ; export type RadioGroupProps = MuiRadioGroupProps & { inputRef?: MuiRadioGroupProps [ "ref" ] ; errorMessage?: string ; radioPropsList: RadioProps [] ; } ; export const RadioGroup: React.FC < RadioGroupProps > = ( { inputRef , radioPropsList , errorMessage , ...rest } ) => { return ( < div > < FormControl error = { !! errorMessage } > < MuiRadioGroup ref = { inputRef } { ...rest } > { radioPropsList.map (( el ) => ( < FormControlLabel key = { el.value } value = { el.value } label = { el.label } control = { < Radio / > } / > )) } < /MuiRadioGroup > < /FormControl > { !! errorMessage && < FormHelperText error > { errorMessage } < /FormHelperText > } < /div > ); } ; import { useController } from "react-hook-form" ; import type { FieldValues , UseControllerProps , DeepMap , FieldError , } from "react-hook-form" ; import { RadioGroup , RadioGroupProps } from "./RadioGroup" ; export type RhfRadioGroupProps < T extends FieldValues > = RadioGroupProps & UseControllerProps < T >; export const RhfRadioGroup = < T extends FieldValues >( props: RhfRadioGroupProps < T > ) : JSX. Element => { const { name , control , ...rest } = props ; const { field: { ref , ...restControllerProps } , } = useController < T >( { name , control } ); return < RadioGroup inputRef = { ref } { ...restControllerProps } { ...rest } / >; } ; SelectForm SelectForm は機能としてはRadioGroupに近いのでほぼ同じ実装になっています。ただselectedValueをプロパティとして渡せるようにしないと、現在選択されている値をフォーム上に表示することができないので渡しています。RHFを使うと「現在選択されている値」の情報はuseControllerから持ってこれるので、そのままview層に渡します。 またMuiの Select コンポーネント をスタイリングせずにそのまま使うと、以下のgifのようにwidthが極端に小さいコンポートとなってしまいました。実際に使うときにはお好みのスタイリング手法でwidthを設定したほうが良いでしょう。ただMuiの コンポーネント にスタイルを当てたい場合は公式に用意されている styled() を使うのがオススメです。 import { FormControl , FormHelperText , InputLabel , MenuItem , Select , } from "@mui/material" ; import type { SelectProps as MuiSelectProps } from "@mui/material" ; type SelectProps = { label: string ; value: string ; } ; export type SelectFormProps = MuiSelectProps & { inputRef?: MuiSelectProps [ "ref" ] ; errorMessage?: string ; selectPropsList: SelectProps [] ; selectedValue: string ; } ; export const SelectForm: React.FC < SelectFormProps > = ( { inputRef , errorMessage , selectPropsList , selectedValue , label , ...rest } ) => { return ( < div > < FormControl > < InputLabel > { label } < /InputLabel > < Select ref = { inputRef } value = { selectedValue } label = { label } { ...rest } > { selectPropsList.map (( props ) => ( < MenuItem key = { props.value } value = { props.value } > { props.label } < /MenuItem > )) } < /Select > < /FormControl > { !! errorMessage && < FormHelperText error > { errorMessage } < /FormHelperText > } < /div > ); } ; import { useController } from "react-hook-form" ; import type { FieldValues , UseControllerProps , DeepMap , FieldError , } from "react-hook-form" ; import { SelectForm , SelectFormProps } from "./SelectForm" ; export type RhfSelectFormProps < T extends FieldValues > = Omit < SelectFormProps , "selectedValue" > & UseControllerProps < T >; export const RhfSelectForm = < T extends FieldValues >( props: RhfSelectFormProps < T > ) : JSX. Element => { const { name , control } = props ; const { field: { ref , onChange , value: selectedValue , ...rest } , fieldState: { error } , } = useController < T >( { name , control } ); return ( < SelectForm inputRef = { ref } onChange = { ( e ) => onChange ( e ) } { ...rest } { ...props } selectedValue = { selectedValue } errorMessage = { ( error && error.message ) || props.errorMessage } / > ); } ; CheckboxGroup CheckboxGroup にはチェックした値が配列として返ってくるようにしています。例えば「りんご」「ばなな」をチェックすると ["apple", "banana"] が返ってきます。 ロジックとしては RhfCheckboxGroup の handleChange とRHFの onChange 関数を使って実現しています。 handleChange でチェックした値の配列を作り、 onChange 関数の引数に渡すことでRHFに作成した配列の情報を渡すことができます。 import React from "react" ; import { Checkbox , FormControlLabel , FormGroup , FormHelperText , } from "@mui/material" ; import type { FormGroupProps } from "@mui/material" ; type CheckboxProps = { value: string ; label: string ; } ; export type CheckboxGroupProps = FormGroupProps & { inputRef?: FormGroupProps [ "ref" ] ; errorMessage?: string ; checkBoxPropsList: CheckboxProps [] ; checkedValues: string [] ; } ; export const CheckboxGroup: React.FC < CheckboxGroupProps > = ( { inputRef , checkBoxPropsList , checkedValues , errorMessage , ...rest } ) => { return ( < div > < FormGroup ref = { inputRef } { ...rest } > { checkBoxPropsList.map (( props ) => ( < FormControlLabel key = { props.value } control = { < Checkbox value = { props.value } checked = { checkedValues.includes ( props.value ) } / > } label = { props.label } / > )) } < /FormGroup > { !! errorMessage && < FormHelperText error > { errorMessage } < /FormHelperText > } < /div > ); } ; import React from "react" ; import { DeepMap , FieldError , useController } from "react-hook-form" ; import type { FieldValues , UseControllerProps } from "react-hook-form" ; import { CheckboxGroup , CheckboxGroupProps } from "./CheckboxGroup" ; export type RhfCheckboxGroupProps < T extends FieldValues > = Omit < CheckboxGroupProps , "checkedValues" > & UseControllerProps < T >; export const RhfCheckboxGroup = < T extends FieldValues >( props: RhfCheckboxGroupProps < T > ) : JSX. Element => { const { name , control } = props ; const { field: { ref , onChange , value: checkedValues , ...rest } , fieldState: { error } , } = useController < T >( { name , control } ); const handleChange = ( e: React.ChangeEvent < HTMLInputElement >) => { let newCheckedValueList: string [] = [] ; if ( e.target.checked ) { // チェックボックスがチェックされた時、チェックされた値を重複値の無い配列に追加 newCheckedValueList = [ ... new Set ( [ ...checkedValues , e.target.value ] ) ] ; } else { // チェックボックスが外された時は、チェックが外された値を配列から削除 newCheckedValueList = [ ...checkedValues ] .filter ( ( value ) => value !== e.target.value ); } return newCheckedValueList ; } ; return ( < CheckboxGroup inputRef = { ref } onChange = { ( e: React.ChangeEvent < HTMLInputElement >) => onChange ( handleChange ( e )) } { ...rest } checkBoxPropsList = { props.checkBoxPropsList } checkedValues = {[ ...checkedValues ]} errorMessage = { ( error && error.message ) || props.errorMessage } / > ); } ; DatePicker DatePicker はMuiの構成上view層とロジック層を分けることができませんでした。renderInputでRhfTextFieldを直接渡すことでRHFに対応させるようにしています。またMuiのデフォルトだと DatePicker のplaceholderに y/mm/dd と表示されてしまうので、inputPropsからplaceholderを設定するようにしています。また defaultValue={undefined} を指定しないと型エラーが出てしまうので設定しています。 Muiの DatePicker はカレンダーアイコンから日付を選択することもできるし、TextFieldに直接日付を入力することもできます。直接日付を入力した際は文字列をDate型としてparseしたいのでdate-fnsのparse関数を用いています。また数値以外の文字列を入力できるとinvalid dateとなってしまうので、onChange内で 正規表現 を用いて入力できないようにしています。 またMuiの DatePicker はバックスペースなどで入力した日付を全て消すと値としてはnullが入るのでzod側ではnullを許容するようにしています。 import { DatePicker } from "@mui/x-date-pickers" ; import { parse } from "date-fns" ; import { useController } from "react-hook-form" ; import type { FieldValues , UseControllerProps } from "react-hook-form" ; import { RhfTextField } from "./RhfTextField" ; /** 日付フォーマットyyyy/MM/ddを文字列とみなした時の長さは10 */ const DATE_FORMAT_LENGTH = 10 ; export type RhfDatePickerProps < T extends FieldValues > = UseControllerProps < T >; export const RhfDatePicker = < T extends FieldValues >( props: RhfDatePickerProps < T > ) => { const { name , control } = props ; const { field: { onChange , value } , } = useController < T >( { name , control } ); const onSelectDate = ( e: Date | null ) => { onChange ( e ); } ; const onChangeText = ( value: string ) => { // MUIのDatePickerはデフォルトで10文字より多く入力できてしまうため、10文字を超えた分は省略する // ex) yyyy/MM/dd{任意の文字}のように入力できてしまう if ( value.length > DATE_FORMAT_LENGTH ) { onChange ( parse ( value.slice ( 0 , DATE_FORMAT_LENGTH ), "yyyy/MM/dd" , new Date ()) ); return; } onChange ( parse ( value , "yyyy/MM/dd" , new Date ())); } ; return ( < DatePicker value = { value || null } onChange = { ( e: Date | null ) => onSelectDate ( e ) } renderInput = { ( params ) => ( < RhfTextField { ...params } inputProps = {{ ...params.inputProps , placeholder: "yyyy/MM/dd" , }} error = { !! errors [ name ]} onChange = { ( e ) => { // 数値以外を弾く if ( !/^\d*$/ .test ( e.target.value )) return; onChangeText ( e.target.value ); }} defaultValue = { undefined } name = { name } control = { control } / > ) } / > ); } ; コンポーネント 使用側実装例 参考までにこれまでに紹介した コンポーネント の使用側実装例を掲載しておきます。 import "./App.css" ; import { styled , Button } from "@mui/material" ; import { z } from "zod" ; import { useForm , SubmitHandler } from "react-hook-form" ; import { zodResolver } from "@hookform/resolvers/zod" ; import { RhfTextField } from "./components/RhfTextField" ; import { RhfRadioGroup } from "./components/RhfRadioGroup" ; import { RhfSelectForm } from "./components/RhfSelectForm" ; import { RhfCheckboxGroup } from "./components/RhfCheckboxGroup" ; import { RhfDatePicker } from "./components/RhfDatePicker" ; const Form = styled ( "form" )( { display: "flex" , flexDirection: "column" , gap: "16px" , alignItems: "center" , width: "100%" , padding: "16px" , } ); const Flex = styled ( "div" )( { display: "flex" , gap: "16px" , } ); const schema = z. object ( { text: z. string () .min ( 1 , { message: "Required" } ), radio: z. string () .min ( 1 , { message: "Required" } ), select: z. string () .min ( 1 , { message: "Required" } ), checkbox: z. string () .array () .min ( 1 , { message: "Required" } ), date: z .date () .nullable () .refine (( date ) => date !== null , "Required" ), } ); type Inputs = z.infer <typeof schema >; const defaultValues: Inputs = { text: "" , radio: "" , select: "" , checkbox: [] , date: null , } ; const props = [ { label: "りんご" , value: "apple" , } , { label: "みかん" , value: "orange" , } , { label: "ばなな" , value: "banana" , } , ] ; function App () { const { control , handleSubmit , reset } = useForm < Inputs >( { defaultValues: defaultValues , resolver: zodResolver ( schema ), } ); const onSubmit : SubmitHandler < Inputs > = ( data ) => console .log ( data ); return ( < Form onSubmit = { handleSubmit ( onSubmit ) } > < RhfTextField label = "Text" name = "text" control = { control } / > < RhfRadioGroup name = "radio" control = { control } radioPropsList = { props } / > < RhfSelectForm label = "Select" name = "select" control = { control } selectPropsList = { props } / > < RhfCheckboxGroup name = "checkbox" control = { control } checkBoxPropsList = { props } / > < RhfDatePicker name = "date" control = { control } / > < Flex > < Button type= "submit" > 送信 < /Button > < Button onClick = { () => reset () } > リセット < /Button > < /Flex > < /Form > ); } export default App ; おわりに よく使う コンポーネント をRHF化して子 コンポーネント として作成することができました。これらの コンポーネント を組み合わせ、zodと連携することで様々なバリデーション機能を持ったフォームを作成することができると思います。 DatePicker に関しては若干ゴリ押しの実装になってしまった感が否めませんが自分の実力ではこれが限界でした...。 未だにRHFの底が見えていないのでどんどん使い倒してマスターできるようになっていきたいです。 エンジニア 中途採用 サイト ラク スでは、エンジニア・デザイナーの 中途採用 を積極的に行っております! ご興味ありましたら是非ご確認をお願いします。 https://career-recruit.rakus.co.jp/career_engineer/ カジュアル面談お申込みフォーム どの職種に応募すれば良いかわからないという方は、カジュアル面談も随時行っております。 以下フォームよりお申込みください。 rakus.hubspotpagebuilder.com ラク スDevelopers登録フォーム https://career-recruit.rakus.co.jp/career_engineer/form_rakusdev/ イベント情報 会社の雰囲気を知りたい方は、毎週開催しているイベントにご参加ください! ◆TECH PLAY techplay.jp ◆connpass rakus.connpass.com