本文 AI 產出,尚未審核

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。

最佳實踐

  1. 明確寫出所有泛型:forwardRef<DOMOrComponent, Props>,讓 IDE 完整提示。
  2. 使用 React.RefAttributes<T>:在自訂元件的型別宣告時,直接擴充 RefAttributes,避免手動加入 ref?: React.Ref<T>。
  3. 將 useImperativeHandle 的回傳物件抽成介面,保持一致性與可重用性。
  4. 在測試中加入 ref 行為:使用 @testing-library/react 的 ref 測試,確保未因重構失去功能。
  5. 在 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 應用注入更強的型別安全與彈性。祝你寫程式快樂,專案順利 🚀。