如何寫一個 MUI AutoComplete 搭配 React-hook-form 和 TypeScript 的共用元件

Published at 2024-11-21

cover_image

網路上關於MUI 元件和 React-hook-form 的使用範例有很多,但有時當表單有大量的客製化 AutoComplete 元件需要使用,就必須做一個共用元件。在製作共用元件時,會有一些需要解決的TypeScript 型別問題,根據需求不同也可能做一些 Props 傳遞上的調整。 基本的 React-hook-form 搭配 input 的使用方法這邊就不多贅述,官方文件已有詳細說明。

這篇因為型別很搞剛所以非常冗長,想看結論的話 這裡 有程式碼連結。

註:React-hook-form 以下簡稱RHF。

今天我們將會示範以下需求的 MUI AutoComplete 元件:

1.綁定 RHF 2.可輸入搜尋(TextField)

首先,暫時忽略型別的部分,一個綁定 RHF 的 MUI AutoComplete 雛形大概會長這樣:

1export default function CustomAutoComplete({ 2 control, name, label, options, 3}: Props) { 4 return ( 5 <Controller 6 name={name} 7 control={control} 8 render={({ field }) => ( 9 <Autocomplete 10 {...field} 11 options={options} 12 value={field.value} 13 onChange={(_, newValue) => { 14 field.onChange(newValue) 15 }} 16 renderInput={(params) => ( 17 <TextField 18 {...params} 19 label={label} 20 variant="outlined" 21 /> 22 )} 23 /> 24 )} 25 /> 26 ) 27}

Controller : RHF提供的一個 Wrapper,大部分基本的 input element(如 <input><select>)可以直接使用 register 方法來註冊。但當涉及到第三方 UI 元件庫時(如 MUI、Antd 等),這些元件通常包裝複雜且不支援直接使用 ref ,這使得 register 無法正常工作。有了 Controller,就像是表單和元件兩者之間的橋樑,讓你就算有層層包裹的 UI 元件,仍然能夠進行綁定和進行表單行為控制。

更多 Controller 的使用方法,在這裡可以找得到。

name : 和傳統 HTML input 的 name 屬性類似,都是做為表單元素的唯一識別名稱。若 name 和別的表單元件重複,可能會出現問題。

control : 搭配 useForm 做使用,可以當成用在更複雜元件的 register,幫助 RHF 註冊和綁定表單元件。

render : 用來渲染自定義元件。以函數形式呈現,提供參數 field, fieldState ,formState 做客製化使用。可根據需求傳遞需要的參數。(文件) 註:除了需要傳value 讓元件變成 Controlled 以外,還需要另外寫 onChange(範例如上),因為MUI AutoComplete 元件的第一個參數是 event,第二個參數才是 value。

舉例來說,我今天需要在表單有錯誤時顯示 error message 在元件下方,我就可以使用 fieldState.error.message 屬性:

1 <Controller 2 name={name} 3 control={control} 4 render={({ field, fieldState }) => ( 5 <> 6 <Autocomplete 7 {...field} 8 options={options} 9 renderInput={(params) => ( 10 <TextField 11 {...params} 12 variant="outlined" 13 /> 14 )} 15 /> 16 {fieldState.error && ( 17 <Typography color="error">{fieldState.error.message}</Typography> 18 )} 19 </> 20 )} 21 />

這樣在RHF檢查出驗證錯誤的時候,錯誤訊息就會隨著條件渲染。 在這個範例中,我們會需要傳進來的 Props 有以下:

給 RHF 做綁定的:

  • name
  • control

給 MUI AutoComplete 使用的:

  • options (提供使用者的選項)

這三個 Props,是做出這個元件的基本款,稍後我們可以再多做一些變化。 接著我們來看型別。

RHF 官方文件中,對 name 的型別定義如下:

1export type FieldPath<TFieldValues extends FieldValues> = Path<TFieldValues>

有點複雜,讓我們來仔細拆解。

什麼是 FieldPath?

  • 需要帶一個 generic type TFieldValues ,這個 type 會是你在使用 useForm 時所定義出的表單物件結構。
  • FieldValues 就只是一個定義物件的型別 Record<string,any> ,強調 TFieldValues 必須是物件型態。
  • Path<TFieldValues> : Path 是RHF自定義的一個 Recursive Type,它會解析並驗證物件的深層路徑。舉個例子:
1 2// 假如你的表單結構長這樣: 3type FormValues = { 4 username: string; 5 profile: { 6 firstName: string; 7 lastName: string; 8 }; 9}; 10 11 12const { control } = useForm<FormValues>() 13

