本文 AI 產出,尚未審核

TypeScript 實務應用:React 中的 useRefuseContext 型別


簡介

在 React 專案裡,useRefuseContext 是兩個常被使用的 Hook。

  • useRef 能讓我們在函式元件中保持「可變」的參考(reference),常見於取得 DOM 元素或儲存跨渲染的值。
  • useContext 則是跨層級傳遞資料的利器,讓子元件不必透過層層 props 傳遞。

當我們把 TypeScript 帶入 React 時,正確的型別宣告不僅能提升開發體驗(IDE 智慧提示、編譯時錯誤),更能避免許多執行期的陷阱。本文將從概念說明、實作範例、常見問題與最佳實踐,帶你把 useRefuseContext 的型別寫得既安全又簡潔。


核心概念

1. useRef 的型別基礎

useRef<T>(initialValue) 會回傳一個形如 { current: T } 的物件。

  • DOM RefT 通常是 HTMLDivElement | null(因為在第一次渲染時元素尚未掛載)。
  • Mutable Value RefT 可以是任意類型,例如 number, boolean, 或自訂介面。

範例 1️⃣:取得 DOM 元素的 Ref

import React, { useRef, useEffect } from 'react';

function FocusInput() {
  // 使用 HTMLInputElement 作為 Ref 的類型
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // 由於 initialValue 為 null,需先做非 null 檢查
    if (inputRef.current) {
      inputRef.current.focus(); // 讓輸入框自動取得焦點
    }
  }, []);

  return <input ref={inputRef} placeholder="自動聚焦" />;
}

重點useRef<HTMLInputElement>(null) 明確告訴 TypeScript「這個 Ref 最終會指向 HTMLInputElement」;若不寫型別,會被推斷為 MutableRefObject<undefined>,導致 .focus() 產生編譯錯誤。

範例 2️⃣:儲存跨渲染的計時器 ID

import React, { useRef, useState } from 'react';

function Timer() {
  const [seconds, setSeconds] = useState(0);
  // Ref 用來保存 setInterval 的回傳值 (NodeJS.Timer | number)
  const timerRef = useRef<NodeJS.Timer | number | null>(null);

  const start = () => {
    if (!timerRef.current) {
      timerRef.current = setInterval(() => setSeconds(s => s + 1), 1000);
    }
  };

  const stop = () => {
    if (timerRef.current) {
      clearInterval(timerRef.current as NodeJS.Timer);
      timerRef.current = null;
    }
  };

  return (
    <div>
      <p>已執行 {seconds} 秒</p>
      <button onClick={start}>開始</button>
      <button onClick={stop}>停止</button>
    </div>
  );
}

說明NodeJS.Timer 在 Node 環境下是 setInterval 的型別,瀏覽器則是 number。使用聯合型別 NodeJS.Timer | number | null 可兼容兩種執行環境。

範例 3️⃣:泛型 Ref 讓元件更具彈性

import React, { forwardRef, useImperativeHandle, useRef } from 'react';

interface ModalHandles {
  open: () => void;
  close: () => void;
}

// 透過 forwardRef 讓父層直接呼叫子層方法
const Modal = forwardRef<ModalHandles, { title: string }>((props, ref) => {
  const { title } = props;
  const internalRef = useRef<HTMLDivElement>(null);

  useImperativeHandle(ref, () => ({
    open() {
      if (internalRef.current) internalRef.current.style.display = 'block';
    },
    close() {
      if (internalRef.current) internalRef.current.style.display = 'none';
    },
  }));

  return (
    <div ref={internalRef} style={{ display: 'none', border: '1px solid #ccc', padding: 10 }}>
      <h3>{title}</h3>
      <p>這是一個可被父層控制的 Modal。</p>
    </div>
  );
});

export default Modal;

要點forwardRef<ModalHandles, Props> 直接在型別參數中指定 exposed 方法的介面,讓使用 Modal 的地方得到完整的 IntelliSense。


2. useContext 的型別策略

useContext 會接受一個 React.Context<T>,並返回 T
最常見的挑戰是 「預設值的型別」「可能為 undefined」 的情況。

2.1 建立安全的 Context

import React, { createContext, useContext, ReactNode } from 'react';

// 定義 Context 中的資料結構
interface AuthState {
  user: string | null;
  login: (name: string) => void;
  logout: () => void;
}

// 1️⃣ 使用 undefined 作為預設值,讓 TypeScript 強迫使用者在 Provider 外部
//    必須先檢查 undefined 才能使用
const AuthContext = createContext<AuthState | undefined>(undefined);

