React 開發指南 - 避免常見錯誤
以下列出 hooks 使用上常見錯誤
useEffect
身為 React 開發者,你可能經常使用 useEffect 來處理各種邏輯。
它是一個 React Hook,讓你能夠使元件與外部系統同步,
但很多時候,我們其實不需要 Effect。過度使用 Effect 會讓程式碼變得複雜、效能低下,甚至容易出錯。
什麼時候你真的需要 Effect?
Effect 是 React 的「逃脫機制」(Escape hatch),主要用於與外部系統同步,例如:
- 與第三方 API 整合
- 操作 DOM
- 設置訂閱或計時器
- 與瀏覽器 API 互動
如果你的邏輯只涉及 props 或 state 的變化,那你可能不需要 Effect。
Escape hatch
代表 useEffect 讓元件能夠與 React 渲染流程之外的系統進行同步
常見的 Effect 濫用場景
1. 根據 props 或 state 來更新 state
❌ 避免這樣做:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selection, setSelection] = useState(null);
useEffect(() => {
setSelection(null); // 會導致額外的重新渲染
}, [items]);
// 每當items發生變化時,List其子元件會先使用舊的selection值來渲染。
// 然後 React 會更新 DOM 並執行 Effect。最後,呼叫setSelection(null)將導致List其子元件重新渲染,重新啟動整個流程。
}
根據 props 或 state 來調整狀態都會使資料流更難以理解和調試
✅ 更好的做法:
function List({ items }) {
const [isReverse, setIsReverse] = useState(false);
const [selectedId, setSelectedId] = useState(null);
// ✅ 非常好:在渲染期間計算所需內容
const selection = items.find((item) => item.id === selectedId) ?? null;
// ...
}
如果一個值可以基於現有的 props 或 state 計算得出,不要為了渲染使用 useEffect 轉換數據,而是在渲染期間直接計算這個值。
可以從 React 哲學 中了解什麼值應該作為狀態。
為什麼這樣更好?
- 避免多餘的重新渲染
- 程式碼更簡潔
- 減少出錯機會
2. 處理昂貴的計算
如果計算成本很高,使用 useMemo 而不是 useEffect:
✅ 正確的做法:
function TodoList({ todos, filter }) {
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter);
}, [todos, filter]);
return <ul>{/* 渲染 todos */}</ul>;
}
可以透過以下方式測量計算成本:
console.time("filter array");
const visibleTodos = filterTodos(todos, tab);
console.timeEnd("filter array");
如果記錄的總時間加起來相當可觀(例如,1ms 或更多),則可能需要記住該計算結果 - 官方建議
3. 重置元件狀態
❌ 避免在 Effect 中重置狀態:
function ProfilePage({ userId }) {
const [comment, setComment] = useState("");
useEffect(() => {
setComment(""); // 會導致額外的重新渲染
}, [userId]);
}
✅ 使用 key 來重置元件:
function ProfilePage({ userId }) {
// ✅ 當 key 變化時,該元件的 comment 或其他 state 會自動重置
return <Profile key={userId} userId={userId} />;
}
function Profile({ userId }) {
const [comment, setComment] = useState(""); // 自動重置
}
4. 處理用戶事件
❌ 不要在 Effect 中處理用戶操作:
function ProductPage({ product }) {
useEffect(() => {
if (product.isInCart) {
showNotification("商品已加入購物車!");
}
}, [product]);
}
如果這個邏輯是由某些特定的事件引起的,則其會傳回在對應的事件處理函數中。
如果是由使用者在螢幕上元件看到時引起的,那麼它保留在 Effect 中。
✅ 在事件處理器中處理:
function ProductPage({ product, addToCart }) {
function handleAddToCart() {
addToCart(product);
showNotification("商品已加入購物車!");
}
return <button onClick={handleAddToCart}>加入購物車</button>;
}
5. 不理解 Effect 的「快照」特性
這是最容易被忽視但極其重要的概念:每次渲染都會創建全新的 Effect 函數,它們會「記住」當時的 props 和 state。
基本概念:每個渲染都有自己的一切
function Counter() {
const [count, setCount] = useState(0);
// 第一次渲染時:count = 0
// 第二次渲染時:count = 1
// 第三次渲染時:count = 2
// 每次都是不同的常數!
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${count} times`);
}, 3000);
}); // ✅ 沒有依賴陣列,每次渲染都會執行
return (
<div>
<p>You clicked {count} times</p>
<button onClick={() => setCount(count + 1)}>Click me</button>
</div>
);
}
實驗: 快速點擊按鈕 3 次,然後等 3 秒,你會看到:
"You clicked 0 times" // 第一次渲染的 Effect
"You clicked 1 times" // 第二次渲染的 Effect
"You clicked 2 times" // 第三次渲染的 Effect
為什麼會這樣? 每次渲染實際上創建了不同的 Effect 函數:
// 第一次渲染
function Counter() {
const count = 0; // 來自 useState
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${0} times`); // 拿到 0
}, 3000);
});
}
// 第二次渲染
function Counter() {
const count = 1; // 來自 useState
useEffect(() => {
setTimeout(() => {
console.log(`You clicked ${1} times`); // 拿到 1
}, 3000);
});
}
若需要讀取最新值而不是快照值
記住: useEffect 內的所有值都是該次渲染的「快照」,這是 React Hooks 的設計特色,不是 bug!
function Counter() {
const [count, setCount] = useState(0);
useEffect(() => {
const id = setInterval(() => {
// 使用update function更新,不依賴當前值
setCount((prevCount) => prevCount + 1);
}, 1000);
return () => clearInterval(id);
}, []); // 空依賴陣列是安全的
}
useEffect 使用情境
記住,Effect 是 React 的「逃脫機制」,只有在需要「跳出 React」時才使用。
正確使用場景
- 連接外部系統
function ChatRoom({ roomId }) {
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);
}
直接在 useEffect 中取得資料通常意味著無需預先載入或快取資料。
例如,如果元件卸載後又重新掛載,則必須重新取得資料。
需考慮
如果使用框架、Cleint side fetch ( React Query、useSWR ),可以使用其內建的資料擷取機制代替 useEffect
function Profile({ userId }) {
const [user, setUser] = useState(null);
useEffect(() => {
let ignore = false;
fetchUser(userId).then((data) => {
if (!ignore) {
setUser(data);
}
});
return () => {
ignore = true; // 可以防止快速切換時,舊請求的結果覆蓋新數據。
};
}, [userId]);
}
可參考 What are good alternatives to data fetching in Effects?
- 函數依賴處理 → 替換或補強現有的「useEffect 使用情境」部分
進階技巧: 處理 function 依賴
原則:函數也是數據流的一部分
方法一:移到 Effect 內部
function SearchResults() {
useEffect(() => {
// ✅ 函數在內部,清晰可見所有依賴
function getFetchUrl() {
return "https://api.example.com/search";
}
getFetchUrl(); // 使用函數
}, []);
}
方法二:提升到元件外部
// ✅ 純函數,不依賴元件狀態
function getFetchUrl(query) {
return `https://api.example.com/search?q=${query}`;
}
function SearchResults() {
useEffect(() => {
getFetchUrl("react"); // 安全使用
}, []);
}
方法三:useCallback 包裝
function SearchResults() {
const [query, setQuery] = useState("react");
const getFetchUrl = useCallback(() => {
return `https://api.example.com/search?q=${query}`;
}, [query]); // 明確聲明依賴
useEffect(() => {
getFetchUrl();
}, [getFetchUrl]);
}
檢查清單
在寫 useEffect 之前,問問自己:
- 這個邏輯是因為元件顯示而觸發的嗎? → 可能需要 useEffect
- 這個邏輯是因為用戶操作而觸發的嗎? → 應該放在事件處理器中
- 這個資料可以從現有的 props/state 計算得出嗎? → 不需要 useEffect
- 這個計算很昂貴嗎? → 使用
useMemo - 需要重置元件狀態嗎? → 考慮使用
key
詳細請參考 官方文件
useMemo
useMemo 是一個 React Hook,讓你在重新渲染之間快取計算結果。
雖然它看起來是個很棒的效能優化工具,但很多開發者會過度使用或在錯誤的場景中使用 useMemo,
反而可能導致程式碼複雜度增加,甚至效能下降。
常見的 useMemo 錯誤使用
1. 過度使用 useMemo - 快取不需要的計算
❌ 沒有必要的快取:
function UserProfile({ firstName, lastName }) {
// 🔴 完全不必要的 useMemo - 字串連接本身就很快
const fullName = useMemo(() => {
return firstName + " " + lastName;
}, [firstName, lastName]);
// 🔴 更不必要的快取 - 這種計算幾乎沒有成本
const displayName = useMemo(() => {
return fullName.toUpperCase();
}, [fullName]);
return <div>{displayName}</div>;
}
✅ 更好的做法:
function UserProfile({ firstName, lastName }) {
// ✅ 直接計算即可,非常輕量
const fullName = firstName + " " + lastName;
const displayName = fullName.toUpperCase();
return <div>{displayName}</div>;
}
為什麼不需要? 簡單的字串操作、數學運算、邏輯判斷等都是極輕量的操作,快取它們不會帶來效能提升,反而增加了記憶體開銷。
2. 忘記添加依賴陣列
❌ 缺少依賴陣列:
function ProductList({ products, filter }) {
// 🔴 沒有依賴陣列,每次都會重新計算
const filteredProducts = useMemo(() => {
return products.filter(
(product) =>
product.category === filter.category && product.price >= filter.minPrice
);
}); // 缺少依賴陣列
return <div>{/* 渲染產品 */}</div>;
}
✅ 正確的做法:
function ProductList({ products, filter }) {
const filteredProducts = useMemo(() => {
return products.filter(
(product) =>
product.category === filter.category && product.price >= filter.minPrice
);
}, [products, filter]); // ✅ 包含所有依賴
return <div>{/* 渲染產品 */}</div>;
}
3. 依賴包含每次都變化的物件
❌ 依賴不穩定的物件:
function SearchResults({ query, filters }) {
const searchOptions = {
// 🔴 每次渲染都是新物件
includeInactive: false,
sortBy: "relevance",
...filters, // 展開的物件也可能每次都不同
};
// 🔴 searchOptions 每次都不同,memo 失效
const results = useMemo(() => {
return searchItems(query, searchOptions);
}, [query, searchOptions]);
}
✅ 正確的做法:
function SearchResults({ query, filters }) {
const results = useMemo(() => {
// ✅ 在計算函數內部創建物件
const searchOptions = {
includeInactive: false,
sortBy: "relevance",
...filters,
};
return searchItems(query, searchOptions);
}, [query, filters]); // 只依賴真正變化的值
}
4. 使用 useMemo 快取函數
❌ 用 useMemo 包裝函數:
function ProductPage({ productId, referrer }) {
// 🔴 語法笨拙,有更好的方式
const handleSubmit = useMemo(() => {
return (orderDetails) => {
post("/product/" + productId + "/buy", {
referrer,
orderDetails,
});
};
}, [productId, referrer]);
return <Form onSubmit={handleSubmit} />;
}
✅ 使用 useCallback:
function ProductPage({ productId, referrer }) {
// ✅ useCallback 專門用於快取函數
const handleSubmit = useCallback(
(orderDetails) => {
post("/product/" + productId + "/buy", {
referrer,
orderDetails,
});
},
[productId, referrer]
);
return <Form onSubmit={handleSubmit} />;
}
5. 在迴圈中使用 useMemo
❌ 在 map 中調用 Hook:
function ReportList({ items }) {
return (
<article>
{items.map((item) => {
// 🔴 不能在迴圈中調用 useMemo
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure key={item.id}>
<Chart data={data} />
</figure>
);
})}
</article>
);
}
✅ 提取元件:
function ReportList({ items }) {
return (
<article>
{items.map((item) => (
<Report key={item.id} item={item} />
))}
</article>
);
}
function Report({ item }) {
// ✅ 在元件頂層調用 useMemo
const data = useMemo(() => calculateReport(item), [item]);
return (
<figure>
<Chart data={data} />
</figure>
);
}
6. 錯誤的物件回傳語法
❌ 語法錯誤:
function SearchForm({ query }) {
// 🔴 箭頭函數語法錯誤,會回傳 undefined
const searchOptions = useMemo(() => {
matchMode: 'whole-word',
caseSensitive: false,
query: query
}, [query]);
}
✅ 正確的語法:
function SearchForm({ query }) {
// ✅ 方法一:明確的 return 陳述
const searchOptions = useMemo(() => {
return {
matchMode: "whole-word",
caseSensitive: false,
query: query,
};
}, [query]);
// ✅ 方法二:用括號包圍物件
const searchOptions2 = useMemo(
() => ({
matchMode: "whole-word",
caseSensitive: false,
query: query,
}),
[query]
);
}