HeyBinyang

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

·技術·visibility3 閱讀 ·React

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

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

  • 過濾後的列表存一下。

  • 總價存一下。

  • 已完成數量存一下。

  • 當前表單是不是有效,也順手存一下。

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

  • 原始資料一份。

  • 派生資料再來一份。

  • 每份資料後面跟一個 effect,負責保持同步。

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

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

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

先看幾個常見變數:

  • fullName = firstName + ' ' + lastName

  • completedCount = todos.filter(t => t.done).length

  • visibleItems = items.filter(i => i.visible)

  • totalPrice = cartItems.reduce(...)

這些值有個共同點:

  • 它們不是獨立事實。

  • 它們都可以由已有的 props 或 state 計算出來。

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

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

為什麼?

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

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

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

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

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

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

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

  • fullName 本來就是 firstName + lastName

  • 結果現在你額外開了一個 state。

  • 又額外開了一個 effect 去維持它。

  • 然後每次 props 變化,元件先渲染一次舊值,再跑 effect,再 setState,再渲染一次新值。

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

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

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

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

  • firstNamelastName 是源資料。

  • fullName 是結果,不是源資料。

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

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

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

比如下面這幾個:

應該存的

  • 輸入框當前文字。

  • 當前選中的 tab。

  • 請求返回的原始列表。

  • 使用者是否展開彈層。

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

不該存的

  • 過濾後的列表。

  • 某個條件下的統計數量。

  • 兩個欄位拼接後的展示文案。

  • 是否為空、是否命中條件、是否需要高亮。

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

一句更直白的話:

  • state 存事實

  • 變數存計算結果

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

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

先看一段典型壞程式碼:

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>
  );
}

問題和前面一樣:

  • filteredProducts 不是獨立狀態。

  • 它只是 products + keyword 的一個函數結果。

  • 你為了「快取」它,反而引入了額外渲染和同步負擔。

更合理的版本:

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>
  );
}

這時候資料流非常清楚:

  • productskeyword 變了;

  • filteredProducts 自然跟著變。

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

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

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

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

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

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

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

比如這段:

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

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

但像這種:

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. 狀態失同步

這是最直接的。

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

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

問題在於:

  • 你現在維護了兩份本來應該是一份的資訊。

  • 某一天有人改了 count,或者某條路徑沒走這個 effect,UI 就錯了。

而如果你直接寫:

const count = items.length;

這個問題根本不會出現。

2. 鏈式副作用

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

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

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

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

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

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

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

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

  1. 先按舊值渲染一次。

  2. effect 在提交後執行。

  3. 呼叫 setState。

  4. 再渲染一次新值。

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

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

  • 到底哪個才是源資料?

  • 為什麼這裡要晚一拍?

  • 為什麼一個展示值也有自己的 state?

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

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

也不是。

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

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

比如一個編輯表單:

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

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

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

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

比如:

  • 上一次滾動位置。

  • 動畫過程中的中間值。

  • 使用者上一次提交時的快照。

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

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

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

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

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

先看壞程式碼:

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>
  );
}

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

  • 邏輯沒錯。

  • 但整段元件像是在「維護快取系統」。

重構後:

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 都不要:

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

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

  • 狀態只剩真正的源資料。

  • 其餘都是可讀、可追蹤的表達式。

  • 資料關係是一條線,不是 effect 互相接力。

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

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

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

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

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

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

分享