React + TypeScript:forwardRef 型別定義完整指南
簡介
在 React 中,ref 提供了一條直接存取 DOM 元素或子元件實例的管道,常用於聚焦、測量尺寸或與第三方 UI 函式庫整合。當我們在 TypeScript 專案裡使用 ref 時,若不正確地為 forwardRef 產生型別,會導致編譯錯誤、IDE 智慧提示失效,甚至執行時出現不可預期的行為。
本篇文章聚焦於 React 的 forwardRef,說明如何在 TypeScript 中為它寫出正確且可維護的型別定義。從基礎概念到進階寫法,配合 4 個實務範例、常見陷阱與最佳實踐,幫助你在日常開發中安全、快速地使用 forwardRef。
核心概念
1. 為什麼需要 forwardRef?
forwardRef 允許父層元件把 ref 直接傳遞給子層的 DOM 或 自訂元件,而不必透過額外的 props 轉發。這在以下情境特別有用:
- 聚焦表單欄位:父元件想要在表單載入後自動聚焦某個
<input>。 - 與第三方 UI 函式庫整合:許多 UI 套件(如 Material‑UI、Ant Design)要求外部取得內部的 DOM 節點。
- 高階元件 (HOC) 的透明化:HOC 包裝後仍希望保留原始元件的 ref 能力。
2. forwardRef 的基本型別
在 JavaScript 中,我們這樣寫:
import React, { forwardRef } from 'react';
const MyButton = forwardRef((props, ref) => (
<button ref={ref} {...props}>Click me</button>
));
在 TypeScript 中,forwardRef 會返回一個泛型函式。最簡單的型別寫法如下:
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
const MyButton = forwardRef<HTMLButtonElement, ButtonHTMLAttributes<HTMLButtonElement>>(
(props, ref) => (
<button ref={ref} {...props}>Click me</button>
)
);
- 第一個泛型參數 (
HTMLButtonElement) 表示 ref 最終指向的 DOM 類型。 - 第二個泛型參數 (
ButtonHTMLAttributes<HTMLButtonElement>) 為 props 的型別。
重點:若省略這兩個泛型,
ref會被推斷為any,失去型別安全。
3. React.Ref 與 React.RefObject 的差別
| 型別 | 說明 |
|---|---|
React.Ref<T> |
可以是 RefObject<T>、回呼函式 `(instance: T |
React.RefObject<T> |
由 React.createRef<T>() 產生,擁有 .current 屬性。常在類別元件或函式元件裡使用。 |
在 forwardRef 中,我們通常不需要自行宣告 React.Ref<T>,只要在泛型裡提供 T(即最終的 DOM 或元件實例型別)即可。
4. 自訂元件的 forwardRef 型別
如果 ref 需要指向 子元件的實例(例如使用 useImperativeHandle 暴露方法),型別會稍微複雜一些。下面示範一個「可聚焦」的自訂 Input 元件:
import React, { forwardRef, useImperativeHandle, useRef } from 'react';
export interface FocusableInputHandle {
focus: () => void;
}
const FocusableInput = forwardRef<FocusableInputHandle, React.InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => {
const innerRef = useRef<HTMLInputElement>(null);
// 把 focus 方法暴露給外層 ref
useImperativeHandle(ref, () => ({
focus: () => innerRef.current?.focus(),
}));
return <input ref={innerRef} {...props} />;
}
);
forwardRef<FocusableInputHandle, ...>:FocusableInputHandle定義了外層可以呼叫的介面。useImperativeHandle:將內部的focus方法映射到外部。
5. React.ForwardRefExoticComponent 與 React.RefAttributes
當我們把 forwardRef 包裝成 具備靜態屬性(如 displayName)的元件時,會得到 React.ForwardRefExoticComponent<P>:
type MyCompProps = { label: string };
const MyComp = forwardRef<HTMLDivElement, MyCompProps>((props, ref) => (
<div ref={ref}>{props.label}</div>
));
MyComp.displayName = 'MyComp';
// MyComp 的型別是 React.ForwardRefExoticComponent<MyCompProps & React.RefAttributes<HTMLDivElement>>
React.RefAttributes<T>會自動加入ref?: React.Ref<T>的屬性,使得使用MyComp時能直接傳入ref。
程式碼範例
以下提供 四個實務範例,從最簡單的 DOM 轉發到自訂 hook 暴露方法,皆附上完整的型別說明與註解。
範例 1:最簡單的 forwardRef(Button)
import React, { forwardRef, ButtonHTMLAttributes } from 'react';
// 1️⃣ 指定 ref 指向的 DOM 類型與 props 型別
const SimpleButton = forwardRef<
HTMLButtonElement, // ref 最終指向 <button>
ButtonHTMLAttributes<HTMLButtonElement> // props 繼承原生 button 屬性
>((props, ref) => {
// 2️⃣ 將 ref 直接掛在 <button> 上
return <button ref={ref} {...props} />;
});
export default SimpleButton;
使用方式
const btnRef = React.useRef<HTMLButtonElement>(null); <SimpleButton ref={btnRef} onClick={() => btnRef.current?.focus()}>點我</SimpleButton>
範例 2:自訂 Input 並暴露 focus 方法
import React, {
forwardRef,
useImperativeHandle,
useRef,
InputHTMLAttributes,
} from 'react';
// 1️⃣ 定義外部可以呼叫的介面
export interface InputHandle {
focus: () => void;
}
// 2️⃣ forwardRef 的泛型:<介面, props>
const CustomInput = forwardRef<InputHandle, InputHTMLAttributes<HTMLInputElement>>(
(props, ref) => {
const innerRef = useRef<HTMLInputElement>(null);
// 3️⃣ 把 focus 方法映射到外層 ref
useImperativeHandle(ref, () => ({
focus: () => innerRef.current?.focus(),
}));
return <input ref={innerRef} {...props} />;
}
);
export default CustomInput;
使用方式
const inputRef = React.useRef<InputHandle>(null); <CustomInput ref={inputRef} placeholder="請輸入文字" /> <button onClick={() => inputRef.current?.focus()}>聚焦輸入框</button>
範例 3:在 HOC 中保留 forwardRef 的型別
import React, {
forwardRef,
ComponentType,
PropsWithChildren,
Ref,
} from 'react';
// 1️⃣ HOC 會接收一個具備 ref 的元件,並回傳同樣支援 ref 的元件
function withLoading<P, T>(WrappedComponent: ComponentType<P & React.RefAttributes<T>>) {
const ComponentWithLoading = forwardRef<T, PropsWithChildren<P>>(
(props, ref) => {
const [loading, setLoading] = React.useState(false);
// 假設有一些非同步操作
const handleClick = async () => {
setLoading(true);
// ... async work
setLoading(false);
};
return (
<div>
{loading && <span>載入中...</span>}
{/* 2️⃣ 把 ref 直接傳給 WrappedComponent */}
<WrappedComponent {...props} ref={ref as Ref<T>} />
<button onClick={handleClick}>觸發載入</button>
</div>
);
}
);
// 讓開發者在除錯時能看到正確的名稱
ComponentWithLoading.displayName = `withLoading(${WrappedComponent.displayName || WrappedComponent.name})`;
return ComponentWithLoading;
}
// ----- 使用範例 -----
type CardProps = { title: string };
const Card = forwardRef<HTMLDivElement, CardProps>((props, ref) => (
<div ref={ref}>
<h3>{props.title}</h3>
</div>
));
export default withLoading(Card);
說明
ComponentType<P & React.RefAttributes<T>>確保WrappedComponent接受ref。forwardRef<T, PropsWithChildren<P>>中的T為最終的 ref 型別,PropsWithChildren<P>讓 HOC 仍能接受子元素。
範例 4:結合 React.memo 與 forwardRef(最佳化)
import React, {
forwardRef,
memo,
ButtonHTMLAttributes,
} from 'react';
// 1️⃣ 定義 Props,使用泛型延伸原生 button 屬性
type IconButtonProps = {
icon: React.ReactNode;
} & ButtonHTMLAttributes<HTMLButtonElement>;
// 2️⃣ forwardRef + memo:先 forwardRef 再包 memo
const IconButton = memo(
forwardRef<HTMLButtonElement, IconButtonProps>((props, ref) => {
const { icon, children, ...rest } = props;
return (
<button ref={ref} {...rest}>
{icon}
{children}
</button>
);
})
);
IconButton.displayName = 'IconButton';
export default IconButton;
使用方式
const btnRef = React.useRef<HTMLButtonElement>(null); <IconButton ref={btnRef} icon={<i className="fa fa-search" />} onClick={() => console.log('clicked')} />
常見陷阱與最佳實踐
| 陷阱 | 說明 | 解決方式 |
|---|---|---|
忘記指定 ref 的泛型 |
forwardRef 預設回傳 any,失去型別安全。 |
永遠在 forwardRef<T, P> 中提供 T(ref 指向的型別)。 |
useImperativeHandle 回傳的物件與介面不匹配 |
編譯器不會自動檢查回傳物件與 forwardRef 泛型是否一致。 |
為回傳物件寫明 介面(如 InputHandle),並在 forwardRef 中使用相同介面。 |
在 HOC 中遺失 ref |
HOC 常以 props 方式包裝子元件,若未使用 forwardRef,外層無法取得 ref。 |
在 HOC 中使用 forwardRef,並把 ref 正確傳遞給子元件 (ref as Ref<T>)。 |
memo 內部包 forwardRef 時忘記設定 displayName |
開發工具的元件樹會顯示匿名函式,難以除錯。 | 為 forwardRef 或 memo 包裝的元件手動設定 displayName。 |
ref 與 key 同時傳入,導致 ref 變 null |
React 會先處理 key 再處理 ref,若 key 改變會重新建立元件,舊的 ref 失效。 |
確保在列表渲染時 key 穩定,且不在同一個渲染循環內改變 key。 |
最佳實踐
- 明確寫出所有泛型:
forwardRef<DOMOrComponent, Props>,讓 IDE 完整提示。 - 使用
React.RefAttributes<T>:在自訂元件的型別宣告時,直接擴充RefAttributes,避免手動加入ref?: React.Ref<T>。 - 將
useImperativeHandle的回傳物件抽成介面,保持一致性與可重用性。 - 在測試中加入 ref 行為:使用
@testing-library/react的ref測試,確保未因重構失去功能。 - 在
forwardRef內部避免使用any:即使暫時無法確定型別,也可使用unknown再逐步收斂。
實際應用場景
1. 表單元件庫(如 Ant Design、Material‑UI)
這類 UI 套件的每個輸入元件都會提供 ref,讓使用者在表單提交前自行觸發驗證或聚焦。透過正確的 forwardRef 型別,開發者在自己的表單封裝層仍能保有原始元件的 ref 能力。
2. 動畫與測量(React‑Spring、Framer‑Motion)
許多動畫函式庫需要直接存取 DOM 節點以計算尺寸或座標。使用 forwardRef 包裝的自訂元件,使得外層動畫容器能直接取得子元件的實際 DOM。
3. 可重用的「彈出式」元件
例如自訂的 Modal、Tooltip,內部會在開啟時自動聚焦第一個可互動元素。外層只需要傳遞 ref 給 Modal,不必關心裡面的結構。
4. 高階元件 (HOC) 與裝飾器
在大型專案裡,常見「權限檢查」或「國際化」的 HOC。若原始元件支援 ref,HOC 必須保留這個特性,否則會破壞原本的聚焦或測量功能。
總結
forwardRef是 React 提供的 ref 轉發 機制,配合 TypeScript 必須明確指定 ref 的目標型別 (HTMLDivElement、自訂介面等) 與 props 型別。- 使用
forwardRef<T, P>、React.RefAttributes<T>、useImperativeHandle,可以在 函式元件 中安全、清晰地暴露方法或 DOM。 - 在 HOC、memo、以及自訂 UI 套件 中保留 ref 能力,需要把
forwardRef包裝在最外層,並適當地轉型ref as Ref<T>。 - 常見陷阱包括忘記泛型、介面不匹配、HOC 丟失 ref 等;遵循「永遠寫出完整泛型」與「介面化回傳物件」的最佳實踐,可大幅降低錯誤風險。
- 實務上,
forwardRef為 表單、動畫、彈窗、以及高階元件 提供了必要的底層存取能力,是打造可重用、可測試、易維護的 React+TypeScript 程式碼的關鍵。
掌握了上述概念與範例後,你就能在日常開發中自信地使用 forwardRef,為 React 應用注入更強的型別安全與彈性。祝你寫程式快樂,專案順利 🚀。