那麼在這個狀況下,你的 name 可以是以下路徑:

  • username
  • profile.firstName
  • profile.lastName

假如你傳了不合規定的 name ,比如 profile.whatever ,TypeScript 就會因為型別不符合而報錯。 再白話一點,其實可以當作是這樣的:

1type Name<Form extends FieldValues> = Path<Form> 2// a more simple version: type Name<T> = Path<T>

OK,現在我們知道,name 的 type 實際上叫做 Path<T> ,這樣一來定義Props就簡單很多。

1// CustomAutoComplete.tsx 2 3import { 4 type FieldValues, type Path, 5} from 'react-hook-form' 6 7type Props<T extends FieldValues> = { 8 name: Path<T> 9}

藉由這個泛型參數T,我們就可以適配各種不同的表單結構。 接著我們也補上 control 的型別。這個比較簡單:

1import { 2 type Control, Controller, type FieldValues, type Path, 3} from 'react-hook-form' 4 5type Props<T extends FieldValues> = { 6 name: Path<T> 7 control: Control<T> 8}

現在我們已經完成 RHF 相關的 Props的型別定義了。 記得要在使用元件時也帶入 <T> ,否則型別檢查就會無法運作。

1type Props<T extends FieldValues> = { 2 name: Path<T> 3 control: Control<T> 4} 5 6export default function CustomAutoComplete<T extends FieldValues>({ 7 control, name, 8}: Props<T>) { 9 10// your component... 11

再來,我們進到 MUI 的環節。 optionsAutoComplete 元件的Props ,所以我們接下來要來定義 AutoComplete 元件 Props的型別,以便我們在使用元件時能直接傳入。

進到AutoComplete的型別檔,會看到這一大包:

1export default function Autocomplete< 2 Value, 3 Multiple extends boolean | undefined = false, 4 DisableClearable extends boolean | undefined = false, 5 FreeSolo extends boolean | undefined = false, 6 ChipComponent extends React.ElementType = ChipTypeMap['defaultComponent'], 7>( 8 props: AutocompleteProps<Value, Multiple, DisableClearable, FreeSolo, ChipComponent>, 9): JSX.Element;

你當然可以選擇直接複製這一堆,貼到你的元件上。 但其實有一個更簡便的做法。

Parameters Utility Type

TypeScript 提供的 Parameters<T>T 的型別必須符合 (...args: any) => any 作用是幫你拿出T函式中的參數,並做成一個tuple。 (文件) 如果你不是帶入函數型別,則會回傳 never

舉個例子,

1type MyFunction = (a: string, b: number) => boolean; 2type Params = Parameters<MyFunction>; // [string, number]

你也可以將它用在高階函式:

1 2function add(a: number, b: number): number { 3 return a + b; 4} 5 6function multiply(a: number, b: number): number { 7 return a * b; 8} 9 10function execute( 11 fn: (...args: Parameters<typeof add>) => number, 12 ...args: Parameters<typeof add> 13) { 14 return fn(...args); 15} 16 17console.log(execute(add,2,3)) // 5 18console.log(execute(multuiply,2,3)) //6 19

在我們的元件中,使用 Parameters 可以協助我們將 props 的型別取出,不用再複製一次所有的 generic type。

1type Props<T extends FieldValues> = { 2 name: Path<T> 3 control: Control<T> 4} & Parameters<typeof Autocomplete>[0] 5// [props: AutocompleteProps<unknown, boolean | undefined, boolean | undefined, boolean | undefined, ElementType<any>>] 6// 因為是tuple,所以需要用[0]把第一項取出來才是props的型別

但是 AutoComplete 仍然需要知道 generic 的值為何,所以我們仍然需要將參數帶進去。 在這邊我們有五個參數要傳:

  • Value(自定義)
  • Multiple (boolean)
  • DisableClearable (boolean)
  • FreeSolo (boolean)
  • ChipComponent (React.ElementType)
1 2// Step 1 : 帶進所有的參數,將 Value 設為generic 3 4type MuiAutoCompleteProps<K> = Parameters<typeof Autocomplete< 5K, boolean, boolean, boolean, React.ElementType>>[0] 6 7// Step 2: 由於我的元件的 Value 型態都會是固定的,我直接定義在元件中。 8 9type Option = { 10 label: string 11 value: string 12} 13 14type MuiAutoCompleteProps<K = Option> = Parameters<typeof Autocomplete< 15K, boolean, boolean, boolean, React.ElementType>>[0] 16 17// Step 3: 由於 `renderInput` 我是直接寫死在元件裡,這邊會需要先Omit掉,再merge進 Prop type中。否則會重複定義。 18 19type Option = { 20 label: string 21 value: string 22} 23 24type MuiAutoCompleteProps<K = Option> = Parameters<typeof Autocomplete< 25K, boolean, boolean, boolean, React.ElementType>>[0] 26 27type Props<T extends FieldValues> = { 28 name: Path<T> 29 control: Control<T> 30 label: React.ReactNode 31} & Omit<MuiAutoCompleteProps, 'renderInput'> 32

現在我們來看一眼元件長什麼樣子:

1 2import { Autocomplete, TextField, Typography } from '@mui/material' 3import { 4 type Control, Controller, type FieldValues, type Path, 5} from 'react-hook-form' 6 7type Option = { 8 label: string 9 value: string 10} 11 12type MuiAutoCompleteProps<K = Option> = Parameters<typeof Autocomplete< 13K, boolean, boolean, boolean, React.ElementType>>[0] 14 15type Props<T extends FieldValues> = { 16 name: Path<T> 17 control: Control<T> 18} & Omit<MuiAutoCompleteProps, 'renderInput'> 19 20export default function CustomAutoComplete<T extends FieldValues>({ 21 control, name, ...autocompleteProps 22}: Props<T>) { 23 return ( 24 <Controller 25 name={name} 26 control={control} 27 render={({ field, fieldState }) => ( 28 <> 29 <Autocomplete 30 {...field} 31 value={field.value} 32 onChange={(_, newValue) => { 33 field.onChange(newValue) 34 }} 35 renderInput={(params) => ( 36 <TextField 37 {...params} 38 variant="outlined" 39 /> 40 )} 41 {...autocompleteProps} 42 /> 43 {fieldState.error && ( 44 <Typography color="error">{fieldState.error.message}</Typography> 45 )} 46 </> 47 )} 48 /> 49 ) 50} 51 52

再加上一些客製化的 props ,這個元件就可以大功告成:

1 2import React from "react"; 3import { 4 Controller, 5 type Path, 6 type FieldValues, 7 type Control, 8 type UseControllerProps, 9} from "react-hook-form"; 10import { 11 type TextFieldProps, 12 Autocomplete as MuiAutoComplete, 13 Stack, 14 TextField, 15 Typography, 16} from "@mui/material"; 17 18type Option = { 19 label: string; 20 value: string 21}; 22 23type MuiAutoCompleteProps<K = Option> = Parameters< 24 typeof MuiAutoComplete<K, boolean, boolean, boolean, React.ElementType> 25>[0]; 26 27type Props<T extends FieldValues> = { 28 name: Path<T>; 29 control: Control<T>; 30 label?: string; 31 placeholder?: string; 32 controllerProps?: Omit<UseControllerProps<T>, "name" | "control">; 33 textFieldProps?: TextFieldProps; 34} & Omit<MuiAutoCompleteProps, "renderInput">; 35 36const AutoComplete = <T extends FieldValues>({ 37 name, 38 control, 39 options, 40 multiple = false, 41 label = "", 42 placeholder = "", 43 controllerProps = {}, 44 textFieldProps = {}, 45 ...autoCompleteProps 46}: Props<T>) => ( 47 <Controller 48 name={name} 49 control={control} 50 {...controllerProps} 51 render={({ field: { onChange, value }, fieldState: { error } }) => ( 52 <MuiAutoComplete 53 multiple={multiple} 54 disableCloseOnSelect={multiple} 55 filterSelectedOptions={multiple} 56 options={options} 57 value={value} 58 onChange={(_, newValue) => { 59 onChange(newValue); 60 }} 61 renderInput={(params) => ( 62 <TextField 63 {...params} 64 label={label} 65 placeholder={placeholder} 66 error={!!error} 67 helperText={error?.message} 68 {...textFieldProps} 69 /> 70 )} 71 renderOption={(props, option) => { 72 const { ...optionProps } = props; 73 return ( 74 <li {...optionProps} key={option.label}> 75 <Stack> 76 <Typography>{option.label}</Typography> 77 <Typography>{option.description}</Typography> 78 </Stack> 79 </li> 80 ); 81 }} 82 {...autoCompleteProps} 83 /> 84 )} 85 /> 86); 87 88export default AutoComplete; 89 90 91

此外,還有一些其他比較細部的設定(multiple , ChipProps 等等),可以在 這裡 看到。 光解決型別問題就花了我快一天,希望這篇文章可以幫助到正在寫共用元件的人!