MUI v5 + React Hook Form v7 で、よく使うコンポーネント達を連携してみる


先日、Vite + React + TypeScript + Emotion + MUI v5 に、入力フォームライブラリ React Hook Form を導入しました。
React+MUI v5 の 入力フォーム用のライブラリは React Hook Form の 一択

今回は MUI v5 の以下のよく使うであろうコンポーネントと React Hook Form の連携について調べてみました。

  • TextField
  • Select
  • RadioGroup
  • DatePicker
  • 複数のCheckBox

通常 validationライブラリ を使用すると思いますが、今回はvalidationライブラリを導入しない状態で React Hook Form の validate のみで実装します。
validationライブラリの選定の前に、まずは MUI v5 と React Hook Form だけの連携方法を確認するのが目的です。

連携の方法

Reactのコンポーネントには 「Controlled component(制御されたコンポーネント)」と「Uncontrolled component(非制御コンポーネント)」があります。

■ controlled component(制御されたコンポーネント)

controlled component(制御されたコンポーネント)は、入力値などをstateで管理するようにように制御されたコンポーネントです。
Controlled componentのサンプルです。
React.useState で input 要素の入力値を管理します。

import React from 'react'

export function ControlledComponent() {
+  const [inputValue, setInputValue] = React.useState<string>('')

  const handleInputChange = (event: React.ChangeEvent<HTMLInputElement>) => {
+    setInputValue(event.target.value)
  }

  const handleSubmit = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault()
+    console.log(inputValue)
  }

  return (
    <div>
+      <input type="text" onChange={handleInputChange} value={inputValue} />
      <button type="submit" onClick={handleSubmit}>Submit</button>
    </div>
  )
}
■ Uncontrolled component(非制御コンポーネント)

Uncontrolled component(非制御コンポーネント)は、入力値をDOM で管理します。
Uncontrolled componentのサンプルです。
React.createRef で作成した Ref を、input要素の ref 属性に指定します。
input 要素の値は、Ref の current 属性で取得できるようになります。

import React from 'react'

export function UncontrolledComponent() {
+  const inputRef = React.createRef<HTMLInputElement>()

  const handleSubmit = (event: React.MouseEvent<HTMLButtonElement>) => {
    event.preventDefault()
+    console.log(inputRef.current?.value)
  }

  return (
    <div>
+      <input ref={inputRef} type="text" />
      <button type="submit" onClick={handleSubmit}>Submit</button>
    </div>
  )
}

■ MUI コンポーネントとの連携
MUIのコンポーネントは「Controlled component(制御されたコンポーネント)」になります。
React Hook Form では、制御されたコンポーネントを簡単に扱うために Controllerコンポーネントが用意されています。
MUIのコンポーネントを React Hook Form で扱うには、このControllerコンポーネントを使用します。

TextField との連携

まずはMUI の TextField からです。
React Hook Form で TextField に初期値の設定、値の検証、Submit時に値の取得を行ってみます。
まずは実行結果です。
名前が未入力と4文字以下の場合は入力エラーとしています。

サンプルコードです。
Controller を使わない方法もありますが、MUI の TextField 以外のコンポーネントはほぼController を使用するので、TextField もあえて Controller を使用して実装しました。

  1. 入力値の定義を作成します。
  2. useFormで必要な関数を取得し、デフォルト値を指定します。
  3. 検証ルールを指定します。
  4. サブミット時の処理を作成します。検証が成功すると呼び出され、引数で入力値が渡ってきます。
  5. form要素のonSubmitに1.で取得しているhandleSubmitを指定します
  6. Controllerコンポーネントで TextFieldをReactHookFormと紐づけます。
    • name="name" ・・・1や2で指定している一意な名称を指定します。
    • control={control} ・・・1で取得したcontrolを指定してます。
    • rules={validationRules.name} ・・・2で指定した検証ルールを指定します。
    • render ・・・ReactHookFormで制御するコンポーネントを指定します。
    • error={errors.name !== undefined} ・・・エラーの有無を指定します。
    • helperText={errors.name?.message} ・・・エラーのメッセージを指定します。
