React + React Hook Form v7 なフォームに、MUI v5 の DateTime Picker を組み込んでみた

React Hook Form に MUI の DateTime Picker を組み込んだところ、いくつか悩んだことがあったため、メモを残します。

 
目次

 

環境

  • React 17.0.2
  • React Router 6.2.1
  • @mui/material 5.2.4
  • @mui/lab 5.0.0-alpha.60
  • date-fns 2.27.0
  • react-hook-form 7.22.2

 
前回の記事の環境に対し、 yarn upgrade-interactive にて今回使いそうなライブラリのバージョンを上げています。

 
2022/07/18 追記 ここから

2022/07/18時点の最新パッケージを使う場合、 import するパッケージや locale に変更が入っています。こちらにまとめていますので、よろしければご確認ください。
React17 + MUI DateTimePicker + React Hook Form なアプリを yarn upgrade --latest したら破壊的変更が入っていたので修正した - メモ的な思考的な

2022/07/18 追記 ここまで

 

作るもの

日時が表示されています。

EDIT DATETIME ボタンをクリックすると、モーダル に MUIのDateTime Picker が表示されます。

 
内容を変更します。

 
保存するとモーダルが閉じ、最初の画面の日時が更新されます。

 

実装の流れ

MUI で Modal を作る

MUI の Modal のサンプルコードを見ながら、 Modal を作ってみます。
React Modal component - MUI

サンプルコードと異なり、 style を適用せず Modal を表示してみます。

MuiModal.tsx

import {Button, Modal} from '@mui/material'
import {useState} from 'react'

const Component = (): JSX.Element => {
  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  return (
    <>
      <Button onClick={handleOpen} variant="contained">
        Open
      </Button>

      <Modal open={open} onClose={handleClose}>
        <h2>hello</h2>
      </Modal>
    </>
  )
}
export default Component

 
OPENボタンをクリックすると、ボタンとモーダルが重なった形で表示されました。また、モーダルエリアの色がなく、分かりづらいです。

 
Boxコンポーネントsx prop を使って、サンプルコード通りのスタイルを当ててみます。
React Box component - MUI

MuiModalWithStyle.tsx

import {Box, Button, Modal} from '@mui/material'
import {useState} from 'react'

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4
}

const Component = (): JSX.Element => {
  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  return (
    <>
      <Button onClick={handleOpen} variant="contained">
        Open
      </Button>

      <Modal open={open} onClose={handleClose}>
        <Box sx={style}>
          <h2>hello</h2>
        </Box>
      </Modal>
    </>
  )
}
export default Component

 
モーダルの位置が調整され、モーダルエリアも分かりやすくなりました。

 

React Hook Form を使ってフォームを作る

続いて、 React Hook Form を使ってフォームを作ります。
Home | React Hook Form - Simple React forms validation

今回は Get Started に従って作ります。
Get Started | React Hook Form - Simple React forms validation

まずは、 useForm hook にて registerhandleSubmit メソッドを使えるようにします。

const {register, handleSubmit} = useForm()

 
次に

  • formに handleSubmit(onSubmit) を追加
  • .....register('name') にて、 name という名前の Input を追加

コンポーネントに追加します。

<form onSubmit={handleSubmit(onSubmit)}>
  <input {...register('name')} />
  <input type="submit" />
</form>

 
最後に、 handleSubmit に渡す onSubmit を定義します。

今回はコンソールにデータを出力するのみとします。

なお、onSubmit で型指定が必要になるため、input name と型を指定します。

type FormInput = {
  name: string
}

