認識 React 隱藏 Hooks:useCallback、useMemo 與 useRef 效能優化實作

Published on: | Last updated:

好,今天要來聊聊 React hooks。嗯...不是啦,不是要講 `useState` 跟 `useEffect`,我知道,這兩個大家大概都用到爛了,好像寫 React component 就只剩這兩招。我自己也是,剛開始的時候,什麼東西都用 `useState`,然後所有跟外部溝通、拉資料的非同步鳥事,全部塞到 `useEffect` 裡面。

但說真的,這樣寫久了,專案一複雜,你就會開始覺得...欸,好像不太對勁。`useState` 如果狀態一多,整個 component 上面散得到處都是,改一個東西要動好幾個 `set` function;`useEffect` 更恐怖,那個 dependency array 根本是個陷阱,少放一個香,多放一個臭,然後就無限 re-render,或是抓到舊的 state,各種奇怪的 bug 都從那裡來。你一定也遇過吧?

所以今天想來聊的,就是除了這兩個「標配」之外,React 其實還給了很多超實用的「進階版」hooks。它們不是什麼黑魔法,都寫在官方文件裡,只是...你知道的,大家比較懶,或是沒碰到痛點,就比較少去研究。但老實說,一旦你搞懂它們,不只你的 App 效能會變好,你自己寫 code 的那個開發體驗(DX)也會舒服超級多。

重點一句話

簡單講,`useState` 和 `useEffect` 只是你的新手村裝備,學會 `useReducer`、`useTransition` 這些進階 hooks,你才能真的開始處理複雜場景,把 React 的效能和開發體驗榨出來。

useReducer:當你的 state 開始失控時

第一個就要講我個人超愛的 `useReducer`。什麼時候用?就是當你發現你的 component 裡面有好幾個 `useState`,而且它們之間還會互相影響的時候。最經典的例子就是購物車或是一個超複雜的表單。

你想想看,用 `useState` 來做購物車:你可能需要一個 `items` 的 state (array)、一個 `totalPrice` 的 state (number)、可能還有一個 `coupon` 的 state (string)。當你「新增商品」時,你要同時更新 `items` 跟 `totalPrice`。當你「套用優惠券」,你要更新 `coupon`,然後可能又要重算一次 `totalPrice`。你的商業邏輯就這樣散在各個 event handler 裡面,亂七八糟。

`useReducer` 就是來解決這個問題的。它的概念很像 Redux,但超級輕量,只活在你的 component 裡。你把所有「更新 state 的邏輯」全部集中到一個叫做 "reducer" 的 function 裡面。

useReducer 的狀態更新流程示意圖
useReducer 的狀態更新流程示意圖

我們直接看 code 可能比較好懂,就用原文那個購物車的例子,我覺得它寫得蠻好的:


// 這是你的「狀態更新說明書」
function cartReducer(state, action) {
  switch (action.type) {
    case 'ADD_ITEM':
      // 邏輯都在這,很清楚
      return {
        ...state,
        items: [...state.items, action.payload],
        total: state.total + action.payload.price
      };
    case 'REMOVE_ITEM':
      const itemToRemove = state.items.find(item => item.id === action.payload.id);
      if (!itemToRemove) return state; // 防呆
      return {
        ...state,
        items: state.items.filter(item => item.id !== action.payload.id),
        total: state.total - itemToRemove.price
      };
    default:
      // 如果看不懂指令,就什麼都不做
      return state;
  }
}

function ShoppingCart() {
  // 把 reducer 和初始狀態傳進去
  const [cartState, dispatch] = useReducer(cartReducer, { items: [], total: 0 });

  // 你看,component 裡面變得很乾淨
  // 它不用知道「怎麼」新增,它只要「下指令」
  const handleAddToCart = (product) => {
    dispatch({ type: 'ADD_ITEM', payload: product });
  };

  // ... 其他操作也一樣,都是 dispatch 一個 action object
  
  return (
    <div>
      {/* 介面直接用 cartState 裡面的值 */}
      <h2>你的購物車 (${cartState.total.toFixed(2)})</h2>
      {/* ... */}
    </div>
  );
}

看到了嗎?`ShoppingCart` 這個 component 本身變得超笨、超乾淨。它只負責兩件事:顯示 `cartState` 的內容,還有呼叫 `dispatch` 去「下指令」。至於收到 `ADD_ITEM` 這個指令到底該怎麼改 state,那是 `cartReducer` 的事。這樣一來,所有複雜的商業邏輯都被關在 `cartReducer` 這個籠子裡,非常好管理和測試。

我直接做個表,讓你看看跟 `useState` 的差別在哪裡。

useState vs. useReducer 到底差在哪?