InputReactHookFormTextField
import { Stack, TextField, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'

// 1. 入力値の定義を作成します。
+ type Inputs = {
+  name: string
+ }

export function InputReactHookFormTextField() {
  // 2. useFormで必要な関数を取得し、デフォルト値を指定します。
+  const {
+    control,
+    handleSubmit,
+  } = useForm<Inputs>({
+    defaultValues: { name: 'longbridgeyuk' }
+  })

  // 3. 検証ルールを指定します。
+  const validationRules = {
+    name: {
+      required: '名前を入力してください。',
+      minLength: { value: 4, message: '4文字以上で入力してください。' }
+    }
+  }
  
  // 4. サブミット時の処理を作成します。
  // 検証が成功すると呼び出され、引数で入力値が渡ってきます。
+  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
+    console.log(`submit: ${data.name}`)
+  }

  return (
   {/* 5. form要素のonSubmitに1.で取得しているhandleSubmitを指定します */}
    <Stack component="form" noValidate 
+    onSubmit={handleSubmit(onSubmit)} 
    spacing={2} sx={{ m: 2, width: '25ch' }}>

       {/* 6.Controllerコンポーネントで TextFieldをReactHookFormと紐づけます。*/}
+      <Controller
+        name="name"
+        control={control}
+        rules={validationRules.name}
+        render={({ field, fieldState }) => (
          <TextField
+            {...field}
            type="text"
            label="名前"
+            error={fieldState.invalid}  
+            helperText={fieldState.error?.message}
          />
        )}
      />
      <Button variant="contained" type="submit" >
        送信する
      </Button>
    </Stack>
  )
}

Select との連携

つづいてSelectです。
まずは実行画像から確認します。

基本的に TextField と同じですが、MUI の Select 自体 が複数のコンポーネントで成り立っているので、その分だけ複雑さが増します。
InputLabel、Select、FormHelperText を含有するFormControl コンポーネントがあり、
この FormControl の error に検証エラーの有無を指定します。
検証エラーのメッセージはFormHelperText で表示しています。
Controllerコンポーネント の render には、FormControl を含めて指定しています。

import { Stack, FormControl, InputLabel, Select, MenuItem, FormHelperText, Button } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'

// 1. 入力値の定義を作成します。
+ type Inputs = {
+   area: number | ''
+ }

// 2. useFormで必要な関数を取得し、デフォルト値を指定します。
export function InputReactHookFormSelect() {
+   const {
+     control,
+     handleSubmit,
+   } = useForm<Inputs>({
+     defaultValues: { area: 6 }
+   })

 // 3. 検証ルールを指定します。
+   const validationRules = {
+     area: {
+       validate: (value:number | '') => value !== '' || 'いずれかを選択してください。'
+     }
+   }

  // 4. サブミット時の処理を作成します。
+   const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
+     console.log(`submit: ${data.area}`)
+   }

  return (
   {/* 5. form要素のonSubmitに1.で取得しているhandleSubmitを指定します */}
    <Stack component="form" noValidate 
+      onSubmit={handleSubmit(onSubmit)} 
      spacing={2} 
      sx={{ m: 2, width: '25ch' }}>

       {/* 6.Controllerコンポーネントで TextFieldをReactHookFormと紐づけます。*/}
+      <Controller
+        name="area"
+        control={control}
+        rules={validationRules.area}
+        render={({ field, fieldState }) => (
+          <FormControl fullWidth error={fieldState.invalid}>
            <InputLabel id="area-label">地域</InputLabel>
            <Select
              labelId="area-label"
              label="地域" // フォーカスを外した時のラベルの部分これを指定しないとラベルとコントロール線が被る
+              {...field}
            >
              <MenuItem value='' sx={{color:'gray'}}>未選択</MenuItem>
              <MenuItem value={1}>北海道</MenuItem>
              <MenuItem value={2}>東北</MenuItem>
              <MenuItem value={4}>関東</MenuItem>
              <MenuItem value={5}>中部</MenuItem>
              <MenuItem value={6}>近畿</MenuItem>
              <MenuItem value={7}>中国</MenuItem>
              <MenuItem value={8}>四国</MenuItem>
              <MenuItem value={9}>九州沖縄</MenuItem>
            </Select>
+            <FormHelperText>{fieldState.error?.message}</FormHelperText>
          </FormControl>
        )}
      />
      <Button variant="contained" type="submit" >
        送信する
      </Button>
    </Stack>
  )
}

