這陣子陸續把現有的專案從 Ant Design 的 Form (以下簡稱 ADF)改為使用 React Hook Form (以下簡稱 RHF)原因有:
先打個預防針「有些原因不是缺點,只是剛好不太符合現有專案需求」
ADF 客製樣式會很麻煩,本身有太多內建的樣式。 ADF 在綁定複雜的客製元件時較為麻煩。 RHF 的 type 系統比 ADF 好很多。 詳細說明及遷移過程可能會再寫一篇文章,這篇文章主要是要記錄最近在實作 RHF 上遇到坑。
從 useForm reset 發現的坑
RHF 的 reset 主要有兩種功用一個是回到預設值,一個是將 Form 重設為特定的值。
第一個功能比較不用說明,第二個通常是會用在當「這個 Form 的預設值是 asynchronous」,所以我們無法在 Form 初始化時將我們希望的預設值丟進去,就會是在 useEffect 裡將 await 到的值放入 reset 。
這次遇到的問題遇到某些 component 在正常的操作下都沒問題,像是基本的輸入、取值等等。但如果遇到 reset 就是無法正常地觸發 re-render。
綁定元件
先稍微介紹一下 RHF,通常是會使用 useForm 去建立 Form 的 instance,也可以利用這個 hook 去設定預設值或者其他操作。而 useForm 有回傳兩個將元件與 Form 綁定的方式:register 以及 control
基本上我們的 input 元件都會使用 register 進行綁定。
<input {...register("name")} />
而 control
就較為麻煩
<Controller
name="name"
control={control}
render={({ field }) => <input {...field} />}
/>
基本上就是利用 render props 讓這個元件可以被 Form
操控。在 RHF 的文件裡有說到有些第三方的 controlled component 需要使用 Controller
來進行包裝才能被使用。
回到這次的問題
我們使用了 chakra-ui 來當作我們的元件庫,一直以來使用都沒什麼問題 直到我們做了以下事情:
const CustomizedInput = (props: InputProps) => {
return;
<Input
marginBottom="0px"
backgroundColor={White}
border={`1px solid ${Gray500}`}
borderRadius="8px"
{...props}
/>;
};
基本上就是我們將常用的樣式包裝成一個共用 input,而在這個例子中的 props 就會將我們的 register 相關的行為注入進去。
一個簡單還原的 Demo:
有四種 input:
- 使用
register
去綁定Input
- 使用
register
去綁定使用React.forwardRef
的CustomInput
- 使用
control
去綁定CustomInput
- 使用
register
去綁定不經過React.forwardRef
的CustomInput
會發現只有 4. 沒有被 reset 到初始值,所以我們可以合理懷疑這個問題應該跟CustomInput
這種包裝方式有很大關聯。
但為什麼?其實 console 已經給了答案了
Function components cannot be given refs. Attempts to access this ref will fail. Did you mean to use React.forwardRef()
第一個疑問是為什麼會有ref
?這邊就要回去看 RHF 的 source ,這邊會看到 register 有回傳ref
,簡單來看就是register
時就會順便註冊這個ref
到 input。
所以在我們的例子中就將ref
傳給了 CustomInput,但因為 Functional component 本身是沒有ref
的,導致這個傳入的 ref 並沒有作用。
forwardRef
那我們就來看看為什麼使用了 forwardRef
的 CustomInput
可以達成我們的需求。
官方文件:傳送 Ref — React
首先我們要先知道 ref
是一個保留字詞,所以是不能被當作 props 的命名。這也是會發生這個問題的其中一個原因,所以 ref 被傳下來後沒被綁定在 CustomInput
上也無法在{...props}
中 並無法取出 ref
的。
其實有透過其他命名,像是將這個 prop 命名改為customRef
之類的,然後藉此規避掉關鍵字問題,但這種方式跟 forwardRef
的比較並不在本篇文的討論範圍。
那我們就來看一下 forwardRef
如何使用。
forwardRef((props, ref) => <Input {...props} ref={ref} />);
在創建 component 時,我們透過 wrap 一層 forwardRef
讓 ref
可以被取出並傳遞到下層。
從我們能看到的簡單流程就是這樣:
我們在呼叫register
時會將 ref
傳入到 CustomInput
React 會將 ref
當作 forwardRef
裡的 function 中的第二個變數 我們藉由把這個 ref
當作 JSX attribute 來傳遞到更下面的 <Input ref={ref}>
而也許會有人想問為什麼 chakra 的 Input
可以接收 ref
,它應該不是原生的 html tag 啊?而答案當然就是「他也用了forwardRef
」 source code
到這其實這個坑也算解的差不多了。
那至於為什麼 Controller
為什麼可以解決這個問題呢?簡單的解釋是:Controller
在 render props 裡用了另外一個 custom hook :useController
內部有自己的 context,所以就算 ref
沒有被傳遞到最下面的 component 也是能夠更改及讀取到狀態,那這樣的行為到底這算不算 Bug 我就沒深入研究了。
結語
但也因為這個坑我發現另外一件事,還記得我最一開始說這是因爲 reset
而發現的嗎?
我在 codesandbox 實作這個 demo 時發現,為什麼一般 onChange 也會失效了?後來才知道這是 RHF 的版本問題,codesandbox 裡使用是最新版(寫文當下是:7.19.2),而公司內的專案使用的是 7.12.2,而 7.12.2 裡就算我不用 forwardRef 狀態也是能正常被讀取跟更改。
但我也沒有深入比對這兩個版本中到底差了哪些東西就是了,總之這意外的讓我先往 reset
這個 api 實作去找問題。而發現到 reset
中是有操作到 ref
之後才再去看 register
的實作來找到這個問題。
其中一個原因也是我很少在操作 ref
,以至於在遇到這個坑之前我不知道ref
是不能直接被使用ref
這個名字在進行傳遞的,而且剛好之前在使用ref
時都剛好有額外的命名而規避掉這個坑。直到 register
自動傳入ref
才讓我發現這件事情。
懶人包
- RHF 會自動的傳入
ref
- 在包裝第三方套件時請注意到
ref
的傳遞情況 ref
不能被直接傳遞,如果要傳遞ref
到下層 component 要就是用其他名字又或者使用forwardRef