TypeScript 實務應用:React 中的 useRef 與 useContext 型別
簡介
在 React 專案裡,useRef 與 useContext 是兩個常被使用的 Hook。
useRef能讓我們在函式元件中保持「可變」的參考(reference),常見於取得 DOM 元素或儲存跨渲染的值。useContext則是跨層級傳遞資料的利器,讓子元件不必透過層層props傳遞。
當我們把 TypeScript 帶入 React 時,正確的型別宣告不僅能提升開發體驗(IDE 智慧提示、編譯時錯誤),更能避免許多執行期的陷阱。本文將從概念說明、實作範例、常見問題與最佳實踐,帶你把 useRef 與 useContext 的型別寫得既安全又簡潔。
核心概念
1. useRef 的型別基礎
useRef<T>(initialValue) 會回傳一個形如 { current: T } 的物件。
- DOM Ref:
T通常是HTMLDivElement | null(因為在第一次渲染時元素尚未掛載)。 - Mutable Value Ref:
T可以是任意類型,例如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 中的函式,或把函式抽離到外部 |
最佳實踐清單
- 永遠為 Ref 指定泛型:即使是
null也要寫useRef<Type>(null)。 - 使用自訂 Hook 包裝 Context:如
useAuth,可集中錯誤處理與型別檢查。 - 避免在 Provider 內直接建立物件:使用
useMemo或useCallback防止不必要的重渲染。 - 在需要對外暴露方法時,使用
useImperativeHandle,讓父層只取得需要的 API。 - 對於可能為
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 Ref、Mutable Value Ref,或是 透過forwardRef暴露的 API。useContext的型別安全關鍵在於 預設值(建議使用undefined)以及 自訂 Hook 內的檢查,讓開發者在忘記包 Provider 時立即得到錯誤提示。- 結合使用 時,將
ref放入 Context 可以讓多個元件共享同一個實例,解決跨層級操作 DOM 或共享狀態的需求。 - 常見陷阱(如未檢查
null、Context 預設值不當、Ref 失去型別推斷)只要遵守 「明確型別 + 必要的防呆檢查」 的原則,就能寫出 安全、可維護、IDE 友善 的 React + TypeScript 程式碼。
藉由本文的概念、範例與最佳實踐,你現在可以在專案中自信地使用 useRef 與 useContext,同時享受 TypeScript 帶來的型別保護與開發效率提升。祝你寫程式快快快、除錯少少少! 🎉