技術0 閱讀

React Hooks: 計算屬性與派生狀態的重構指南

寫 React 時,最容易讓元件發胖的,不是網路請求,也不是動畫,而是「多存了一點狀態」。

一開始你只是覺得這樣寫更順手:

結果寫著寫著,元件裡就變成這樣:

最後元件雖然能跑,但會有一種很典型的氣質: 狀態很多,計算很少,effect 一大堆。

接下來我們從「派生狀態」問題入手,逐步拆解其中的設計問題與解決方式。

一、什麼叫「派生狀態」?

先看幾個常見變數:

這些值有個共同點:

這類值就叫 派生狀態(derived state),也可以把它理解成元件裡的「計算屬性」。

React 官方一直在強調一個原則: > 如果一個值可以從當前 props 或 state 計算出來,那通常就不應該再單獨存一份 state。

為什麼?

因為一旦你把它存進 state,你就得自己維護「它和原始狀態永遠一致」。 而人最容易出錯的地方,恰恰就是「同步兩份,本來應該只有一份的資料」。

二、最經典的反模式:用 useEffect 同步派生狀態

先看一個非常常見的例子:

tsx
function UserCard({
  firstName,
  lastName,
}: {
  firstName: string;
  lastName: string;
}) {
  const [fullName, setFullName] = useState('');

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

  return <div>{fullName}</div>;
}

這段程式碼的問題不是「會報錯」,而是它做了一件完全沒必要的事:

這就是典型的「明明只是一條表達式,卻寫成了兩段生命週期」。

重構後的版本其實只有一句:

tsx
function UserCard({
  firstName,
  lastName,
}: {
  firstName: string;
  lastName: string;
}) {
  const fullName = `${firstName} ${lastName}`;
  return <div>{fullName}</div>;
}

這類重構看上去像是「簡化程式碼」,但本質上是在恢復正確的資料建模:

它不該擁有自己的生命週期。

三、判斷一個值該不該存:看它是不是「獨立事實」

這裡有一個很實用的判斷法: > 一個值只有在它是「獨立事實」時,才值得進入 state。 > 如果它只是別的 state 的變形結果,那它更應該是一個表達式。

比如下面這幾個:

應該存的

因為這些都是使用者輸入、伺服器結果或 UI 當前狀態,它們本身就是事實。

不該存的

因為這些都可以從已有事實推出,不需要單獨再保存一份。

一句更直白的話:

這個邊界一旦清楚,很多元件會立刻瘦下來。

四、一個真實重構:列表過濾別再「存兩份」

先看一段典型壞程式碼:

