[Tips] React Form Tips
Conditional Form Fields
- React reconciliation: how it works and why should we care @ Developer Way.
- Reconciliation @ React Docs
Conditional form fields are commonly used in forms, but if they are not handled properly, they can cause unexpected bugs.
Take this form as an example,
// https://github.com/pjchender/react-form-best-practice/commit/773337d60c856b5b7c7768554e39308c7a7b9de3
{
isBusinessUser ? (
<input
id="company-name"
placeholder="Apple. Inc"
// ...
/>
) : (
<input
id="personal-name"
placeholder="John Doe"
// ...
/>
);
}
Problem: why does the state remain unchanged even after rerendering the component?
7cb56ca @ GitHub
The term "rerendering" here refers only to the invoking of the render
method, not to "mount" and "remount".
The question here is why the state is preserved even after the component has been changed.
It's all about the "reconciliation" algorithm of React. In reconciliation, React compares the types of DOM elements such as img
, div
, span
. If the root elements differ in their types, React will create a completely new one from scratch instead of changing the existing one.
However, if the element types are the same, React analyzes the attributes of each. It maintains the same foundational DOM node and only modifies the attributes that have changed.
In the example above, since the element types are the same (i.e., input
), the instance remains unchanged when a component rerenders. This ensures that the state is preserved across different renders.
That's the reason why the state remains the same even after the React component has been rerendered.
Solve the problem
In order to ensure that the state is not retained by React, we have to make sure the component is unmounted and then remounted.
Solution 1: Keep the DOM but will return null
32f134 @ GitHub
{
isBusinessUser && <input id="company-name" placeholder="Apple. Inc" />;
}
{
!isBusinessUser && <input id="personal-name" placeholder="John Doe" />;
}
Solution 2: Use the "key" attribute to let React differentiate them
62d78c @ GitHub
{
watchIsBusiness ? (
<input
key="companyName"
id="company-name"
placeholder="Apple. Inc"
// ...
/>
) : (
<input
key="personalName"
id="personal-name"
placeholder="John Doe"
// ...
/>
);
}
Solution 3: Use "key" attributes to force React to render
62d78c @ GitHub
<div
key={watchIsBusiness ? 'Company Name' : 'Personal Name'}
>
{watchIsBusiness ? (
<input
id="company-name"
placeholder="Apple. Inc"
// ...
/>
) : (
<input
id="personal-name"
placeholder="John Doe"
// ...
/>
)}
</div>
If we want to learn more about this, check out the article React reconciliation: how it works and why should we care for detailed. The article also provides a Youtube video that explains this concept.
React Hook Form makes things weirder when not properly handling reconciliation
The behavior will become much weirder if you use React Hook Form but don't handle reconciliation properly.
It makes form behavior weird if you don't handle reconciliation properly
00ef0e @ GitHub
As you can see in the image, if we did not use the key
to handle reconciliation, the Personal Name "Aaron" is not preserved when we toggle the Personal Name field for the first time, which is not expected. However, once we toggle the checkbox, the value is maintained in the state as expected.
Key points when using react hook form
- Consider
useForm
as equivalent touseState
. This means all form values/state will be preserved within that component. - When using conditional form fields, ensure you handle reconciliation properly. This allows React to distinguish between different components.
- If you want the form behavior to resemble that of native browsers (uncontrolled input) closely, set
shouldUnregister
to true. In this case, react-hook-form will remove its field name and value when the component isn't displayed on the webpage.
Controlled and Uncontrolled components
Problem: 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>
);
}
這麼做還要留意,需要根據 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 會不會介不介意使用者做出這種操作。