Using Material UI with React Hook Form
@mui, Material UI v5.16, react-hook-form v7.53
引言
整合 React.v18 + Material UI v5 + React Hook Form v7。
導入 Material UI 實作高 UX 的使用者介面。
導入 react-hook-form 使實作表單的 validation 更簡便。
參考文章
這篇比較簡單,說明 material 與 react-hook-form 如作整合。
實作時的參考範例。看看別人如何整合的。
關鍵知識
各套件的基本知識自行參拜 google 大神。
不管 Material UI 還是 react-hook-form 都已以高階易用的套件了。在實務上建議自行整合它們會比較好。
當然也可以找到第三方整合 Material UI 與 React Hook Fome 的套件,只是應用到自己的專案時總是有 gutter 存在,這會使得程式碼變很肥、效率很差。細看這第三方整合它們的碼其實不難。
實務上。建議自己整合把這此 gutter 消除。
react 開發知識就不再重述了。
主要在 Material UI 與 React Hook Form 整合的語法。
整合關鍵元件
§ useForm
與 FormProvider
、useFormContext
元件
useForm
與 FormProvider
、useFormContext
元件useForm
建構 react-hook-form。FromProvider
把 formContext 分享到子孫層元件。useFormContext
使與表單繫結。
const {
control, //※擊結 react-hoo-form 與高階輸入元件。
handleSubmit, //※送出表單
reset, //※重置表單
getValues, //※取得表單全部欄位或單一欄位的值。
setValue, //※手動設定某欄位新值。
trigger, //※觸發某欄位 validatiaon 檢查並反應到UI。
formState, //※現在表單狀態
watch, //※監看某欄位即時變更。
register, //直接註冊底層元件 input。但無法註冊高階輸入元件:如 Autocomplete 等等等。
unregister, //反註冊 input 元件
resetField,
setFocus,
getFieldState,
setError,
clearErrors,
} = useForm();
§ Controller
中介繫結元件
Controller
中介繫結元件若 react-hook-form 與底層 input 繫結用 regiseter
指令即可。
若要與高階元件繫結需透過 Controller
元件進行中介。
<Controller
control={control}
name="fieldName"
render={({
field: { onChange, onBlur, value, name, ref },
fieldState: { invalid, isTouched, isDirty, error },
formState,
}) => ( WHATEVER_INPUT_WE_WANT )}
/>
自 Controller 元件拿到的資源
field
: 表單的這個欄位。filedState
: 這個欄位的編輯狀態。formState
: 此表單的狀態