RadioGroup との連携

まずは実行画像です。

コードは Select と同じで FormLabel、RadioGroup、FormControlLabel を含有するFormControl コンポーネントがあり、この FormControl の error に検証エラーの有無を指定します。
検証エラーのメッセージはFormHelperText で表示しています。
Controllerコンポーネント の render には、FormControl を含めて指定しています。

import {
  Stack,
  RadioGroup,
  FormLabel,
  FormControlLabel,
  Radio,
  FormControl,
  Button,
  FormHelperText
} from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'

// 1. 入力値の定義を作成します。
+ type Inputs = {
+   gender: number
+ }

export function InputReactHookFormRadioGroup() {
  
  // 2. useFormで必要な関数を取得し、デフォルト値を指定します。
+  const {
+    control,
+    handleSubmit
+  } = useForm<Inputs>({
+    defaultValues: { gender: -1 }
+  })

  // 3. 検証ルールを指定します。
+  const validationRules = {
+    gender: {
+      validate: (value: number) => value !== -1 || 'いずれかを選択してください。'
+    }
+  }

  // 4. サブミット時の処理を作成します。
+  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
+    console.log(`submit: ${data.gender}`)
+  }

  return (
    <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} spacing={2} sx={{ m: 2, width: '25ch' }}>
      {/* 6.Controllerコンポーネントで TextFieldをReactHookFormと紐づけます。 */}
+      <Controller
+        name="gender"
+        control={control}
+        rules={validationRules.gender}
+        render={({ field, fieldState }) => (
+          <FormControl error={fieldState.invalid}>
            <FormLabel id="radio-buttons-group-label">Gender</FormLabel>
            <RadioGroup 
              aria-labelledby="radio-buttons-group-label" 
+              value={field.value} name="gender">
+              <FormControlLabel {...field} value={1} control={<Radio />} label="男性" />
+              <FormControlLabel {...field} value={2} control={<Radio />} label="女性" />
+              <FormControlLabel {...field} value={0} control={<Radio />} label="未回答" />
            </RadioGroup>
+            <FormHelperText>{fieldState.error?.message}</FormHelperText>
          </FormControl>
        )}
      />

      <Button variant="contained" type="submit">
        送信する
      </Button>
    </Stack>
  )
}

DatePicker との連携

MUI の DatePicker は下記リンクで、その使い方や日本語化を記事にしています。
MUI v5 DatePickerの使い方 その1 ~日付ライブラリの選定 から基本の使い方 まで~
MUI v5 DatePickerの使い方 その2 ~日本語化~
その時に作成したDatePicker で React Hook Form と連携します。

DatePicker はまだ発展途上のコンポーネントなのでいろいろと不具合が多く、他のコンポーネントと動きが微妙に異なります。
Select と同じように、FormControl を使用して検証エラーを表示しようとしたのですが、入力ボックスの枠線やラベルがエラー表示(赤色で表示)されませんでした。
検証エラー時の表示は TextField の error に、検証エラーのメッセージは TextField の helperText に指定することがポイントです。

