はじめに
今回はReact MUIとReact Hook Formを用いて汎用的な入力コンポーネントを作成する方法をご紹介します。良いのか悪いのかは微妙な内容ですので、お話し程度にどうぞ。
今回は以下のような画面を作ることを考えます。
この記事を読むとできること
Before
上記の画面は、下記のようなコードで実装できます。
クリックしてコードを表示できます。
import {
Container,
TextField,
Box,
Select,
MenuItem,
FormControl,
InputLabel,
FormHelperText
} from '@mui/material'
import { DesktopDatePicker, LocalizationProvider } from '@mui/x-date-pickers'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
import { Controller, useForm } from 'react-hook-form'
import Grid from '@mui/material/Grid2'
import { LoadingButton } from '@mui/lab'
const GENDER_OPTIONS = [
{
id: 1,
name: '男性',
value: 'MAN'
},
{
id: 2,
name: '女性',
value: 'WOMAN'
},
{
id: 3,
name: 'その他',
value: 'OTHER'
}
]
const MainPage = () => {
const {
control,
handleSubmit,
formState: { errors }
} = useForm({})
const signUp = async (inputData: { [key: string]: string }) => {
console.log(inputData)
}
return (
<Container maxWidth="sm" sx={{ boxShadow: 1 }}>
<Box fontSize="large" textAlign="center">
ユーザ登録
</Box>
<Grid container size={12} columnSpacing={2} rowSpacing={1} p={2}>
<Grid size={12}>
<Controller
control={control}
name="firstName"
defaultValue=""
rules={{
required: true
}}
render={({ field }) => (
<TextField
{...field}
label="姓"
type="search"
required
fullWidth
error={!!errors.firstName}
helperText={errors.firstName && '姓 は必須項目です。'}
sx={{ '& .MuiFormLabel-asterisk': { color: 'red' } }}
/>
)}
/>
</Grid>
<Grid size={12}>
<Controller
control={control}
name="lastName"
defaultValue=""
rules={{
required: true
}}
render={({ field }) => (
<TextField
{...field}
label="名"
type="search"
required
fullWidth
error={!!errors.lastName}
helperText={errors.lastName && '名 は必須項目です。'}
sx={{ '& .MuiFormLabel-asterisk': { color: 'red' } }}
/>
)}
/>
</Grid>
<Grid size={6}>
<Controller
control={control}
name="birthDay"
defaultValue=""
rules={{ required: true }}
render={({ field }) => (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DesktopDatePicker
{...field}
label="生年月日"
format="YYYY/MM/DD"
value={field.value || null}
slotProps={{
calendarHeader: { format: 'YYYY年MM月' },
textField: {
fullWidth: true,
required: true,
error: !!errors.birthDay,
helperText:
errors.birthDay && '生年月日 は必須項目です。',
type: 'search'
}
}}
sx={{
'& .MuiFormLabel-asterisk': { color: 'red' }
}}
/>
</LocalizationProvider>
)}
/>
</Grid>
<Grid size={6}>
<Controller
control={control}
name="gender"
defaultValue=""
rules={{ required: true }}
render={({ field }) => (
<FormControl
fullWidth
required
error={!!errors.gender}
sx={{
'& .MuiFormLabel-asterisk': { color: 'red' }
}}
>
<InputLabel>性別</InputLabel>
<Select {...field} label="性別">
{GENDER_OPTIONS.map((option) => (
<MenuItem key={option.id} value={option.value}>
{option.name}
</MenuItem>
))}
</Select>
<FormHelperText>
{errors.gender && '性別 は必須項目です。'}
</FormHelperText>
</FormControl>
)}
/>
</Grid>
<Grid size={12}>
<Controller
control={control}
name="password"
defaultValue=""
rules={{
required: true
}}
render={({ field }) => (
<TextField
{...field}
label="パスワード"
type="password"
required
fullWidth
error={!!errors.lastName}
helperText={errors.lastName && 'パスワード は必須項目です。'}
sx={{ '& .MuiFormLabel-asterisk': { color: 'red' } }}
/>
)}
/>
</Grid>
<Grid size={12}>
<Controller
control={control}
name="selfIntroduction"
defaultValue=""
render={({ field }) => (
<TextField {...field} label="自己紹介" fullWidth multiline />
)}
/>
</Grid>
<Grid size={12} mt={5}>
<LoadingButton
fullWidth
variant="contained"
onClick={handleSubmit(signUp)}
>
登録
</LoadingButton>
</Grid>
</Grid>
</Container>
)
}
export default MainPage
After
React Hook FormのFormProvider
やuseFormContext
と今回独自に作成するコンポーネント(Base〇〇.tsxと命名)を用いて下記のようなコードで実装できるようになります。
※画面の見た目は同じになるため、タイトルに(Baseコンポーネント使用)と記載することにします。
クリックしてコードを表示できます
import { Container, Box } from '@mui/material'
import { useForm, FormProvider } from 'react-hook-form'
import Grid from '@mui/material/Grid2'
import { LoadingButton } from '@mui/lab'
import BaseTextField from '@/components/BaseTextFeild'
import BaseDatePicker from '@/components/BaseDatePicker'
import BaseSelect from '@/components/BaseSelect'
const GENDER_OPTIONS = [
{
id: 1,
name: '男性',
value: 'MAN'
},
{
id: 2,
name: '女性',
value: 'WOMAN'
},
{
id: 3,
name: 'その他',
value: 'OTHER'
}
]
const MainPage = () => {
const methods = useForm({})
const signUp = async (inputData: { [key: string]: string }) => {
console.log(inputData)
}
return (
<Container maxWidth="sm" sx={{ boxShadow: 1 }}>
<Box fontSize="large" textAlign="center">
ユーザ登録 (Baseコンポーネント使用)
</Box>
<Grid container size={12} columnSpacing={2} rowSpacing={1} p={2}>
<FormProvider {...methods}>
<Grid size={12}>
<BaseTextField name="firstName" label="姓" required />
</Grid>
<Grid size={12}>
<BaseTextField name="lastName" label="名" required />
</Grid>
<Grid size={6}>
<BaseDatePicker name="birthDay" label="生年月日" required />
</Grid>
<Grid size={6}>
<BaseSelect
name="gender"
label="性別"
required
options={GENDER_OPTIONS}
/>
</Grid>
<Grid size={12}>
<BaseTextField
name="password"
label="パスワード"
type="password"
required
/>
</Grid>
<Grid size={12}>
<BaseTextField name="selfIntroduction" label="自己紹介" multiline />
</Grid>
<Grid size={12} mt={5}>
<LoadingButton
fullWidth
variant="contained"
onClick={methods.handleSubmit(signUp)}
>
登録
</LoadingButton>
</Grid>
</FormProvider>
</Grid>
</Container>
)
}
export default MainPage
上図のように、必須入力のバリデーションについても機能します。
対象の方
- 環境構築ができている方
- MUIとReact Hook Formを使用したことがある方
- Controllerやrender部分を毎回書くのが煩雑だと感じている方
環境とドキュメント
- MUI material: 6.2.1
- MUI x-date-pickers: 7.23.3
- React Hook Form: 7.54.1
TextFieldのコンポーネント
子コンポーネントでは、useFormContext
を用いて親コンポーネントとデータのやりとりをできるようにしていきます。Propsでrequired
やtype
などを設定できるようにしています。特に、required
の変数ひとつで入力欄に*を表示させることと、入力必須のバリデーションが実装されるようにしています。
今回は例の画面を実装する場合のソースコードになっていますが、readonly
やdisabled
なども同様にすればPropsで設定できるようになります。
またカスタムバリデーションを設定したい場合でもバリデーションの関数を作ってPropsで受け渡せば実装できます。
それ以外のController
やrender
を用いる部分は、変数で機能するように書き換えたら問題ありません。
import { TextField } from '@mui/material'
import { Controller, useFormContext } from 'react-hook-form'
type Props = {
name: string
label: string
required?: boolean
multiline?: boolean
type?: string
defaultValue?: string | number
}
const BaseTextField = ({
name,
label,
required,
multiline,
type,
defaultValue
}: Props) => {
const {
control,
formState: { errors }
} = useFormContext()
return (
<Controller
name={name}
control={control}
defaultValue={defaultValue ?? ''}
rules={{
required: required ?? false
}}
render={({ field }) => (
<TextField
{...field}
label={label}
type={type}
required={required ?? false}
multiline={multiline ?? false}
fullWidth
error={!!errors[name]}
helperText={errors[name] && `${label} は必須項目です。`}
sx={{ '& .MuiFormLabel-asterisk': { color: 'red' } }}
/>
)}
/>
)
}
export default BaseTextField
ドキュメント
- Text Field
- Form Probider
- useFormContext
DatePickerのコンポーネント
基本はTextFieldの時と同じです。
いつものController
とrender
の部分をPropsで変化するようにして、useFormContext
で親コンポーネントとやりとりできるようにします。
import { Controller, useFormContext } from 'react-hook-form'
import { DesktopDatePicker, LocalizationProvider } from '@mui/x-date-pickers'
import { AdapterDayjs } from '@mui/x-date-pickers/AdapterDayjs'
type Props = {
name: string
label: string
required?: boolean
}
const BaseDatePicker = ({ name, label, required }: Props) => {
const {
control,
formState: { errors }
} = useFormContext()
return (
<Controller
name={name}
control={control}
rules={{
required: required ?? false
}}
render={({ field }) => (
<LocalizationProvider dateAdapter={AdapterDayjs}>
<DesktopDatePicker
{...field}
label={label}
value={field.value || null}
sx={{ '& .MuiFormLabel-asterisk': { color: 'red' } }}
slotProps={{
calendarHeader: { format: 'YYYY年MM月' },
textField: {
helperText: errors[name] && `${label} は必須項目です。`,
error: !!errors[name],
required: required ?? false,
fullWidth: true
}
}}
/>
</LocalizationProvider>
)}
/>
)
}
export default BaseDatePicker
ドキュメント
- DatePicker
- Form Probider
- useFormContext
Selectのコンポーネント
基本はこれまでと同じです。
const GENDER_OPTIONS = [
{
id: 1,
name: '男性',
value: 'MAN'
},
{
id: 2,
name: '女性',
value: 'WOMAN'
},
{
id: 3,
name: 'その他',
value: 'OTHER'
}
]
import {
Select,
MenuItem,
FormControl,
InputLabel,
FormHelperText
} from '@mui/material'
import { Controller, useFormContext } from 'react-hook-form'
type Props = {
name: string
label: string
options: { [key: string]: number | string }[]
required?: boolean
defaultValue?: string | number
}
const BaseSelect = ({
name,
label,
required,
options,
defaultValue
}: Props) => {
const {
control,
formState: { errors }
} = useFormContext()
return (
<Controller
name={name}
control={control}
rules={{ required: required ?? false }}
defaultValue={defaultValue ?? ''}
render={({ field }) => (
<FormControl
fullWidth
required={required ?? false}
error={!!errors[name]}
sx={{
'& .MuiFormLabel-asterisk': { color: 'red' }
}}
>
<InputLabel>{label}</InputLabel>
<Select {...field} label={label}>
{options.map((item) => (
<MenuItem key={item.id} value={item.value}>
{item.name}
</MenuItem>
))}
</Select>
<FormHelperText>
{errors[name] && `${label} は必須項目です`}
</FormHelperText>
</FormControl>
)}
/>
)
}
export default BaseSelect
ドキュメント
- Select
- Form Probider
- useFormContext
余談
Autocompleteなどでもそうですが、少し悩みどころなのが選択肢を渡す必要がある点です。コンポーネント化してしまうとどうしても選択肢に使用するオブジェクトのkey名を固定する必要があります。(Propsでどのkey名を参照するか指定することでその都度変更することもできますが、かなり好みは分かれそうですね。)
個人的にはフロント側で選択肢を定義する場合はあまり問題なさそうですが、例えばサーバから取得したデータを使いたい場合などには少し大変かもしれません。
Baseコンポーネントを使用した画面のコード
はじめにの部分でも載せたコードをもう一度載せておきます。
import { Container, Box } from '@mui/material'
import { useForm, FormProvider } from 'react-hook-form'
import Grid from '@mui/material/Grid2'
import { LoadingButton } from '@mui/lab'
import BaseTextField from '@/components/BaseTextFeild'
import BaseDatePicker from '@/components/BaseDatePicker'
import BaseSelect from '@/components/BaseSelect'
const GENDER_OPTIONS = [
{
id: 1,
name: '男性',
value: 'MAN'
},
{
id: 2,
name: '女性',
value: 'WOMAN'
},
{
id: 3,
name: 'その他',
value: 'OTHER'
}
]
const MainPage = () => {
const methods = useForm({})
const signUp = async (inputData: { [key: string]: string }) => {
console.log(inputData)
}
return (
<Container maxWidth="sm" sx={{ boxShadow: 1 }}>
<Box fontSize="large" textAlign="center">
ユーザ登録 (Baseコンポーネント使用)
</Box>
<Grid container size={12} columnSpacing={2} rowSpacing={1} p={2}>
<FormProvider {...methods}>
<Grid size={12}>
<BaseTextField name="firstName" label="姓" required />
</Grid>
<Grid size={12}>
<BaseTextField name="lastName" label="名" required />
</Grid>
<Grid size={6}>
<BaseDatePicker name="birthDay" label="生年月日" required />
</Grid>
<Grid size={6}>
<BaseSelect
name="gender"
label="性別"
required
options={GENDER_OPTIONS}
/>
</Grid>
<Grid size={12}>
<BaseTextField
name="password"
label="パスワード"
type="password"
required
/>
</Grid>
<Grid size={12}>
<BaseTextField name="selfIntroduction" label="自己紹介" multiline />
</Grid>
<Grid size={12} mt={5}>
<LoadingButton
fullWidth
variant="contained"
onClick={methods.handleSubmit(signUp)}
>
登録
</LoadingButton>
</Grid>
</FormProvider>
</Grid>
</Container>
)
}
export default MainPage
おわりに
ここまでお読みいただきありがとうございました。悩ましいポイントもあり、またできないと困るものではありませんが、少しでも何かの役に立てたのなら嬉しいです。
余談
タイトルの内容からは離れた内容ですが、書きたくなったので少しだけ。
pages
直下のtsxファイルやpage.tsx
はapi通信の処理や作成したコンポーネントを寄せ集めたファイルにしたいのに・・・という方へ。
今回作成した「プロフィール登録」を丸々コンポーネント化(SignUpForm.tsx
と命名)し、親元に入力データを受け渡す書き方です。以下のようにapi通信を行う関数を渡し、React Hook FormのhandleSubmitの書き方を少し変えるだけで簡単に実装できます。
クリックしてコードを表示できます
import SignUpForm from '@/components/Form'
const MainPage = () => {
const signUp = async (inputData: { [key: string]: string }) => {
console.log(inputData)
//api通知の処理を書いてください。
}
return <SignUpForm signUp={signUp} />
}
export default MainPage
import { Container, Box } from '@mui/material'
import { useForm, FormProvider } from 'react-hook-form'
import Grid from '@mui/material/Grid2'
import { LoadingButton } from '@mui/lab'
import BaseTextField from '@/components/BaseTextFeild'
import BaseDatePicker from '@/components/BaseDatePicker'
import BaseSelect from '@/components/BaseSelect'
type Props = {
signUp: (inputData: { [key: string]: string }) => Promise<void>
}
const GENDER_OPTIONS = [
{
id: 1,
name: '男性',
value: 'MAN'
},
{
id: 2,
name: '女性',
value: 'WOMAN'
},
{
id: 3,
name: 'その他',
value: 'OTHER'
}
]
const SignUpForm = ({ signUp }: Props) => {
const methods = useForm({})
return (
<Container maxWidth="sm" sx={{ boxShadow: 1 }}>
<Box fontSize="large" textAlign="center">
ユーザ登録
</Box>
<Grid container size={12} columnSpacing={2} rowSpacing={1} p={2}>
<FormProvider {...methods}>
<Grid size={12}>
<BaseTextField name="firstName" label="姓" required />
</Grid>
<Grid size={12}>
<BaseTextField name="lastName" label="名" required />
</Grid>
<Grid size={6}>
<BaseDatePicker name="birthDay" label="生年月日" required />
</Grid>
<Grid size={6}>
<BaseSelect
name="gender"
label="性別"
required
options={GENDER_OPTIONS}
/>
</Grid>
<Grid size={12}>
<BaseTextField
name="password"
label="パスワード"
type="password"
required
/>
</Grid>
<Grid size={12}>
<BaseTextField name="selfIntroduction" label="自己紹介" multiline />
</Grid>
<Grid size={12} mt={5}>
<LoadingButton
fullWidth
variant="contained"
onClick={methods.handleSubmit(
async (inputData) => await signUp(inputData)
)}
>
登録
</LoadingButton>
</Grid>
</FormProvider>
</Grid>
</Container>
)
}
export default SignUpForm
参考記事
- こちらの記事では、MUIとReact Hook Formとの連携やFormProvider、useFormContextについてまとめていただいています。