在先前探索 useState 的文章裡,我們討論過當其保存的「狀態」值更新時,會觸發元件的重新渲染;在 React Hook 裡,提供了另一個同樣作為保存資料的工具 useRef,我們將它儲存的資料稱為「參考」(reference)。
參考和一般宣告的變數一樣,資料改變時不會觸發元件的重新渲染,不過,不同於一般變數在每次渲染後都必須重新計算,參考能夠在跨渲染之間保存原有的值。
如何使用 useRef?
useRef 的語法很簡單,首先,跟 useState 一樣,要給它定義初次渲染時的初始值;接著,useRef 會回傳一個參考物件,這個物件只有一個 current 屬性,你所定義的初始值,在元件初次渲染時便是賦予給這個屬性:
const ref = useRef(initialValue);
console.log(ref); // { current: initialValue }
ref 就只是單純的 JavaScript 物件,current 的值可以是任意型別的資料。 而 useRef 的作用在於:React 確保我們能夠在整個元件生命週期中,保持同一個 ref 的物件引用。
比較 useState 與 useRef:如何選擇狀態與參考?
相較於狀態是不可變的,你需要靠 useState 回傳的 setState 方法來覆寫、更新狀態,這個動作會觸發元件的重新渲染;相對的,參考是可變的,你可以把新的值直接賦予給 current 屬性,React 並不會追蹤 current 的變化,元件也不會因此重新渲染。
不過,更重要的是,在每次重新渲染後,React 都會回傳同一個 ref 物件,因此無論 ref.current 的值如何變化,元件會一直記住這些變動。因此,ref 比較適合存放「與畫面無關、但需要被記住」的資訊,例如 DOM 節點、計數器 ID、第三方函式庫實例等等。
以下是我在 CodePen 所寫的範例,在這個計數器元件中,我分別用狀態與參考定義數字,並在同一個畫面呈現:
🌟 備註:為方便告知點擊後發生了什麼狀況,我在兩者的事件處理器都加入了瀏覽器的 alert,如果不喜歡 alert 阻斷測試的流暢度,可以自行點進 CodePen,將它們拿除。
畫面中,State 後方的數字是用 useState 所定義,並在點擊 State + 1 後,用 setState 更新狀態;
// 定義狀態
const [stateCount, setStateCount] = useState(0);
// 事件處理器
const handleStateClick = () => {
setStateCount((c) => c + 1);
alert("將執行重新渲染,將一併渲染出 State 跟 Ref 的最新值");
};
// JSX
<p>State:{stateCount}</p>
<button onClick={handleStateClick} type="button">
State + 1
</button>
而 Ref 後方的數字則是用 useRef 所定義,點擊 Ref + 1 後,讓其 current 值累加:
// 定義參考
const refCount = useRef(0);
// 事件處理器
const handleRefClick = () => {
refCount.current++;
alert(`Ref 的 current 值已更新為 ${refCount.current} 👉 但畫面將不會更新`);
};
// JSX
<p>Ref:{refCount.current}</p>
<button onClick={handleRefClick} type="button">
Ref + 1
</button>
從這些範例的程式碼,可以發現:
當點擊 Ref + 1 的按鈕,並沒有發生元件渲染,因此縱使 refCount.current 的值確實改變了,但畫面上的數字依然停留在當次渲染的值;直到點擊 State + 1 的按鈕,因為 setStateCount 的執行觸發了重新渲染,所以會立即更新畫面上的數字,包括 refCount.current 的最新值。
也就是說,顯示在畫面的參考值,因為本身的變化不觸發重新渲染,必須依賴另外一個重新渲染發生才會更新顯示,如果要依資料變動而更新畫面,何不用狀態定義就好呢?因此,我們通常會避免把參考顯示在畫面上,所以,當你需要在狀態與參考之間選擇,可以依據一個原則:
「會影響畫面的資料 → 用useState定義;
不會影響畫面的資料 → 用useRef定義。」
useRef 常見用法
一、跨渲染之間保存不影響畫面的資料
useRef 最直觀的用法,便是運用它的特性,在跨渲染間保存不影響畫面的資料。其中計數器 API 的識別碼便是最典型的例子:
由於計數器 API (setInterval、setTimeout)不會隨元件的卸載而自動清除,所以我們需要儲存它們回傳的識別碼(正整數)作為相應清除方法(clearInterval 、clearTimeout)的參數。
它們的識別碼不會用於畫面,因此透過 useRef 來儲存是較推薦的做法,我們將會執行以下動作來實作一個碼錶元件:
- 用
useState管理畫面上顯示的時間秒數及碼錶狀態 - 用
setInterval讓時間每秒遞增 - 用
useRef儲存 interval ID,以便讓副作用及事件處理器管理
以下是實作步驟:
⓵ 初始化狀態與參考,並顯示在 UI
function Stopwatch() {
const [time, setTime] = useState(0);
const [isRunning, setIsRunning] = useState(false);
const intervalRef = useRef(null);
return (
<div>
<h1>碼錶:{time} 秒</h1>
<button disabled={isRunning} type="button">
開始
</button>
<button disabled={!isRunning} type="button">
暫停
</button>
<button type="button">重置</button>
</div>
)
}
⓶ 實作「開始」、「暫停」及「重置」碼錶的事件處理器
// 開始計時
function handleStart() {
if (!isRunning) return;
setIsRunning(true);
intervalRef.current = setInterval(() => {
setTime(t => t + 1); // 👈 1. 要使用函式更新
}, 1000);
};
// 暫停計時
function handleStop() {
setIsRunning(false);
clearInterval(intervalRef.current); // 👈 2. 清理計數器
};
// 重置計時
function handleReset() {
setTime(0);
setIsRunning(false);
clearInterval(intervalRef.current); // 👈 2. 清理計數器
};
1. 如果此處使用setTime(time + 1)會產生閉包問題,導致time會持續停留在初始值,必須使用 setState 的函式更新模式才能正確運作
2. 在處理暫停及重置事件時,記得都要清理計數器
⓷ 用副作用處理元件卸載時的清理計數器
為避免元件因為被切換路由或條件渲染移除,而沒有清除 interval 計數器的問題,我們需要在副作用上追加清理函式:
useEffect(() => {
return () => {
clearInterval(intervalRef.current);
};
}, []);
㊣ 將事件處理器綁定後的 Demo:
不過,以這個例子來說,讓副作用統一來處理 interval 的訂閱與清除可能是更好的實踐:
useEffect(() => {
if (!isRunning) return;
const id = setInterval(() => {
setTime((t) => t + 1);
}, 1000);
return () => {
clearInterval(id);
};
}, [isRunning]); // 👈 透過切換 isRunning 來重啟副作用
我們將 isRunning 放入 useEffect 的依賴陣列中來控制副作用的重啟;如此,事件處理器只需轉換 isRunning 的狀態即可:
function handleStart() {
setIsRunning(true);
}
function handleStop() {
setIsRunning(false);
}
function handleReset() {
setTime(0);
setIsRunning(false);
};
Demo:
比起直接在事件處理器以「命令」的方式處理計時器的訂閱與清除,寫法反覆、不易管理外還容易漏掉計數器清理,甚至可能會因為 React「批次處理狀態」的做法,在某些極端狀況產生問題;事實上,第二種寫法則更接近 React 宣告式 UI 的思維模式——透過狀態的切換,決定計時器的訂閱與清理。
在第二種寫法裡,也可以在元件最外層讓 useRef 儲存計數器 ID,並在副作用的其他地方使用,不過在這個例子中並不需要。
二、取得並操作 DOM 節點
上面有提及,React 是透過宣告的方式來創建介面 (declarative UI),我們根據不同的狀態,來描述相應的介面。但是,操作 DOM 並沒有辦法透過狀態描述。React 的元件有生命週期、有虛擬 DOM,用 JavaScript 原生手法 querySelector 操作的話可能會導致許多問題,因此 React 讓 useRef 成為與 DOM 溝通的橋樑。
要用 useRef 操作 DOM,主要有三個步驟:
- 用
useRef(null)初始化參考變數 - 在 JSX 透過
ref屬性,綁定要取得參考的 DOM 元素 - 在副作用或是事件處理器取得該 DOM 元素的屬性及方法
比如說,載入畫面時自動對焦的輸入框:
function AutoFocusInput(){
// 1. 用 useRef(null) 初始化變數
const inputRef = useRef(null);
useEffect(() => {
// 3. 在副作用取得 input 方法
inputRef.current?.focus();
}, [])
// 2. 綁定 DOM 元素
return <input ref={inputRef} type="text" />
}
我們以 null 來初始化參考 inputRef,元件掛載完成之後,React 會透過 ref 屬性將對應的 DOM 元素賦值給 inputRef.current;當此節點被移除,React 會將 current 屬性的值設定回 null,讓 DOM 的處理與元件生命週期同步。
三、存取上次渲染的狀態、props
我們可以在副作用中,將當前狀態或 props 傳給 ref.current,來儲存此次渲染的狀態。這個作法的原理是利用 useEffect 會在元件渲染之後運作,以及 useRef 更新後不會觸發重新渲染的特性,就能在檯面下默默儲存狀態:
=== 元件初始渲染 ===
👉 元件函式執行,註冊 useRef、useState 初始值
👉 用 State 初始值渲染畫面
👉 useEffect 執行,將 State 初始值賦予給 Ref.current
👉 等待 State 更新,重新渲染畫面...
=== State 更新後,元件重新渲染 ===
👉 元件函式重新執行
👉 從 Ref.current 拿到 State 初始值
👉 用新 State 渲染畫面
👉 useEffect 執行,將新 State 賦予給 Ref.current
👉 等待 State 更新,重覆此步驟
能夠拿到上一個狀態之後,就能用新狀態(當次渲染的狀態)來做比較,比如說:
const [price, setPrice] = useState(168);
const prevPriceRef = useRef(null);
useEffect(() => {
// 將當次渲染的 price 存起來,作為下次渲染比較的依據
prevPriceRef.current = price;
}, [price]);
// 計算價差
const difference =
prevPriceRef.current !== null ? price - prevPriceRef.current : 0;
// ...
像這樣,就可以根據比較前後狀態所產生的數據,來改變畫面的樣式、敘述等等,比如說:
注意事項
一、避免參考用於畫面更新
正如前文所述,參考不會觸發元件的重新渲染,因此將其用於畫面更新容易發生不預期的狀況。
function Counter() {
const countRef = useRef(0);
function handleIncrement() {
countRef.current++; // ⚠️ 不會觸發畫面更新
}
return (
<div>
<p>計數:{countRef.current}</p>
<button onClick={handleIncrement}>增加</button>
</div>
);
};
二、在渲染時直接讀取或修改參考的 current 值
React 的核心原則是「渲染階段必須是純函式 (pure function)」。因此,在渲染階段必須避免出現副作用以及可變資料的讀取或修改。
而參考的 current 值便屬於可變資料,既不會被 React 追蹤,也不會觸發重新渲染,因此容易發生不預期的狀況。
比如說之前範例的自動對焦輸入框,如果這樣寫的話:
function AutoFocusInput() {
const inputRef = useRef(null);
if (inputRef.current) {
inputRef.current.focus();
};
return <input ref={inputRef} />;
};
初次渲染時,在 DOM 還沒有建立前就讀取 inputRef.current,所以它的值將會是 null。因此條件不成立,當輸入框被建立時就不會有對焦效果。
假如說這個元件裡狀態改變發生,觸發重新渲染時,此時 DOM 已經存在,所以 if 條件成立,觸發對焦,但是新的提交階段 (commit) 尚未完成,可能導致不預期的渲染結果。因此需要把這段邏輯放在副作用中,確保在提交階段之後、DOM 已經存在時執行。
function AutoFocusInput() {
const inputRef = useRef(null);
useEffect(() => {
if (inputRef.current) {
inputRef.current.focus();
};
}, []);
return <input ref={inputRef} />;
};
總結
useRef 就像 React 元件的秘密口袋:
- 它能夠保存資料,但不會跟狀態一樣觸發重新渲染
- 在重新渲染之後依然能夠存取到上次的資料,不像一般變數會重置
- 最常用於取得 DOM 元素,操作其原生的 API
- 適合儲存不影響畫面的資料,像是計時器 ID、訂閱的物件實例
參考資料
- React 官方文件:useRef
- Web Dev Simplified:Learn useRef in 11 Minutes