import { Stack, TextField, Button, FormControl } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'
import { LocalizationProvider, DatePicker } from '@mui/lab'
import AdapterDateFns from '@mui/lab/AdapterDateFns'
import ja from 'date-fns/locale/ja'

// 1. 入力値の定義を作成します。
+ type Inputs = {
+  applicationDate: Date | null
+ }

export function InputDateTimePicker() {
// 2. useFormで必要な関数を取得し、デフォルト値を指定します。
+  const {
+    control,
+    handleSubmit
+  } = useForm<Inputs>({
+    defaultValues: { applicationDate: new Date() }
+  })

// 3. 検証ルールを指定します。
+  const validationRules = {
+    applicationDate: {
+      validate: (val: Date | null) => {
+        if (val == null ) {
+          return '申請日を入力してください。'
+        }
+        if (Number.isNaN(val.getTime())) {
+          return '日付を正しく入力してください。'
+        }
+        return true
+      },
+    }
+  }

// 4. サブミット時の処理を作成します。
+  const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
+    console.log(`submit: ${data.applicationDate}`)
+  }

  return (
    <LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>
     {/* 5. form要素のonSubmitに1.で取得しているhandleSubmitを指定します */}
      <Stack component="form" noValidate 
+	 onSubmit={handleSubmit(onSubmit)} 
	 spacing={2} sx={{ m: 2, width: '25ch' }}>
	 
	{/* 6.Controllerコンポーネントで TextFieldをReactHookFormと紐づけます。*/}
+        <Controller
+          name="applicationDate"
+          control={control}
+          rules={validationRules.applicationDate}
+          render={({ field, fieldState }) => (
            <DatePicker
              label="申請日"
              inputFormat="yyyy年MM月dd日"
              mask="____年__月__日"
              leftArrowButtonText="前月を表示"
              rightArrowButtonText="次月を表示"
              toolbarTitle="日付選択"
              cancelText="キャンセル"
              okText="選択"
              toolbarFormat="yyyy年MM月dd日"
              renderInput={(params) => (
                <TextField
                  {...params}
+                  error={fieldState.invalid}
+                  helperText={fieldState.error?.message}
                />
              )}
              PaperProps={{ sx: styles.paperprops }}
              DialogProps={{ sx: styles.mobiledialogprops }}
+              {...field}
            />
          )}
        />

        <Button variant="contained" type="submit">
          送信する
        </Button>
      </Stack>
    </LocalizationProvider>
  )
}

const styles = {
  paperprops: {
    'div[role=presentation]': {
      display: 'flex',
      '& .PrivatePickersFadeTransitionGroup-root:first-of-type': {
        order: 2
      },
      '& .PrivatePickersFadeTransitionGroup-root:nth-of-type(2)': {
        order: 1,
        '& div::after': {
          content: '"年"'
        }
      },
      '& .MuiButtonBase-root': {
        order: 3
      }
    }
  },
  mobiledialogprops: {
    '.PrivatePickersToolbar-dateTitleContainer .MuiTypography-root': {
      fontSize: '1.5rem'
    },
    'div[role=presentation]:first-of-type': {
      display: 'flex',
      '& .PrivatePickersFadeTransitionGroup-root:first-of-type': {
        order: 2
      },
      '& .PrivatePickersFadeTransitionGroup-root:nth-of-type(2)': {
        order: 1,
        '& > div::after': {
          content: '"年"'
        }
      },
      '& .MuiButtonBase-root': {
        order: 3
      }
    }
  }
}

複数のCheckBox(CheckBoxGroup的なもの) との連携

まずは実行画像です。
CheckBox が2つ以上チェックONされてないとエラーにしています。

CheckBox には RadioGroup のような、複数の CheckBox を扱うコンポーネントがありません。
複数のCheckBox と React Hook Form をいい感じにサクっと連携する方法は用意されていないようです。

