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 FormProvideruseFormContext元件

  • 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 中介繫結元件

若 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

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

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