跳至主要内容

[note] React Hook From

react-hook-form @ official website

警告

react-hook-form 在不同版本 API 的使用上可能會略有差異,即使只是次版號的升級也要留意。

Nested Object Fields

Nested Object Fields with react-hook-form @ pjchender gist

react-hook-form 有支援使用 nested object fields,只需要使用 .(dot syntax)就可以了。有幾點需要留意:

  • 在 react-hook-form devTools 的 v3.0.0 中,nested object field 的 TouchedDirty 欄位在 devtools 中有問題,不會正確顯示,但實際上的 form 是有作用的。
  • 如果有搭配 Yup 使用,記得 validation 時,也要把改 object 包在對應的 object field 中。

API

useForm

shouldUnregister

shouldUnregister 預設是 false,即使 input 被移除,該 input value 仍然會保留在 react hook form 中

  • 沒有 render 在 browser 上的欄位其值仍然保留在 react-hook-form 中
  • 沒有 render 在 browser 上的欄位不會進行表單驗證
  • default Value 在 submission 時會 merge 到送出的表單中

如果 shouldUnregister: true 的話,會更貼近瀏覽器原本表單的行為

  • 欄位的值是紀錄在 input 本身上,所以如果該 input 沒有被 render,則該欄位的值就不會被紀錄在 rhf 中
  • 只有有被 register 的 input 的資料,才會出現在送出的資料中

Controller & useController

react-hook-form 傾向使用 uncontrolled components 和瀏覽器原生的表單,但不可避免的有時會需要使用到 controlled components(例如搭配 material UI 或 Antd),因為這些 component 的 input 太深而無法直接使用 register,這時候就可以使用 <Controller /> 這個 wrapper。

寫 component 套用到 react-hook-form 中

  • onBlur() 會影響到 isTouchedisDirty 的值
  • onChange() 會影響到 value
const YesNoQuestion = ({ selected, onChange, onBlur, title, error }) => {
const handleClick = useCallback(
(value) => () => {
onChange(value);
onBlur(value);
},
[onBlur, onChange],
);

return (
<YesNoQuestionWrapper>
<div className="error">{error}</div>
<div className="title">{title}</div>
<button className={`btn ${selected === false && 'selected'}`} onClick={handleClick(false)}>

</button>
<button className={`btn ${selected === true && 'selected'}`} onClick={handleClick(true)}>

</button>
</YesNoQuestionWrapper>
);
};

FormProvider & useFromContext

useFormContext API @ react-hook-form

如果表單內的元件很深,又希望不要透過 props 一直將 react-hook-form 的方法透過 props 傳遞到該元件內時,可以在最外層使用 <FormProvider />,如此在該 Provider 內的子層元件,即可使用 useFormContext 的方式取得 react-hook-form 提供的各種方法。

useFieldArray 搭配 Controller 使用

  • DOM Element 上使用的 key 是用 useFieldArray 提供的 id(預設),例如,<li key={item.id}>
  • input field 的 name 使用 index,例如 name={`drivers.${index}.firstName` as const}
  • remove 的時候使用 index,例如 onClick={() => remove(index)},沒有帶 index 的話會全部清掉
  • append 的時候,一定要帶入完成 defaultValues 的解構,不能不帶或只帶空物件
const defaultValues = {
drivers: [
{
firstName: 'Aaron',
},
],
};

export default function App() {
const { handleSubmit, control, formState } = useForm<IDrivers>({
mode: 'onBlur',
resolver: yupResolver(validationSchema),
defaultValues,
});

const { fields, append, prepend, remove, swap, move, insert } = useFieldArray({
control,
name: 'drivers', // 這個 fieldArray 的 unique name
});

const onSubmit: SubmitHandler<IDrivers> = (data) => console.log(data.drivers);

return (
<div className="App">
<form onSubmit={handleSubmit(onSubmit)}>
<button
onClick={() => {
// 一定要帶入完成 defaultValue 的結構,不能不帶或只帶空物件
append({ firstName: 'foo' });
}}
>
Add
</button>

<ul>
{fields.map((item, index) => (
// 這裡要用的是 item.id 不是 index
<li key={item.id}>
<button onClick={() => remove(index)}>Delete</button>
<Controller
name={`drivers.${index}.firstName` as const}
control={control}
// 舊版的 rhf 需要加 defaultValue
// defaultValue={item.firstName}
render={({ field: { onChange, onBlur, value, ref } }) => (
<input
onBlur={onBlur}
onChange={onChange}
value={value}
type="text"
placeholder="username"
ref={ref}
/>
)}
/>
</li>
))}
</ul>

<input type="submit" />
</form>
</div>
);
}

