0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

OECUAdvent Calendar 2024

Day 21

【React/TypeScript】MUI と React Hook Formで汎用的な入力コンポーネントを作る。

Posted at

はじめに

今回はReact MUIとReact Hook Formを用いて汎用的な入力コンポーネントを作成する方法をご紹介します。良いのか悪いのかは微妙な内容ですので、お話し程度にどうぞ。
今回は以下のような画面を作ることを考えます。
デモ画面.png

この記事を読むとできること

Before

上記の画面は、下記のようなコードで実装できます。

クリックしてコードを表示できます。
index.tsx
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のFormProvideruseFormContextと今回独自に作成するコンポーネント(Base〇〇.tsxと命名)を用いて下記のようなコードで実装できるようになります。
※画面の見た目は同じになるため、タイトルに(Baseコンポーネント使用)と記載することにします。

クリックしてコードを表示できます
index.tsx
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

バリデーション.png
上図のように、必須入力のバリデーションについても機能します。

対象の方

  • 環境構築ができている方
  • 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でrequiredtypeなどを設定できるようにしています。特に、requiredの変数ひとつで入力欄に*を表示させることと、入力必須のバリデーションが実装されるようにしています。
今回は例の画面を実装する場合のソースコードになっていますが、readonlydisabledなども同様にすればPropsで設定できるようになります。
またカスタムバリデーションを設定したい場合でもバリデーションの関数を作ってPropsで受け渡せば実装できます。
それ以外のControllerrenderを用いる部分は、変数で機能するように書き換えたら問題ありません。

BaseTextField.tsx
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の時と同じです。
いつものControllerrenderの部分をPropsで変化するようにして、useFormContextで親コンポーネントとやりとりできるようにします。

BaseDatePicker.tsx
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のコンポーネント

基本はこれまでと同じです。

index.tsx
const GENDER_OPTIONS = [
  {
    id: 1,
    name: '男性',
    value: 'MAN'
  },
  {
    id: 2,
    name: '女性',
    value: 'WOMAN'
  },
  {
    id: 3,
    name: 'その他',
    value: 'OTHER'
  }
]
BaseSelect.tsx
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コンポーネントを使用した画面のコード

はじめにの部分でも載せたコードをもう一度載せておきます。

index.tsx
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の書き方を少し変えるだけで簡単に実装できます。

クリックしてコードを表示できます
index.tsx
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

SignUpForm.tsx
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についてまとめていただいています。

0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?