程式紀錄
ATextField.tsx
import React, { useContext, useMemo } from "react"
import { Grid, TextField, TextFieldPropsSizeOverrides, TextFieldVariants } from "@mui/material"
import { OverridableStringUnion } from '@mui/types';
import { Controller, ControllerRenderProps, FieldValues, RegisterOptions, useFormContext } from "react-hook-form"
import { FormRowContext, FormRowFieldSize } from "./FormRow"
//rules:
// required: string
// min: number
// max: number
// minLength: number
// maxLength: number
// pattern: RegEx: /^[A-Za-z]+$/i
// validate: ()=>boolean
export function ATextField(props: {
name: string,
type?: string,
label?: string,
required?: boolean,
readOnly?: boolean,
placeholder?: string,
helperText?: string,
size?: OverridableStringUnion<'small' | 'medium', TextFieldPropsSizeOverrides>,
grid?: FormRowFieldSize
//rules?: RegisterOptions<FieldValues, string>,
minLength?: [value: number, message: string],
maxLength?: [value: number, message: string],
pattern?: [value: RegExp, message: string]
min?: [value: number, message: string],
max?: [value: number, message: string],
validate?: (value: string, formValues: FieldValues) => string | boolean,
// notify change
notifyChange?: (newValue: string) => void // 通告輸入值有變更
}) {
const {
control,
trigger,
} = useFormContext() // retrieve all hook methods
const formRow = useContext(FormRowContext)
const rules = useMemo(() => {
const rules: RegisterOptions<FieldValues, string> = {}
if (props.required)
rules.required = `${props.label} 為必填欄位`
if (props.minLength)
rules.minLength = {
value: props.minLength[0],
message: `${props.label} ${props.minLength[1]}`
}
if (props.maxLength)
rules.maxLength = {
value: props.maxLength[0],
message: `${props.label} ${props.maxLength[1]}`
}
if (props.pattern)
rules.pattern = {
value: props.pattern[0],
message: `${props.label} ${props.pattern[1]}`
}
if (props.min)
rules.min = {
value: props.min[0],
message: `${props.label} ${props.min[1]}`
}
if (props.max)
rules.max = {
value: props.max[0],
message: `${props.label} ${props.max[1]}`
}
if (props.validate)
rules.validate = props.validate
return rules
}, [props.label, props.max, props.maxLength, props.min, props.minLength, props.pattern, props.required, props.validate])
// used to render
const _readOnly = useMemo(() => props.readOnly ?? formRow?.readOnly, [props.readOnly, formRow])
const _size = useMemo(() => props.size ?? formRow?.size, [props.size, formRow])
const fieldElement = (
<Controller
control={control}
name={props.name}
rules={rules}
render={({ field, fieldState }) => (
<TextField
{...field}
label={props.label}
type={props.type}
required={props.required}
placeholder={props.placeholder}
variant={_readOnly ? 'filled' : 'standard'}
size={_size}
error={fieldState.invalid}
helperText={fieldState.error?.message || props.helperText}
fullWidth={Boolean(formRow)} /* 有 FormRow 就填滿格子 */
onChange={e => {
field.onChange(e);
Boolean(props.notifyChange) && props.notifyChange(e.target.value)
}}
onBlur={() => {
trigger(props.name) // 將觸發 validation 並更新編輯狀態。
field.onBlur()
}}
InputProps={{
readOnly: _readOnly,
}}
/>
)}
/>
);
if (Boolean(formRow)) {
const [xs, sm, md, lg, xl] = useMemo(() => props.grid ?? formRow.grid, [props.grid, formRow.grid])
return (
<Grid item xs={xs} sm={sm} md={md} lg={lg} xl={xl}>
{fieldElement}
</Grid>
)
}
return (<>{fieldElement}</>)
}
AComboField.tsx
import React, { useCallback, useContext, useEffect, useLayoutEffect, useMemo, useState } from 'react'
import { Autocomplete, AutocompleteChangeDetails, AutocompleteChangeReason, CircularProgress, FilterOptionsState, Grid, TextField, TextFieldPropsSizeOverrides } from '@mui/material'
import { OverridableStringUnion } from '@mui/types';
import { Controller, ControllerRenderProps, FieldValues, RegisterOptions, useFormContext } from 'react-hook-form'
import { FormRowContext, FormRowFieldSize } from './FormRow'
export function AComboField<TItem extends ICodeName>(props: {
name: string,
codeRepo: CodeNameRepo<TItem>,
label?: string,
groupFor?: string, // 之前連動(分群)欄位
chainFor?: string, // 之後連動欄位
required?: boolean,
readOnly?: boolean,
placeholder?: string,
helperText?: string,
size?: OverridableStringUnion<'small' | 'medium', TextFieldPropsSizeOverrides>,
grid?: FormRowFieldSize
//rules?: RegisterOptions<FieldValues, string>,
validate?: (value: string, formValues: FieldValues) => string | boolean,
// notify change
notifyChange?: (newValue: string) => void // 通告輸入值有變更
}) {
const [codeList, f_loading, error] = props.codeRepo
const {
control,
setValue,
getValues,
watch,
trigger,
} = useFormContext() // retrieve all hook methods
const formRow = useContext(FormRowContext)
const rules = useMemo(() => {
const rules: RegisterOptions<FieldValues, string> = {}
if (props.required)
rules.required = `${props.label} 為必填欄位`
if (props.validate)
rules.validate = props.validate
return rules
}, [props.label, props.required, props.validate]);
// 內層元件真正的值 & 初值填入
const [innerValue, setInnerValue] = useState<TItem | null>(null)
// codeRepo 載入完成後刷新
useEffect(() => {
//console.log('codeList →', codeList?.length)
const curValue = getValues(props.name)
const valueItem = codeList.find(v => v.code === curValue) ?? null // undefined → null
setInnerValue(valueItem)
}, [codeList])
//※ 當值已改變 => 反應到 UI
const thisFieldValue = watch(props.name)
useLayoutEffect(() => {
//console.log('thisFieldValue →', thisFieldValue)
const valueItem = codeList.find(v => v.code === thisFieldValue) ?? null // undefined → null
setInnerValue(valueItem)
}, [thisFieldValue])
// used to render
const _readOnly = useMemo(() => props.readOnly ?? formRow?.readOnly, [props.readOnly, formRow])
const _size = useMemo(() => props.size ?? formRow?.size, [props.size, formRow])
const fieldElement = (
<Controller
control={control}
name={props.name}
rules={rules}
render={({ field, fieldState }) => {
const onChangeHandler = useCallback((_event: React.SyntheticEvent, value: TItem, _reason: AutocompleteChangeReason, _details: AutocompleteChangeDetails<TItem>) => {
field.onChange(value?.code) // 將觸發:當值已改變 => 反應到 UI
Boolean(props.notifyChange) && props.notifyChange(value?.code)
// 當值變了則清除之後連動欄位,讓使用者重選。
if (Boolean(props.chainFor)) {
setValue(props.chainFor, null, { shouldDirty: true, shouldValidate: true }) // 將觸發之後連動欄位:當值已改變 => 反應到 UI
}
}, [field.onChange, props.notifyChange])
return (
<Autocomplete {...field}
options={codeList}
getOptionLabel={(option) => `${option?.code}.${option?.name}`}
loading={f_loading}
size={_size}
value={innerValue}
readOnly={_readOnly}
filterOptions={filterOptionsHandler}
onChange={onChangeHandler}
onBlur={() => handleBlur(field)}
renderInput={(params) => (
<TextField {...params}
label={props.label}
required={props.required}
placeholder={props.placeholder}
variant={_readOnly ? 'filled' : 'standard'}
error={fieldState.invalid}
helperText={fieldState.error?.message || props.helperText}
InputProps={{
...params.InputProps,
readOnly: _readOnly,
endAdornment: (
<React.Fragment>
{f_loading ? <CircularProgress color="info" size={20} /> : null}
{params.InputProps.endAdornment}
</React.Fragment>
)
}}
/>
)}
/>
)
}}
/>
)
if (Boolean(formRow)) {
const [xs, sm, md, lg, xl] = useMemo(() => props.grid ?? formRow.grid, [props.grid, formRow.grid])
return (
<Grid item xs={xs} sm={sm} md={md} lg={lg} xl={xl}>
{fieldElement}
</Grid>
)
}
return (<>{fieldElement}</>)
//----------------
function handleBlur(field: ControllerRenderProps<FieldValues, string>) {
trigger(props.name) // 將觸發 validation 並更新編輯狀態。
field.onBlur()
}
function filterOptionsHandler(options: TItem[], state: FilterOptionsState<TItem>) {
// 依之前連動(分群)欄位過濾選項
if (Boolean(props.groupFor)) {
const groupValue = getValues(props.groupFor)
return options.filter(item => item.group === groupValue)
} else {
return options
}
}
}
(先這樣)
Last updated