跳至主要内容

[Tips] React Form Tips

Conditional Form Fields

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?

source code

7cb56ca @ GitHub

the meaning of rerender in here

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.

1

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.

2

Solution 1: Keep the DOM but will return null

source code

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

source code

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

source code

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

source code

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.

react hook form makes form behavior weirder

Key points when using react hook form

  • Consider useForm as equivalent to useState. 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

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>
);
}

這麼做還要留意,需要根據 API 的設計,看是要補成其他的預設數值,或是傳給後端 nullundefined

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 會不會介不介意使用者做出這種操作。