React Hook Form 7系と MUI 5系を組み合わせたフォームを作ってみた

React Hook Form 7系とMUI 5系を組み合わせてフォームを作る時に、色々悩んだことがあったのでメモを残します。

 
目次

 

環境

  • React 18.1.0
  • React Hook Form 7.31.3
  • MUI 5.8.2
  • React Router 6.3.0
  • Vite 2.9.9

 

controlled component と uncontrolled component について

Reactでフォームを作るときにはコンポーネントcontrolleduncontrolled のどちらであるかを意識します。

そこで、まずは

  • React
  • MUI
  • React Hook Form

の各ライブラリにおいて、それらをどう扱っているかを見ていきます。

 

Reactのフォームでは

ざっくり書くと

  • controlled component
    • Reactのstateでデータを管理
  • uncontrolled component
    • DOMでデータを管理

となります。

 
Reactの公式ドキュメントでは、controlled component については

HTML では <input><textarea> 、そして <select> のようなフォーム要素は通常、自身で状態を保持しており、ユーザの入力に基づいてそれを更新します。React では、変更されうる状態は通常はコンポーネントの state プロパティに保持され、setState() 関数でのみ更新されます。

React の state を “信頼できる唯一の情報源 (single source of truth)” とすることで、上述の 2 つの状態を結合させることができます。そうすることで、フォームをレンダーしている React コンポーネントが、後続するユーザ入力でフォームで起きることも制御できるようになります。このような方法で React によって値が制御される入力フォーム要素は「制御されたコンポーネント」と呼ばれます。

フォーム要素の value 属性が設定されているので、表示される値は常に this.state.value となり、React の state が信頼できる情報源となります。handleChange はキーストロークごとに実行されて React の state を更新するので、表示される値はユーザがタイプするたびに更新されます。

制御されたコンポーネントを使うと、ユーザ入力の値は常に React の state によって制御されるようになります。これによりタイプするコード量は少し増えますが、その値を他の UI 要素に渡したり、他のイベントハンドラからリセットしたりできるようになります。

https://ja.reactjs.org/docs/forms.html#controlled-components

とあります。

 
一方、 uncontrolled component については

非制御コンポーネント (uncontrolled component) はその代替となるものであり、フォームデータを DOM 自身が扱います。

非制御コンポーネントを記述するには、各 state の更新に対してイベントハンドラを書く代わりに、ref を使用して DOM からフォームの値を取得します。

https://ja.reactjs.org/docs/uncontrolled-components.html

とあります。

 
両者の違いについてはWeb上にいくつも情報があるため、ここでは目についたもののリンクだけにとどめます。

 

MUIでは

MUIでは、controlled component な使い方、uncontrolled component な使い方、どちらでもできるようです。

例えば、 TextField コンポーネントについては以下に記載があります。
Uncontrolled vs. Controlled | Text field React component - Material UI

 

React Hook Formでは

React Hook Formでは、基本的には uncontrolled component を扱うようです。

ただ、React Hook Formにはラッパーコンポーネントがあることから、ラッパーコンポーネントを使えば MUI などの controlled component も簡単に扱えるようです。

React Hook Form embraces uncontrolled components and native inputs, however it's hard to avoid working with external controlled component such as React-Select, AntD and Material-UI. This wrapper component will make it easier for you to work with them.

https://react-hook-form.com/api/usecontroller/controller

 
そこで今回は、React Hook Form のラッパーコンポーネント Controller を使って、React Hook Form 7系と MUI 5系を組み合わせたフォームを作ってみることにします。

 

実装

今回はMUIの

  • TextField
  • Radio
  • Select (TextField select)
  • Check

の各コンポーネントを使って実装してみます。

 

フォームを作る

まずはフォームの全体を作ります。

useForm を使い、 control を取得します。これはReact Hook Formの Controller コンポーネントに渡します。

 
また、入力したフォームの値を確認するため

  • handleSubmit
    • submitボタンが押された時をハンドリングするもの
  • getValues
    • フォームの値を取得するもの

も取得します。

 
あとは、入力した値を保持する時の型として