比較項目 useState useReducer
適合情境 簡單、獨立的 state。像是一個開關 (boolean)、一個計數器 (number)、輸入框的值 (string)。 好幾個 state 會連動的複雜場景。例如:多步驟表單、購物車、有各種篩選條件的列表。
狀態更新 邏輯散落在各處的 event handler 裡面。改一個小邏輯可能要找半天。 所有更新邏輯都集中在 reducer function 裡,一目了然。超好維護!
可預測性 還行啦,但 bug 比較難追。因為你不知道是哪個 `set` function 出問題。 非常高。你可以直接在 reducer 裡面 `console.log(action)`,就知道是哪個指令造成狀態改變。
效能 如果一次要更新好幾個 state,可能會觸發多次 re-render(雖然 React 18 有自動批次處理,但舊版會)。 通常一次 `dispatch` 就搞定所有相關 state 的更新,只會有一次 re-render。而且,dispatch function 本身的參照是穩定的,這點後面會講到。
我的個人牢騷 新手好朋友,但也是讓你寫出義大利麵 code 的元兇之一。 剛開始要多寫一點 code,但專案一大,你會回來感謝當初選擇用它的自己。

useTransition & useDeferredValue:讓你的 UI 不再卡頓

接下來這兩個是 React 18 的大招,主要是來拯救使用者體驗的。你有沒有做過那種...欸...搜尋框?就是使用者一邊打字,下面列表要一邊即時篩選結果。如果你的資料量一大,比如說幾千筆、幾萬筆,使用者打字快一點,整個畫面就會卡住,因為你的 component 正忙著在那邊跑 filter,沒空理使用者的下一個輸入。

這體驗超差的。`useTransition` 就是為了解決這個問題而生的。

它的概念是,你可以把 state 的更新分成兩種:「緊急的」跟「不那麼緊急的」。

  • 緊急的更新:像是更新輸入框裡面的文字。這必須要馬上反應,不然使用者會覺得電腦壞了。
  • 不緊急的更新(Transition):像是根據輸入框的文字去篩選那幾萬筆資料。這個可以稍微慢一點沒關係,不要卡住整個畫面就好。

你看,`useTransition` 會給你一個 `isPending` 的 flag 和一個 `startTransition` 的 function。你把那個很慢的、很耗效能的 state 更新,包在 `startTransition` 裡面,React 就會知道:「喔!這個更新不急,我可以先處理別的緊急任務,有空再慢慢算。」

使用 useTransition 前後,使用者輸入體驗的對照
使用 useTransition 前後,使用者輸入體驗的對照

function SearchPage() {
  const [inputValue, setInputValue] = useState('');
  const [searchQuery, setSearchQuery] = useState('');
  const [isPending, startTransition] = useTransition();

  const handleInputChange = (e) => {
    // 這是緊急的更新,讓輸入框馬上反應
    setInputValue(e.target.value);

    // 把耗時的更新包起來
    startTransition(() => {
      // 這個更新可以被中斷,不會卡住 UI
      setSearchQuery(e.target.value);
    });
  };

  // 真正拿去 filter 的是 searchQuery
  // const filteredItems = useMemo(() => expensiveFilter(items, searchQuery), [searchQuery]);

  return (
    <div>
      <input value="{inputValue}" onchange="{handleInputChange}">
      {isPending ? <p>資料篩選中...</p> : <displayresults items="{filteredItems}"></displayresults>}
    </div>
  );
}

這樣一來,使用者打字就會超順,因為 `setInputValue` 是馬上執行的。而那個耗時的 `setSearchQuery` 和後續的 filter 計算,會在背景慢慢做。在它還沒算完之前,`isPending` 會是 `true`,你就可以顯示一個 "Loading..." 的提示,使用者體驗直接起飛。

那 `useDeferredValue` 呢?它跟 `useTransition` 很像,但用法不太一樣。`useTransition` 是拿來包「更新 state 的動作」,而 `useDeferredValue` 是拿來包「某個 state 的值」。

你可以想像成,`useDeferredValue` 會回傳一個「比較慢跟上」的版本。當你原本的 state (`inputValue`) 快速變動時,那個 deferred (延遲的) value 會先停在舊的值,等 React 忙完之後,再追上來。這在某些情境下比 `useTransition` 更直覺。

useCallback & useMemo:別再亂用了,好嗎?

好,接下來是效能優化的老班底 `useCallback` 跟 `useMemo`。說真的,這兩個 hooks 大概是被誤用最嚴重的。很多人以為包了就賺到,結果到處亂包,反而讓 code 變更複雜,甚至可能有害效能。

什麼是 `useMemo`?
`useMemo` 是拿來「記住計算結果」的。如果你的 component 裡有一個超級無敵耗時的計算,而且這個計算的依賴項不常變,你就可以用 `useMemo` 把它包起來。這樣,除非依賴項變了,不然 component re-render 的時候,它會直接拿上次算好的結果,不用重算一次。


function ProductList({ products, filter }) {
  // 假設這個 filter 超級複雜,要跑很久
  const filteredProducts = useMemo(() => {
    console.log("正在進行昂貴的計算...");
    return products.filter(p => p.name.includes(filter));
  }, [products, filter]); // 只有 products 或 filter 變了才重算

  return <ul>{/* ... render filteredProducts */}</ul>;
}