const Component = (): JSX.Element => {
// ...
  const onSubmit: SubmitHandler<FormInput> = (data) => console.log(data)
  return (
// ...

 
全体像はこんな感じです。

MuiModalWithReactHookForm.tsx

import {Box, Button, Modal} from '@mui/material'
import {useState} from 'react'
import {SubmitHandler, useForm} from 'react-hook-form'

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4
}

type FormInput = {
  name: string
}

const Component = (): JSX.Element => {
  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  const {register, handleSubmit} = useForm()
  const onSubmit: SubmitHandler<FormInput> = (data) => console.log(data)

  return (
    <>
      <Button onClick={handleOpen} variant="contained">
        Open
      </Button>

      <Modal open={open} onClose={handleClose}>
        <Box sx={style}>
          <form onSubmit={handleSubmit(onSubmit)}>
            <input {...register('name')} />
            <input type="submit" />
          </form>
        </Box>
      </Modal>
    </>
  )
}
export default Component

 
OPENボタンをクリックし、値を入力後に送信ボタンをクリックすると、コンソールに入力値が表示されました。

 

MUI DateTime Picker と組み合わせる

セットアップ

続いて、React Hook Form を MUI の DateTime Picker と組み合わせて使ってみます。
React Date Time Picker component - MUI

なお、DateTime Picker は @mui/lab のインストールが必要です。
About the lab - MUI

% yarn add @mui/lab

 
他にも日付を処理するライブラリが必要になります。

今回は以前使用した date-fns を使います。

 

先ほどの React Hook Form では、<input {...register('name')} /> のように、 uncontrolled component を hook に登録していました。

 
ただ、MUI などの UI コンポーネントライブラリを使いたい場合は、Controller コンポーネントを使うことで React Hook Form に組み込めます。

 

まずは、Modal の中に LocalizationProviderController を配置してみます。

Controller コンポーネントについては

  • name
    • input を識別するためのユニークな名前
  • defaultValue
    • DateTime Picker のデフォルト値
  • render

を指定します。

LocalizationProviderInside.tsx

<Modal open={open} onClose={handleClose}>
  <LocalizationProvider dateAdapter={AdapterDateFns}>
    <Box sx={style}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
          name="inputValue"
          control={control}
          defaultValue={new Date()}
          render={({field}) => {
            return (
              <DateTimePicker
                {...field}
                label="input"
                renderInput={(props) => <TextField {...props} />}
                onChange={(newValue) => {
                  setValue('inputValue', newValue)
                }}
              />
            )
          }}
        />
      </form>
    </Box>
  </LocalizationProvider>
</Modal>

 
画面には MUI DataTime Picker が表示できたものの、Warning も出ています。

Warning: Failed prop type: Invalid prop children supplied to Unstable_TrapFocus. Expected an element that can hold a ref. Did you accidentally use a plain function component for an element instead? For more information see https://mui.com/r/caveat-with-refs-guide

 
そのため、 Modal の外に LocalizationProvider を、 Modal の中に Controller を配置することで、Warning が出なくなります。

LocalizationProviderOutside.tsx

<Modal open={open} onClose={handleClose}>
  <LocalizationProvider dateAdapter={AdapterDateFns}>
    <Box sx={style}>
      <form onSubmit={handleSubmit(onSubmit)}>
        <Controller
        />
      </form>
    </Box>
  </LocalizationProvider>
</Modal>

 

Localization のために mask と inputFormat を指定

DateTime Picker の Localization は、 LocalizationProvider に locale を渡せばよいです。
Localization - React Date Picker component - MUI

ただ、

<LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>

と指定しただけでは、画面は正しく表示されるものの、Warning が出ます (例: LocalizationProviderOutsideWithLocale.tsx)。

The mask "// :" you passed is not valid for the format used P HH:mm. Falling down to uncontrolled not-masked input.

 
そこで、Controller の中にある DateTime Picker に mask を追加しますが、それでもまだ同じエラーが出ます。

LocalizationProviderOutsideWithLocaleMask.tsx

<Controller
  render={({field}) => {
    return (
      <DateTimePicker
        {...field}
        label="input"
        mask="____/__/__ __:__:__"
        renderInput={(props) => <TextField {...props} />}
        onChange={(newValue) => {
          setValue('inputValue', newValue)
        }}
      />
    )
  }}
/>

 
さらに、maskinputFormat を指定します。

今回は date-fns を使っているため、 date-fns のフォーマットで指定します。
date-fns - modern JavaScript date utility library

LocalizationProviderOutsideWithLocaleMaskInputFormat.tsx

<Controller
  render={({field}) => {
    return (
      <DateTimePicker
        {...field}
        label="input"
        mask="____/__/__ __:__:__"
        inputFormat="yyyy/MM/dd HH:mm:ss"
        renderInput={(props) => <TextField {...props} />}
        onChange={(newValue) => {
          setValue('inputValue', newValue)
        }}
      />
    )
  }}
/>

 

ここまでで機能ができたため、最後に

の2つに分けてみます。

 
まずは、入力した日時を表示するコンポーネントについてです。

コンポーネントでは、Modal の開閉に関係する state や、表示する日時の state を、 Modal のコンポーネントに渡します。

DateTimePickerWithReactHookForm.tsx

import {Button} from '@mui/material'
import {useState} from 'react'
import DateTimePickerModal from '@/components/pages/datetime_picker_with_react_hook_form/DateTimePickerModal'
import {format} from 'date-fns'

const Component = (): JSX.Element => {
  const [dateTimeLabel, setDateTimeLabel] = useState<Date | null>(new Date())

  const [open, setOpen] = useState(false)
  const handleOpen = () => setOpen(true)
  const handleClose = () => setOpen(false)

  return (
    <>
      <h1>Datetime Picker with React Hook Form</h1>
      <h2>Current value: {dateTimeLabel && format(dateTimeLabel, 'yyyy/MM/dd HH:mm:ss')}</h2>

      <Button onClick={handleOpen} variant="contained">
        Edit DateTime
      </Button>

      <DateTimePickerModal
        datetimeLabel={dateTimeLabel}
        setDateTimeLabel={setDateTimeLabel}
        open={open}
        handleClose={handleClose}
      />
    </>
  )
}
export default Component

 
続いて、 Modal のコンポーネントです。

  • 日時の state を Controller の defaultValue に設定
  • DateTimePicker の onChange にて、 React Hook Form の setValue を使って Controller の name に指定した項目 inputValue に値を設定
  • form の onSubmit で指定した関数 ( onSubmit ) にて、日時の set hook を使って値を設定し、 Modal を閉じる

などしています。

DateTimePickerModal.tsx

import {Controller, SubmitHandler, useForm} from 'react-hook-form'
import {Box, Button, Modal, TextField} from '@mui/material'
import {DateTimePicker, LocalizationProvider} from '@mui/lab'
import AdapterDateFns from '@mui/lab/AdapterDateFns'
import {ja} from 'date-fns/locale'
import {Dispatch, SetStateAction} from 'react'

type Props = {
  datetimeLabel: Date | null
  setDateTimeLabel: Dispatch<SetStateAction<Date | null>>
  open: boolean
  handleClose: () => void
}

type Input = {
  inputValue: Date | null
}

const style = {
  position: 'absolute' as 'absolute',
  top: '50%',
  left: '50%',
  transform: 'translate(-50%, -50%)',
  width: 400,
  bgcolor: 'background.paper',
  border: '2px solid #000',
  boxShadow: 24,
  p: 4
}

const Component = ({open, handleClose, datetimeLabel, setDateTimeLabel}: Props): JSX.Element => {
  const {control, handleSubmit, setValue} = useForm<Input>()

  const onSubmit: SubmitHandler<Input> = (data) => {
    console.log(data)
    setDateTimeLabel(data['inputValue'])
    handleClose()
  }

  return (
    <>
      <LocalizationProvider dateAdapter={AdapterDateFns} locale={ja}>
        <Modal open={open} onClose={handleClose}>
          <Box sx={style}>
            <form onSubmit={handleSubmit(onSubmit)}>
              <Controller
                name="inputValue"
                control={control}
                defaultValue={datetimeLabel}
                render={({field}) => {
                  return (
                    <DateTimePicker
                      {...field}
                      label="input"
                      inputFormat="yyyy/MM/dd HH:mm:ss"
                      mask="____/__/__ __:__:__"
                      renderInput={(props) => <TextField {...props} />}
                      onChange={(newValue) => {
                        setValue('inputValue', newValue)
                      }}
                    />
                  )
                }}
              />

              <Button type="submit" variant="contained">
                Save
              </Button>
            </form>
          </Box>
        </Modal>
      </LocalizationProvider>
    </>
  )
}
export default Component

 
以上にて、作るものが完成しました。

 

ソースコード

Github に上げました。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample

 
今回のプルリクはこちらです。
https://github.com/thinkAmi-sandbox/react_mui_with_vite-sample/pull/7