本記事を執筆するにあたって、
- マナリンク 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