export type FormInput = {
  name: string  // TextField用
  color: string  // Radio用
  shop: string  // TextField select用
  inStock: boolean // Check用
}

も用意します。

 
フォームの全体像は以下となります。

なお、TextFieldなどを使った各コンポーネントについては、今後実装していきます。

export type FormInput = {
  name: string  // TextField用
  color: string  // Radio用
  shop: string  // TextField select用
  inStock: boolean // Check用
}

const Component = (): JSX.Element => {
  const {control, handleSubmit, getValues} = useForm<FormInput>()

  const printWithData = (data: UnpackNestedValue<FormInput>) => {
    console.log('data による取り出し =========>')

    const {name, color, shop, inStock} = data
    console.log(`name: ${name}`)
    console.log(`color: ${color}`)
    console.log(`shop: ${shop}`)
    console.log(`inStock: ${inStock}`)
  }

  const printByGetValues = () => {
    console.log('getValues() による取り出し =========>')

    const {name, color, shop, inStock} = getValues()
    console.log(`name: ${name}`)
    console.log(`color: ${color}`)
    console.log(`shop: ${shop}`)
    console.log(`inStock: ${inStock}`)
  }

  const onSubmit: SubmitHandler<FormInput> = (data) => {
    // dataオブジェクトを使った取り出し
    printWithData(data)

    // getValues を使った取り出し
    printByGetValues()
  }

  const colors: RadioItem[] = [
    { label: '赤', value: 'red' },
    { label: '黄', value: 'yellow' }
  ]

  const shops: SelectItem[] = [
    { label: 'スーパー', value: 'supermarket' },
    { label: '産直所', value: 'farmersMarket' },
  ]

  return (
    <>
      <Box sx={{ mt: 5 }}>
        <h1>データ登録</h1>
        <form onSubmit={handleSubmit(onSubmit)}>
          <Box sx={{ mt: 5 }}>
            <FruitNameField control={control} />
          </Box>

          <Box sx={{ mt: 5 }}>
            <ColorRadioGroup control={control} items={colors} />
          </Box>

          <Box sx={{ mt: 5 }}>
            <ShopSelect control={control} items={shops} />
          </Box>

          <Box sx={{ mt: 5 }}>
            <InStockCheckBox control={control} />
          </Box>

          <Box sx={{ mt: 5 }}>
            <Button type="submit" variant="contained">
              保存
            </Button>
          </Box>
        </form>
      </Box>
    </>
  )
}

export default Component

 

MUIのTextFieldと組み合わせる

先程見た通り、MUIの各コンポーネントと組み合わせるには React Hook Form の Controller コンポーネントを使えば良さそうです。

 
まずは TextField との組み合わせから実装していきます。

MUIと組み合わせる時に定義が必要な Controller コンポーネントprops

  • name
    • フォームの入力項目を識別するのに利用
  • control
    • useForm で取得した control を割り当てる
  • render
  • defaultValue
    • 入力値のデフォルト値

です。

 
なお、 defaultValue を指定しない場合、TextFieldに何かを入力したタイミングで

Warning: A component is changing an uncontrolled input to be controlled. This is likely caused by the value changing from undefined to a defined value, which should not happen. Decide between using a controlled or uncontrolled input element for the lifetime of the component. More info: https://reactjs.org/link/controlled-components

というWarningがブラウザのコンソールに出力されてしまいます。

 
また、入力値に対するバリデーションが必要な場合には、使用する各コンポーネントにて

により、エラーメッセージを画面へ表示できます。

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

type Props = {
  control: Control<FormInput>
}

const Component = ({control}: Props): JSX.Element => {
  return (
    <>
      <Controller
        control={control}
        name="name"
        defaultValue={""}
        rules={{required: {value: true, message: '入力必須です'}}}
        render={({field, fieldState: {error}}) =>
          (
            <TextField
              {...field}
              label="品種名"
              error={!!error?.message}
              helperText={error?.message}
            />
          )}
      />
    </>
  )
}

export default Component

 

Radio と組み合わせる

次に、MUIのRadioと組み合わせてみます。
Radio buttons React component - Material UI