// 2️⃣ 建立 Provider
export const AuthProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = React.useState<string | null>(null);

  const login = (name: string) => setUser(name);
  const logout = () => setUser(null);

  return (
    <AuthContext.Provider value={{ user, login, logout }}>
      {children}
    </AuthContext.Provider>
  );
};

// 3️⃣ 自訂 Hook,幫助抽取檢查邏輯
export const useAuth = (): AuthState => {
  const ctx = useContext(AuthContext);
  if (!ctx) {
    throw new Error('useAuth 必須在 AuthProvider 內使用');
  }
  return ctx;
};

為什麼要把預設值設為 undefined 而不是一個「空」物件?

  • 若直接提供 { user: null, login: () => {}, logout: () => {} },子層在忘記包裝 Provider 時不會收到錯誤,可能導致執行期的靜默失敗。
  • 使用 undefined 能讓 TypeScript 把「未包裝」的情況列為錯誤,並在自訂 Hook 中拋出明確例外。

2.2 Context 與泛型

// 允許任意資料型別的 Context(例如表單狀態)
function createTypedContext<T>(defaultValue: T) {
  const ctx = createContext<T>(defaultValue);
  const useTypedContext = () => useContext(ctx);
  return [ctx.Provider, useTypedContext] as const;
}

// 使用範例
interface FormState {
  name: string;
  age: number;
}
const [FormProvider, useForm] = createTypedContext<FormState>({ name: '', age: 0 });

function Form() {
  const { name, age } = useForm();
  return (
    <div>
      <p>姓名:{name}</p>
      <p>年齡:{age}</p>
    </div>
  );
}

技巧:透過 工廠函式 (createTypedContext) 可以一次建立 Provider 與對應的 Hook,讓型別保持一致且呼叫方式更直覺。


3. useRef + useContext 結合的進階範例

import React, { createContext, useContext, useRef, useEffect } from 'react';

// 1️⃣ 建立一個可以跨多個元件共享的 Ref
type ScrollRef = React.RefObject<HTMLDivElement>;

const ScrollContext = createContext<ScrollRef | null>(null);

export const ScrollProvider = ({ children }: { children: React.ReactNode }) => {
  const containerRef = useRef<HTMLDivElement>(null);
  return (
    <ScrollContext.Provider value={containerRef}>
      {children}
    </ScrollContext.Provider>
  );
};

// 2️⃣ 子元件使用 Context 取得同一個 Ref,執行捲動
export const ScrollToTopButton = () => {
  const containerRef = useContext(ScrollContext);
  if (!containerRef) return null; // 防止未包 Provider

  const scrollToTop = () => {
    containerRef.current?.scrollTo({ top: 0, behavior: 'smooth' });
  };

  return <button onClick={scrollToTop}>回到最上方</button>;
};

實務意義:在大型畫面(例如聊天視窗、長列表)中,多個子元件需要共同操作同一個 DOM 節點,此時把 ref 放進 Context 是既安全又易維護的做法。


常見陷阱與最佳實踐

陷阱 說明 解決方式
Ref 初始值為 null 卻直接使用 inputRef.current!.focus() 會在編譯時通過,但執行期仍可能 null 使用條件判斷 if (ref.current) { … },或在確定不會為 null 時使用 非空斷言 !(需謹慎)
Context 預設值不正確 預設值為 {} 會讓 TypeScript 認為所有屬性皆存在,實際上卻未提供 Provider 設為 undefined,並在自訂 Hook 中拋出錯誤
泛型 Ref 失去推斷 useRef(null) 推斷為 MutableRefObject<null>,無法取得正確元素型別 明確傳入泛型 useRef<HTMLDivElement>(null)
跨層級傳遞 Ref 時忘記 forwardRef 直接把 ref 作為普通屬性傳遞會失去 React 的 Ref 機制 使用 forwardRef + useImperativeHandle
Context 中的函式被重新建立 每次 Provider 重新渲染會產生新函式,子元件會因 useContext 變更而重新渲染 使用 useCallback 包裝 Provider 中的函式,或把函式抽離到外部

最佳實踐清單

  1. 永遠為 Ref 指定泛型:即使是 null 也要寫 useRef<Type>(null)
  2. 使用自訂 Hook 包裝 Context:如 useAuth,可集中錯誤處理與型別檢查。
  3. 避免在 Provider 內直接建立物件:使用 useMemouseCallback 防止不必要的重渲染。
  4. 在需要對外暴露方法時,使用 useImperativeHandle,讓父層只取得需要的 API。
  5. 對於可能為 undefined 的 Ref 或 Context,使用可選鏈 (?.) 或非空斷言 (!) 前先檢查,確保執行期安全。