そこであまり手間をかけずに連携する方法として
一つ一つのチェックボックスをReact Hook Form と連携し、入力値の定義に検証結果だけを設定する項目 checkerr を設けました。
検証ルールでは個々のチェックボックスの検証はエラーなしとし、検証結果は checkerr に設定するようにします。
エラーの有無は FormControl の error に設定し、エラー内容は FormHelperText に表示しています。

import { Stack, Button, FormControlLabel, Checkbox, FormControl, FormGroup, FormHelperText } from '@mui/material'
import { useForm, SubmitHandler, Controller } from 'react-hook-form'

// 1. 入力値の定義を作成します。
// 個々のチェックボックス用の checks と
// 検証エラー用に checkerr を用意しています。
+ type Inputs = {
+   checks: boolean[]
+   checkerr: boolean
+ }

export function InputReactHookCheckBox() {

// 2. useFormで必要な関数を取得し、デフォルト値を指定します。
+   const {
+     control,
+     handleSubmit,
+     formState: { errors },
+     getValues,
+     clearErrors,
+     setError
+   } = useForm<Inputs>({
+     defaultValues: {
+       checks: [false, true, false],
+       checkerr: false
+     }
+   })

// 3. 検証ルールを指定します。
// 個々のチェックボックスが検証エラーにならないようにし
// 検証結果は checkerr に設定するようにしています。
+   const validationRules = {
+     checks: {
+       validate: () => {
+         clearErrors(`checkerr`)
+         const checks = [getValues(`checks.${0}`), getValues(`checks.${1}`), getValues(`checks.${2}`)]
+         if (checks.filter((v) => v === true).length < 2) {
+           setError(`checkerr`, { message: 'いずれか2つ選択してください。' })
+         }
+         return true
+       }
+     }
+   }

// 4. サブミット時の処理を作成します。検証が成功すると呼び出され、引数で入力値が渡ってきます。
+   const onSubmit: SubmitHandler<Inputs> = (data: Inputs) => {
+     console.log(`submit: checks[0]=${data.checks[0]} checks[1]=${data.checks[1]}  checks[2]=${data.checks[2]}`)
+   }

  return (
    <Stack component="form" noValidate onSubmit={handleSubmit(onSubmit)} spacing={2} sx={{ m: 2, width: '25ch' }}>
    
+      <FormControl fullWidth error={errors.checkerr !== undefined}>
        <span>いずれか2つ選択。</span>
        <FormGroup>
	
+           <Controller
+             name={`checks.${0}`}
+             control={control}
+             rules={validationRules.checks}
+             render={({ field }) => (
+               <FormControlLabel label="チェック 1" control={<Checkbox {...field} checked={field.value} />} />
+             )}
+           />

+          <Controller
+            name={`checks.${1}`}
+            control={control}
+            rules={validationRules.checks}
+            render={({ field }) => (
+              <FormControlLabel label="チェック 2" control={<Checkbox {...field} checked={field.value} />} />
+            )}
+          />

+         <Controller
+           name={`checks.${2}`}
+           control={control}
+           rules={validationRules.checks}
+           render={({ field }) => (
+             <FormControlLabel label="チェック 3" control={<Checkbox {...field} checked={field.value} />} />
+           )}
+         />
 
        </FormGroup>
+        <FormHelperText>{errors.checkerr?.message}</FormHelperText>
      </FormControl>

      <Button variant="contained" type="submit">
        送信する
      </Button>
    </Stack>
  )
}

まとめ

今回はMUI v5 のよく使うであろうコンポーネントと React Hook Form の連携について調べてみました。
TextField、Select、RadioGroupについてはすんなりと組めたのですが、DatePicker はコンポーネント自体の扱いに手間取りました。
複数チェックボックスについては、そもそもどうするのが正解なのかわかりません。
MUI v5 以前では複数チェックボックスを取り扱う方法があったようなのですが、MUI v5では以前の方法が使えなかったので記事のような方法を採ってみました。
概ね良い感じで動作しているのでヨシとします😉