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

寫 React 時,最容易讓元件發胖的,不是網路請求,也不是動畫,而是「多存了一點狀態」。
一開始你只是覺得這樣寫更順手:
過濾後的列表存一下。
總價存一下。
已完成數量存一下。
當前表單是不是有效,也順手存一下。
結果寫著寫著,元件裡就變成這樣:
原始資料一份。
派生資料再來一份。
每份資料後面跟一個 effect,負責保持同步。
最後元件雖然能跑,但會有一種很典型的氣質: 狀態很多,計算很少,effect 一大堆。
接下來我們從「派生狀態」問題入手,逐步拆解其中的設計問題與解決方式。
一、什麼叫「派生狀態」?
先看幾個常見變數:
fullName = firstName + ' ' + lastNamecompletedCount = todos.filter(t => t.done).lengthvisibleItems = 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>;
}這類重構看上去像是「簡化程式碼」,但本質上是在恢復正確的資料建模:
firstName、lastName是源資料。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>
);
}這時候資料流非常清楚:
products和keyword變了;filteredProducts自然跟著變。
沒有中間同步層,沒有 effect,沒有「某次漏更新」的風險。
五、那 useMemo 呢?什麼時候該用,什麼時候別亂用?
React 官方強調的是: 先直接計算,只有在計算真的昂貴,或者你需要穩定引用時,再考慮 useMemo。
也就是說,優先級應該是:
先問:能不能直接算?
再問:這段計算是不是夠重,值得快取?
再問:是不是因為下游 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-state 和 no-chain-state-updates。
正確思路應該是把它還原成表達式鏈:
const filtered = ...
const sorted = ...
const count = sorted.length這才是 React 元件應該有的資料流。
3. 多一次渲染,邏輯還更繞
這是很多人最開始感覺不到,但長期會很傷的點。
用 effect 同步派生狀態,通常都會走這條路:
先按舊值渲染一次。
effect 在提交後執行。
呼叫 setState。
再渲染一次新值。
也就是說,一個本來渲染時順手算一下就能完成的事情,被拆成了兩輪。
這不僅浪費效能,更重要的是讓讀程式碼的人更難理解:
到底哪個才是源資料?
為什麼這裡要晚一拍?
為什麼一個展示值也有自己的 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 互相接力。
九、這一篇可以帶走的核心結論
state 應該存「事實」,不是存「公式結果」。
如果一個值能由 props/state 直接算出,它通常不該再進入 state。
用 effect 同步派生狀態,會製造額外渲染、鏈式更新和失同步風險。
useMemo是優化工具,不是預設寫法;先直接算,再決定要不要快取。真正值得單獨存的「派生值」,往往已經不是純派生,而是帶有業務語意、歷史語意或用戶編輯語意的獨立事實。
在 Google 上持續關注
把 HeyBinyang 加入 Google 首選來源
如果你希望之後在 Google 上更容易看到我的更新,可以把這個站點加入 preferred source,讓它在相關閱讀情境裡更容易被找到。
SHARE
分享
分享這篇文章。