一樣可以搭配 Yup 來做表單驗證:

const validationSchema = yup.object().shape({
drivers: yup
.array()
.of(
yup.object().shape({
firstName: yup.string().required('Required'),
}),
)
.required('必填')
.min(3, '至少要有三個'),
});

TypeScript

register

:::參考資料

:::

example code (v7)
import { InputHTMLAttributes } from 'react';
import { FieldValues, Path, UseFormRegister } from 'react-hook-form';

interface InputProps<T extends FieldValues> extends InputHTMLAttributes<HTMLInputElement> {
label: Path<T>;
register: UseFormRegister<T>;
}

export default function Input<T extends FieldValues>({ label, register, ...props }: InputProps<T>) {
return <input className="w-80 rounded border px-3 py-1" {...register(label)} {...props} />;
}

常見問題

A component is changing an uncontrolled input to be controlled

A component is changing an uncontrolled input to be controlled.

之所以會看到這個錯誤,一般是因為在 value 中使用了 undefined<input value={undefined} />

React 會把 value={undefined} 的 input 視為 uncontrolled 的;如果 value 有值(不是 undefined)的話,表示這是 controlled input。

因此,如果一開始 render 時在 value 給的是 undefined,後來卻又給值,這個 input 就會從 uncontrolled 變成 controlled。

舉例來說:

  • useForm 中一開始給的 valueundefined
  • 當使用者輸入內容時,這個 value 會有值

這時候就會出現上述的錯誤

import { Controller, useForm } from 'react-hook-form';

type FormData = {
name: string;
};

export default function Form() {
const { handleSubmit, control } = useForm<FormData>({
defaultValues: {
name: undefined,
},
});

return (
<form onSubmit={handleSubmit((data) => console.log(data))}>
<Controller
name="name"
control={control}
render={({ field }) => <input type="string" {...field} />}
/>

<input type="submit" />
</form>
);
}

那預設值可以時 null 嗎?答案是不行的。 value prop on input should not be null

input value

inputvalue 不能是 null,如果是 Controlled Input 的話,則 value 也不能是 undefined。一旦使用了 undefined,該 input 就會被視為 Uncontrolled Input。

根據建議,如果一開始表單是沒有資料的話,預設值可以使用空字串(empty string)。因此,我們可以把上面的範例改成:

// ...

export default function Form() {
const { handleSubmit, control } = useForm<FormData>({
defaultValues: {
name: '',
},
});

return (/* ... */);
}

處理 Input Number 的方式

範例程式碼 @ CodeSandbox

input[type="number"] 是一個很常用到,但是卻有點麻煩的東西。

問題一:實際上 input 的 value 是 string

之所以麻煩,是因為我們預期 input[type="number"] 回傳的結果應該是「數值」,但實際上它值(e.target.value)卻是「字串」。這會使得和我們對 React Hook Form 中對資料型別定義的不一致:

value is string

舉例來說,weight 的資料從後端來時應該是 number,然而,一旦使用者輸入內容後,它就會變成「字串」:

input-number

信息

之所以在使用者輸入資料後會變成字串,是因為該 inputvalue 實際上就是字串,所以當 input onChange 時,react-hook-form 會直接把這個值放到 form state 中,導致實際的資料型別和預期的不同。

type FormData = {
// weight 的型別預期是 number,但是當使用者一輸入後,就變成 string
weight: number;
name: string;
};

export default function Form() {
const { handleSubmit } = useForm<FormData>({
defaultValues: {
name: '',
weight: 50,
},
});
// ...
}

問題二:預設值該怎麼給?如何區分沒填或 0

