[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 的
Touched
和Dirty
欄位在 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
- 👍 Turn Anything Into A Form Field With React Hook Form Controller @ dev.to:說明使用 Controller 時,為什麼要把
onChange
和value
傳進去,以及如何撰寫客製化的 input component 來套用在 Controller 中。- Controller API @ react-hook-form
react-hook-form 傾向使用 uncontrolled components 和瀏覽器原生的表單,但不可避免的有時會需要使用到 controlled components(例如搭配 material UI 或 Antd),因為這些 component 的 input
太深而無法直接使用 register
,這時候就可以使用 <Controller />
這個 wrapper。
寫 component 套用到 react-hook-form 中
onBlur()
會影響到isTouched
和isDirty
的值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 的解構,不能不帶或只帶空物件
- 範例程式碼 @ pjchender codesandbox
- useFieldArray @ react-hook-form
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
:::參考資料
- Integrating an existing form @ React Hook Form > Get Started
:::
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
之所以會看到這個錯誤,一般是因為在 value 中使用了 undefined
,<input value={undefined} />
。
React 會把 value={undefined}
的 input 視為 uncontrolled 的;如果 value 有值(不是 undefined
)的話,表示這是 controlled input。
因此,如果一開始 render 時在 value
給的是 undefined
,後來卻又給值,這個 input 就會從 uncontrolled 變成 controlled。
舉例來說:
- 在
useForm
中一開始給的value
是undefined
- 當使用者輸入內容時,這個
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
嗎?答案是不行的。
input
的 value
不能是 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 中對資料型別定義的不一致:
舉例來說,weight
的資料從後端來時應該是 number,然而,一旦使用者輸入內容後,它就會變成「字串」:
之所以在使用者輸入資料後會變成字串,是因為該 input
的 value
實際上就是字串,所以當 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
,一般來說沒填的話,我們很習慣用 undefined
或 null
。然而,在使用者沒有填寫的情況下,我們預設值並不能給 undefined
或 null
,前者會出現前面提到的「A component is changing an uncontrolled input to be controlled」,後者則不被允許。
這時候我們能夠使用的,就只剩下空字串 ''
。之所以不用 0
當預設值,是因為這樣畫面上就會有 0
,而且 0
並不代表「沒有」。
即使是 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
。
null
和 undefined
在 JSON.stringify 後不同在 JSON.stringify 後,null
的欄位在會存在,其值就會是 null
;當 undefined
的欄位會從 JSON object 中消失。
留意使用 valueAsNumber
雖然瀏覽器本身有提供 e.target.valueAsNumber
,能夠取得「數值」型別的資料,但是如果使用者把資料清空的話,這個值會變成 NaN
。
同時,NaN
在 JSON.stringify
會變成 null
;在 TypeScript 中 NaN
也是 number
的一種,聽起來挺方便的,這樣型別就可以定義成 number
就好,不用加上 ""
。
讓人,當 value
變成 NaN
是,React 會跳出警告,建議轉成字串:
隨意,如果要使用 e.target.valueAsNumber
來取得 input[type="number"]
的 value 時,還是要留意。
仍須優化
按照上面的做法就可以處理基本的數值資料,當如果想要做到更好的話,在 onChange
時可能還需要直接用一下 formatter 過濾,不然使用者輸入一些「特殊情況」。
例如使用者時可以輸入 00001
的
雖然 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 裡的欄位會拿不到(例如,這裡的relationship
和basicInfo
是在物件的同一層是可以的,但如果想要在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
使用 yup 的 test 來根據多個欄位來驗證特定欄位
keywords: yup
, mixed.test()
, validation schema by multiple fields
參考 Yup 筆記 @ pjchender
Property 'message' does not exist on type
keywords: NestedValue
- Typescript: The property 'message' doesn't exists in 'FieldError | FieldErrors @ Github issue
- NestedValue @ react-hook-form
在表單中,當欄位的選項是存成 Array 時,例如像 Tag 這類的 Input:
type FormValues = {
tags: string[];
};
如果想要透過 errors.tags.message
取用驗證的錯誤訊息時,會出現類似 Property 'message' does not exist on type'
的錯誤:
要解決這個問題可以使用 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;
}
結果:
目前不可行:const enum
在 TypeScript 中,還有一個 const enums 這東東,原本以為應該也可以用這個,但測試了一下在 react-hook-form 中也沒辦法用 const enum
來達到預期的效果。
範例程式碼
https://codesandbox.io/s/react-hook-form-with-dynamic-object-key-7dz3z