TextFieldとほとんど変わりませんが、MUI単体で使った時にあった「 RadioGroupvaluedefaultValue を定義する」は不要です。

 
ちなみに、RadioGroupやRadioには error がないため、Controllerの rules によるバリデーションエラーになったとしても、そのままではエラーメッセージを表示できません。

 
そこで、MUIの FormHelperText コンポーネントを使うことで、バリデーションエラーの時にエラーメッセージを表示できるようにします。
FormHelperText API - Material UI

 
全体像は以下の通りです。

export type RadioItem = {
  label: string
  value: string
}

type Props = {
  control: Control<FormInput>
  items: RadioItem[]
}

const Component = ({control, items}: Props): JSX.Element => {
  return (
    <>
      <FormControl>
        <FormLabel></FormLabel>
        <Controller name="color"
                    defaultValue={''}
                    control={control}
                    rules={{required: {value: true, message: '色は選択必須です'}}}
                    render={({field, fieldState: {error}}) => (
                      <>
                        <RadioGroup>
                          {items.map((radio: RadioItem) =>
                            (
                              <FormControlLabel {...field}
                                                key={radio.value}
                                                label={radio.label}
                                                value={radio.value}
                                                control={<Radio />}
                              />
                            )
                          )}
                        </RadioGroup>
                        <FormHelperText error={!!error?.message}>
                          {error?.message}
                        </FormHelperText>
                      </>
                    )}
        />
      </FormControl>
    </>
  )
}

export default Component

 

Select (TextField select) と組み合わせる

フォームのselectを作る場合、MUIでは

のどちらかのコンポーネントを使います。

今回は TextField コンポーネントと見た目を揃えるため、後者で実装します。

 
また、selectの選択肢としてMUIの MenuItem コンポーネントを使います。 MenuItem API - Material UI

 
それ以外は今までの実装と同じです。

export type SelectItem = {
  label: string
  value: string
}

type Props = {
  control: Control<FormInput>
  items: SelectItem[]
}

const Component = ({control, items}: Props): JSX.Element => {
  return (
    <>
      <Controller control={control}
                  name="shop"
                  defaultValue=""
                  rules={{required: {value: true, message: '店舗は選択必須です'}}}
                  render={({field, fieldState: {error}}) => (
                    <>
                      <TextField {...field}
                                 select
                                 sx={{width: 200}}
                                 label="店舗"
                                 error={!!error?.message}
                                 helperText={error?.message}>
                        {items.map((item) => (
                          <MenuItem key={item.value} value={item.value}>
                            {item.label}
                          </MenuItem>
                        ))}
                      </TextField>
                    </>
                  )}
        />
    </>
  )
}

export default Component

 

Checkbox と組み合わせる

Radioのときと同様な実装になります。

また、 Checkbox コンポーネントerror propsがないため、 FormHelperText を使います。
Checkbox API - Material UI

type Props = {
  control: Control<FormInput>
}

const Component = ({control}: Props): JSX.Element => {
  return (
    <>
      <FormControl>
        <Controller name="inStock"
                    defaultValue={false}
                    control={control}
                    rules={{required: {value: true, message: 'チェックを入れてください'}}}
                    render={({field, fieldState: {error}}) => (
                      <>
                        <FormControlLabel {...field}
                          label="在庫あり"
                          control={<Checkbox />}
                        />
                        <FormHelperText error={!!error?.message}>
                          {error?.message}
                        </FormHelperText>
                      </>
                    )}
        />
      </FormControl>
    </>
  )
}

export default Component

 

動作確認

yarn run dev してReactアプリを起動し、React Routerで定義したURL http://localhost:3000/react-hook-form/mui/single-form にアクセスし、動作確認をします。

 
まず、何も入力しないで 保存 ボタンをクリックすると、各入力項目でエラーが表示されます。バリデーションができているようです。

 
次に、各項目に入力して 保存 ボタンをクリックすると、コンソールに入力値(あるいは、valueで指定した値) が出力されます。

onSubmit 関数に渡されてきた値と getValues() のどちらでも同じ値が取得できています。

 

ソースコード

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

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