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でフォームを作るときにはコンポーネントが controlled
と uncontrolled
のどちらであるかを意識します。
そこで、まずは
- 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 からフォームの値を取得します。
とあります。
両者の違いについてはWeb上にいくつも情報があるため、ここでは目についたもののリンクだけにとどめます。
- reactjs - What are React controlled components and uncontrolled components? - Stack Overflow
- Controlled vs. uncontrolled components in React - LogRocket Blog
- What are Controlled and Uncontrolled Components in React JS? Which one to use? React JS Learning Series #1 | by Partha Roy | Fasal Engineering | Medium
- 【React】非制御コンポーネントはいつ使うのか
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.
そこで今回は、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
- この中で MUI のコンポーネントを指定
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がブラウザのコンソールに出力されてしまいます。
また、入力値に対するバリデーションが必要な場合には、使用する各コンポーネントにて
- Controllerコンポーネント
rules
を定義するrender
時にfieldState: {error}
も受け取るようにする- React Hook Formドキュメントでは、
Controller
のReturn
欄にてfieldState
で受け取れる内容などの記載あり
- React Hook Formドキュメントでは、
- TextFieldコンポーネントに
error
とhelperText
を定義する
により、エラーメッセージを画面へ表示できます。
全体像はこんな感じです。
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単体で使った時にあった「 RadioGroup
の value
や defaultValue
を定義する」は不要です。
ちなみに、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では
Select
コンポーネント https://mui.com/material-ui/react-select/TextFiled
コンポーネントのselect
props を指定
のどちらかのコンポーネントを使います。
今回は 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