重點是「昂貴的計算」。如果你只是 map 一個陣列或做個簡單的字串拼接,拜託,真的不要用 `useMemo`,JS 跑那個超快的,你加上 `useMemo` 本身的開銷,搞不好還變慢。

那 `useCallback` 呢?
`useCallback` 是拿來「記住 function 本身」的。在 JavaScript 裡面,每次 component re-render,你在裡面定義的 function 都會是一個「全新」的 function,即使裡面的程式碼一模一樣。這在大部分情況下都沒差,但只有一個關鍵時刻有差:當你把這個 function 當作 props 傳給一個用 `React.memo` 包起來的子元件時。


// 子元件,用 React.memo 包起來,只有 props 變了才會 re-render
const MyButton = React.memo(function MyButton({ onClick }) {
  console.log("按鈕 re-render 了!");
  return <button onClick={onClick}>點我</button>;
});

function Parent() {
  const [count, setCount] = useState(0);

  // 如果沒有 useCallback,每次 Parent re-render (例如 count 改變)
  // handleClick 都會是新的 function,MyButton 就會一直 re-render
  const handleClick = useCallback(() => {
    console.log("按鈕被點了");
  }, []); // 空依賴,這個 function 永遠不會變

  return (
    <div>
      <p>計數: {count}</p>
      <button onClick={() => setCount(c => c + 1)}>增加計數</button>
      <MyButton onClick={handleClick} />
    </div>
  );
}

所以,`useCallback` 的使用前提通常有兩個:

  1. 你把一個 function 當 props 傳下去。
  2. 接收那個 props 的子元件有用 `React.memo` 或其他方式做優化,會去比較 props 的 reference 是否相同。

如果沒有這個情境,你用了 `useCallback` 基本上只是自嗨,沒什麼實際鳥用。對了,前面提到的 `useReducer` 回傳的那個 `dispatch` function,React 官方保證它本身就是穩定的,不需要用 `useCallback` 包。

在程式碼編輯器中,useCallback 用來穩定函式參考的範例
在程式碼編輯器中,useCallback 用來穩定函式參考的範例

還有一些好用的小東西

除了上面這些大咖,還有幾個雖然小但超實用的 hooks。

  • `useId`:這個超讚。以前我們為了 a11y (無障礙) 要把 `label` 跟 `input` 連起來,都要自己想辦法生 id,例如用 `Math.random()`,但在 SSR (Server-Side Rendering) 的時候,server 跟 client 產生的 id 不一樣就會爆錯。`useId` 就是 React 官方給你一個在 server 和 client 之間保證一致的獨一無二的 ID。超級方便,寫 form 必用。
  • `useSyncExternalStore`:這個比較進階,但概念很重要。當你的 state 不是由 React 管理的時候,例如你是訂閱瀏覽器的 `localStorage` 變化、或是接 WebSocket、或是用 Zustand、Jotai 這類外部狀態管理庫,你就應該用這個 hook。它能保證你的 component 能安全、正確地跟外部狀態同步,避免一些撕裂(tearing)的怪問題。這點跟我們在台灣看到很多專案還在用 `useEffect` 去 `window.addEventListener` 很不一樣,說真的,官方都出這個 hook 了,就是希望你用更安全的方式來做。React 官網的文件寫得很清楚,但如果你去看一些比較舊的教學,可能都還是教你用 `useEffect`,這點要注意一下。

常見錯誤與修正:什麼時候「不該」用這些進階 Hooks?

講了這麼多好處,也要來講講什麼時候不該用。工具是拿來解決問題的,不是拿來炫技的。

  1. 不要為了一兩個 state 就硬上 `useReducer`:如果你的 component 只有 `const [isOpen, setIsOpen] = useState(false);` 這種簡單狀態,用 `useState` 就好。`useReducer` 是給「狀態轉移邏輯複雜」的情境,殺雞不要用牛刀。
  2. 停止「預防性優化」:不要看到 function 就包 `useCallback`,看到計算就包 `useMemo`。這兩個 hook 本身是有成本的。React 已經很快了,你應該是先用 profiler 工具發現了效能瓶頸,再針對性地去優化,而不是憑感覺亂加。我自己是覺得,90% 的情況下你根本不需要它們。
  3. 超快的操作不要用 `useTransition`:如果你的 state 更新本來就很快,一瞬間就完成了,你還用 `startTransition` 包起來,只是徒增複雜度,沒有任何好處。

老實說,我自己覺得最重要的原則是:先用最簡單的方式把功能做出來。`useState`、`useEffect` 能搞定就先用。等到你真的覺得「啊,這裡的邏輯好亂」或「天啊,這裡好卡」,再來考慮是不是該拿出 `useReducer` 或 `useTransition` 這些工具。這樣你的 code 才能在簡潔跟效能之間找到最好的平衡。

好啦,今天大概就分享到這。這些 hooks 真的改變了我寫 React 的方式,希望對你也有幫助。你可以在你的下一個專案裡試試看,或是有沒有因為亂用 `useMemo` 結果踩到什麼雷?也歡迎分享一下你的經驗。

Related to this topic:

Comments