tsx
function ProductList({
  products,
  keyword,
}: {
  products: Product[];
  keyword: string;
}) {
  const [filteredProducts, setFilteredProducts] = useState<Product[]>([]);

  useEffect(() => {
    const next = products.filter(product =>
      product.name.toLowerCase().includes(keyword.toLowerCase())
    );
    setFilteredProducts(next);
  }, [products, keyword]);

  return (
    <ul>
      {filteredProducts.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

問題和前面一樣:

更合理的版本:

tsx
function ProductList({
  products,
  keyword,
}: {
  products: Product[];
  keyword: string;
}) {
  const filteredProducts = products.filter(product =>
    product.name.toLowerCase().includes(keyword.toLowerCase())
  );

  return (
    <ul>
      {filteredProducts.map(product => (
        <li key={product.id}>{product.name}</li>
      ))}
    </ul>
  );
}

這時候資料流非常清楚:

沒有中間同步層,沒有 effect,沒有「某次漏更新」的風險。

五、那 useMemo 呢?什麼時候該用,什麼時候別亂用?

React 官方強調的是: 先直接計算,只有在計算真的昂貴,或者你需要穩定引用時,再考慮 useMemo。

也就是說,優先級應該是:

  1. 先問:能不能直接算?

  2. 再問:這段計算是不是夠重,值得快取?

  3. 再問:是不是因為下游 memo 化元件需要穩定引用?

比如這段:

tsx
const fullName = `${firstName} ${lastName}`;

完全沒必要 useMemo。 因為它幾乎沒有成本,包一層 memo 反而增加心智負擔。

但像這種:

tsx
const sortedProducts = useMemo(() => {
  return [...products]
    .filter(p => p.visible)
    .sort((a, b) => a.price - b.price);
}, [products]);

就可能有意義,因為:

不過也要注意,社群裡對 useMemo 有個很常見的誤區: 把所有陣列操作都當成「昂貴計算」。

Developer Way 的觀點很值得參考——很多時候真正貴的不是 JS 過濾本身,而是整棵子樹的重渲染。所以 useMemo 應該是精確工具,而不是預設按鈕。

所以結論是: > useMemo 是給重計算和穩定引用準備的,不是給所有派生值準備的。

六、派生狀態最容易引發的 3 類問題

1. 狀態失同步

這是最直接的。

tsx
const [items, setItems] = useState<Product[]>([]);
const [count, setCount] = useState(0);

useEffect(() => {
  setCount(items.length);
}, [items]);

問題在於:

而如果你直接寫:

tsx
const count = items.length;

這個問題根本不會出現。

2. 鏈式副作用

這是很多大元件後期最噁心的味道:

tsx
useEffect(() => {
  setFiltered(...);
}, [items, keyword]);

useEffect(() => {
  setSorted(...);
}, [filtered]);

useEffect(() => {
  setCount(sorted.length);
}, [sorted]);

這類程式碼看起來「很有層次」,但本質上是在用 effect 手工搭一條資料加工流水線。 eslint-plugin-react-you-might-not-need-an-effect 甚至專門有規則去攔這種寫法,比如 no-derived-stateno-chain-state-updates

正確思路應該是把它還原成表達式鏈:

tsx
const filtered = ...
const sorted = ...
const count = sorted.length

這才是 React 元件應該有的資料流。

3. 多一次渲染,邏輯還更繞

這是很多人最開始感覺不到,但長期會很傷的點。

用 effect 同步派生狀態,通常都會走這條路:

  1. 先按舊值渲染一次。

  2. effect 在提交後執行。

  3. 呼叫 setState。

  4. 再渲染一次新值。

也就是說,一個本來渲染時順手算一下就能完成的事情,被拆成了兩輪。

這不僅浪費效能,更重要的是讓讀程式碼的人更難理解:

七、什麼時候「派生狀態」真的值得單獨存?

說到這裡,容易出現另一個極端: 是不是所有 derived state 都絕對不能進 state?

也不是。

有幾類情況,單獨存是合理的。

1. 它雖然來自 props,但之後會被使用者獨立修改

比如一個編輯表單:

tsx
function Editor({ initialTitle }: { initialTitle: string }) {
  const [title, setTitle] = useState(initialTitle);
  // 使用者後續會手動修改 title
}

這裡 title 雖然初始來源於 props,但它後面已經成了元件自己的可變狀態。 它不再只是一個純派生值。

不過這種場景仍然要小心,不要一邊讓它受 props 控制,一邊又在內部自由修改,否則很容易進入「雙資料源」地獄。

2. 你需要記錄「歷史」或「過渡過程」

比如:

這些值雖然和別的狀態有關,但已經不只是「當前值的計算結果」,而是一個有時間維度的獨立事實。

3. 你要故意快取某個結果,但快取本身是業務語意

比如本地草稿、快照、復原棧。 這時候「快取」不是效能手段,而是業務需求。

所以這篇真正想講的不是「派生狀態絕對不能存」,而是: > 如果你只是為了讓 UI 展示一個可以現算的值,那別存。 > 如果你存它,是因為它已經擁有自己的業務語意,那可以考慮存。

八、一個完整重構示例:從「狀態管理」改回「資料表達」

先看壞程式碼:

tsx
function TodoPanel({ todos, filter }: Props) {
  const [visibleTodos, setVisibleTodos] = useState<Todo[]>([]);
  const [completedCount, setCompletedCount] = useState(0);
  const [remainingCount, setRemainingCount] = useState(0);

  useEffect(() => {
    const next = todos.filter(todo => {
      if (filter === 'all') return true;
      if (filter === 'done') return todo.done;
      return !todo.done;
    });

    setVisibleTodos(next);
  }, [todos, filter]);

  useEffect(() => {
    setCompletedCount(visibleTodos.filter(todo => todo.done).length);
    setRemainingCount(visibleTodos.filter(todo => !todo.done).length);
  }, [visibleTodos]);

  return (
    <div>
      <div>已完成:{completedCount}</div>
      <div>未完成:{remainingCount}</div>
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}

這類程式碼很像很多業務專案裡的現實情況:

重構後:

tsx
function TodoPanel({ todos, filter }: Props) {
  const visibleTodos = useMemo(() => {
    return todos.filter(todo => {
      if (filter === 'all') return true;
      if (filter === 'done') return todo.done;
      return !todo.done;
    });
  }, [todos, filter]);

  const completedCount = useMemo(() => {
    return visibleTodos.filter(todo => todo.done).length;
  }, [visibleTodos]);

  const remainingCount = visibleTodos.length - completedCount;

  return (
    <div>
      <div>已完成:{completedCount}</div>
      <div>未完成:{remainingCount}</div>
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>{todo.title}</li>
        ))}
      </ul>
    </div>
  );
}

如果資料量不大,這裡還能進一步簡化,連第二個 useMemo 都不要:

tsx
const completedCount = visibleTodos.filter(todo => todo.done).length;
const remainingCount = visibleTodos.length - completedCount;

這一版程式碼最大的變化不是「更短」,而是:

九、這一篇可以帶走的核心結論

  1. state 應該存「事實」,不是存「公式結果」。

  2. 如果一個值能由 props/state 直接算出,它通常不該再進入 state。

  3. 用 effect 同步派生狀態,會製造額外渲染、鏈式更新和失同步風險。

  4. useMemo 是優化工具,不是預設寫法;先直接算,再決定要不要快取。

  5. 真正值得單獨存的「派生值」,往往已經不是純派生,而是帶有業務語意、歷史語意或用戶編輯語意的獨立事實。

SHARE

分享

分享這篇文章。