實際應用場景

1. 表單驗證的「即時」Ref

在大型表單中,常需要在提交前聚焦第一個錯誤欄位。透過 useRef 的陣列(或 Map)配合 useContext,可以在任何子表單元件內直接呼叫聚焦:

// FormRefContext.tsx
type FieldRefMap = Map<string, React.RefObject<HTMLInputElement>>;

const FormRefContext = createContext<FieldRefMap | null>(null);
export const FormRefProvider = ({ children }: { children: ReactNode }) => {
  const refMap = useRef<FieldRefMap>(new Map());
  return (
    <FormRefContext.Provider value={refMap.current}>
      {children}
    </FormRefContext.Provider>
  );
};

// 在子元件中註冊自己的 Ref
function TextField({ name }: { name: string }) {
  const ref = useRef<HTMLInputElement>(null);
  const map = useContext(FormRefContext);
  useEffect(() => {
    map?.set(name, ref);
    return () => map?.delete(name);
  }, [name, map]);
  return <input ref={ref} name={name} />;
}

// 提交時聚焦第一個錯誤欄位
function SubmitButton() {
  const map = useContext(FormRefContext);
  const handleSubmit = () => {
    // 假設 errors 為字串陣列,代表有錯誤的欄位名稱
    const errors = ['email', 'password'];
    const firstError = errors[0];
    map?.get(firstError)?.current?.focus();
  };
  return <button onClick={handleSubmit}>送出</button>;
}

效益:表單元件不需要透過 props 把 Ref 向上層傳遞,任意深度的欄位都能直接註冊與取得。

2. 多層級 UI 元件共用滾動 Ref

在電商網站的「商品清單」與「側邊過濾面板」之間,需要同時控制列表的捲動位置。將 ref 放入 Context,任何子元件(如「返回頂部」按鈕、無限捲動偵測)都能共享:

// ScrollProvider.tsx 參考前面的範例
// 在列表元件內使用
function ProductList() {
  const containerRef = useContext(ScrollContext);
  // 無限捲動偵測
  useEffect(() => {
    const el = containerRef?.current;
    if (!el) return;
    const onScroll = () => {
      if (el.scrollTop + el.clientHeight >= el.scrollHeight - 100) {
        // 載入更多商品
      }
    };
    el.addEventListener('scroll', onScroll);
    return () => el.removeEventListener('scroll', onScroll);
  }, [containerRef]);
  // 渲染商品
}

3. 跨頁面 Modal 控制

在大型儀表板中,許多不同頁面需要呼叫同一個全局 Modal。透過 useContext 暴露 open/close 方法,配合 useRef 保存 Modal 的實例:

// ModalContext.tsx
interface ModalHandles {
  open: (props: { title: string; content: ReactNode }) => void;
  close: () => void;
}
const ModalContext = createContext<ModalHandles | null>(null);

export const ModalProvider = ({ children }: { children: ReactNode }) => {
  const modalRef = useRef<ModalHandles>(null);
  return (
    <ModalContext.Provider value={modalRef.current}>
      {children}
      <GlobalModal ref={modalRef} />
    </ModalContext.Provider>
  );
};

// 任意子元件
function DeleteButton() {
  const modal = useContext(ModalContext);
  const handleClick = () => {
    modal?.open({
      title: '確認刪除',
      content: <p>確定要刪除這筆資料嗎?</p>,
    });
  };
  return <button onClick={handleClick}>刪除</button>;
}

總結

  • useRef 在 TypeScript 中必須明確指定泛型,無論是 DOM RefMutable Value Ref,或是 透過 forwardRef 暴露的 API
  • useContext 的型別安全關鍵在於 預設值(建議使用 undefined)以及 自訂 Hook 內的檢查,讓開發者在忘記包 Provider 時立即得到錯誤提示。
  • 結合使用 時,將 ref 放入 Context 可以讓多個元件共享同一個實例,解決跨層級操作 DOM 或共享狀態的需求。
  • 常見陷阱(如未檢查 null、Context 預設值不當、Ref 失去型別推斷)只要遵守 「明確型別 + 必要的防呆檢查」 的原則,就能寫出 安全、可維護、IDE 友善 的 React + TypeScript 程式碼。

藉由本文的概念、範例與最佳實踐,你現在可以在專案中自信地使用 useRefuseContext,同時享受 TypeScript 帶來的型別保護與開發效率提升。祝你寫程式快快快、除錯少少少! 🎉