我們經常會希望能區分這個資料是使用者沒填或是 0,一般來說沒填的話,我們很習慣用 undefinednull。然而,在使用者沒有填寫的情況下,我們預設值並不能給 undefinednull,前者會出現前面提到的「A component is changing an uncontrolled input to be controlled」,後者則不被允許。

這時候我們能夠使用的,就只剩下空字串 ''。之所以不用 0 當預設值,是因為這樣畫面上就會有 0,而且 0 並不代表「沒有」。

input[type="number"] 的值會是字串

即使是 input[type="number"],瀏覽器預設從此 input 取得的 e.target.value 會是「字串」而不是「數值」。

當使用者清空資料時會時空字串

當使用者在 input[type="number"] 的欄位使用刪除鍵把所有資料清空時,e.target.value 會是空字串。

解決方式:在 onChange 轉換成對應的型別

目前看起來,比較好的做法或許是:

  • 把資料的型別定成 number | ''
  • 沒有資料的時候,預設值給空字串 ''
  • onChange 時將資料透過 Number()+ 做轉型,從字串轉回數值

如此,將可以確保資料在 react hook form 中和所定義的型別是一致的。

import { Controller, useForm } from 'react-hook-form';

type FormData = {
name: string;
weight: number | '';
};

export default function Form() {
const {
handleSubmit,
control,
formState: { errors },
} = useForm<FormData>({
defaultValues: {
name: '',
// 沒有資料的時候給空字串
weight: '',
},
});

return (
<form onSubmit={handleSubmit((data) => console.log({ data }))}>
{/* ... */}

<Controller
name="weight"
control={control}
render={({ field: { onChange, ...field } }) => (
<input
type="number"
{...field}
onChange={(e) => {
// e.target.value 會是 string,所以 "0" 也會被轉成 Number
onChange(e.target.value ? Number(e.target.value) : '');
}}
/>
)}
/>

<input type="submit" />
</form>
);
}

這麼做還有留意,https://codesandbox.io/s/input-value-in-rhf-cq1ho5?file=/src/App.tsx,需要根據 API 的設計,看是要補成其他的預設數值,或是使用傳給後端 null 還是 undefined

nullundefined 在 JSON.stringify 後不同

在 JSON.stringify 後,null 的欄位在會存在,其值就會是 null;當 undefined 的欄位會從 JSON object 中消失。

留意使用 valueAsNumber

雖然瀏覽器本身有提供 e.target.valueAsNumber,能夠取得「數值」型別的資料,但是如果使用者把資料清空的話,這個值會變成 NaN

同時,NaNJSON.stringify 會變成 null;在 TypeScript 中 NaN 也是 number 的一種,聽起來挺方便的,這樣型別就可以定義成 number 就好,不用加上 ""

讓人,當 value 變成 NaN 是,React 會跳出警告,建議轉成字串:

value is NaN

隨意,如果要使用 e.target.valueAsNumber 來取得 input[type="number"] 的 value 時,還是要留意。

仍須優化

按照上面的做法就可以處理基本的數值資料,當如果想要做到更好的話,在 onChange 時可能還需要直接用一下 formatter 過濾,不然使用者輸入一些「特殊情況」。

例如使用者時可以輸入 00001

input number

雖然 Number(00001) 會是 1,所以保存的資料也會是 1,但就要看 UI/UX 會不會介不介意使用者做出這種操作。

搭配 yup 的 when 根據欄位的值進行動態的規則

keywords: dynamic validation schema with yup, react-hook-form

react-hook-form 本來就能搭配 Yup 進行表單驗證,但有些時候會需要進行動態的驗證規則,例如,在 A 欄位選了「ooo」才要驗證 B 欄位的「xxx」,具體來說,可能是在「關係」欄位選了其他之後,會多「基本資料」的欄位要使用者填寫,而且要在針對這個多出來的「基本資料」欄位進行表單驗證,這時候使用 yup 提供的 when 方法就非常好用。

有幾個需要留意的地方:

  • is 後面可以接 function 或直接帶 value
const validationSchema = yup.object().shape({
relationship: yup.string().required(),

// 當 relationship 是 other 的時候,進行特定規則的驗證
// 以這裡來說,就是需要以 "other-" 作為前綴才可以
username: yup.string().when('relationship', {
is: 'other', // 或 (value: string) => value === "other",
then: yup
.string()
.test('username-format', "need to have 'other-' as prefix", (value) => {
return value ? value.startsWith('other-') : true;
})
.required(),
otherwise: yup.string().required(),
}),
});

nested object 也可以使用(參考 Yup when condition inside nested object),但要留意:

  • when 裡面使用的欄位需要和該欄位在「物件的同一層」,如果是 nested object 裡的欄位會拿不到(例如,這裡的 relationshipbasicInfo 是在物件的同一層是可以的,但如果想要在 basicInfo.firstName 去拿 relationship 的值就會拿不到)
const validationSchema = yup.object().shape({
relationship: yup.string().required(),

// 當 relationship 是 other 是,basicInfo 這個物件需要包含 `firstName` 和 `lastName`
basicInfo: yup.object().when('relationship', {
is: (value: string) => value === 'other',
then: yup.object().shape({
firstName: yup
.string()
.test('firstname-format', "need to have 'other-' as prefix", (value) => {
return value ? value.startsWith('other-') : true;
})
.required(),
lastName: yup
.string()
.test('lastname-format', "need to have 'other-' as prefix", (value) => {
return value ? value.startsWith('other-') : true;
})
.required(),
}),
}),
});

範例程式碼

https://codesandbox.io/s/react-hook-form-with-yup-when-4o2sr

react-hook-form-yup-when

使用 yup 的 test 來根據多個欄位來驗證特定欄位

keywords: yup, mixed.test(), validation schema by multiple fields

參考 Yup 筆記 @ pjchender

Property 'message' does not exist on type

keywords: NestedValue

在表單中,當欄位的選項是存成 Array 時,例如像 Tag 這類的 Input:

type FormValues = {
tags: string[];
};

如果想要透過 errors.tags.message 取用驗證的錯誤訊息時,會出現類似 Property 'message' does not exist on type' 的錯誤:

Imgur

要解決這個問題可以使用 react-hook-form 提供的 NestedValue。使用的方式,只需要再定義該欄位的定方,型別使用:

import { NestedValue } from 'react-hook-form';
type FormValues = {
tags: NestedValue<string[]>;
};

如果你用了之後,再定義 defaultValues 的地方不斷出現類似 Property [$NestedValue]' is missing in type 'never[]' but ... 的錯誤。

這時候可以透過 npm uninstall react-hook-form 再把它重新安裝一下的即可(和 react-hook-form 的版本沒有直接關係,感覺是 package-lock.json 的影響)。

使用變數作為 interface/type 物件型別的 key

keywords: react-hook-form, key as enum

可行:使用 object as const

直接使用 enum 的話,react-hook-form 的 setValue 會無法正確判斷該欄位的型別,所以需要定義 object 後搭配 as const

// ✅ 使用 Object as const
const UserField = {
USERNAME: 'username',
EMAIL: 'email',
} as const;

interface PersonalInfo {
[UserField.USERNAME]: { firstName: string; lastName: string };
[UserField.EMAIL]: string;
}

export default function App() {
// ...

useEffect(() => {
// react-hook-form 才能正確解析 username.firstName 的型別
setValue('username.firstName', 'PJCHENder');
}, [setValue]);

//...
}

不可行:使用 enum

如果使用的是一般的 enum,則會出現如下的錯誤:

Argument of type 'string' is not assignable to parameter of type 'never'.

例如:

// ❌ 使用一般的 enum 在使用 setValue 是會出現錯誤
enum UserField {
USERNAME = 'username',
EMAIL = 'email',
}

interface PersonalInfo {
[UserField.USERNAME]: { firstName: string; lastName: string };
[UserField.EMAIL]: string;
}

結果:

react-hook-form value with enum

目前不可行:const enum

在 TypeScript 中,還有一個 const enums 這東東,原本以為應該也可以用這個,但測試了一下在 react-hook-form 中也沒辦法用 const enum 來達到預期的效果。

範例程式碼

https://codesandbox.io/s/react-hook-form-with-dynamic-object-key-7dz3z