useState 真的那么簡單嗎?我在項目里踩過的坑
我敢打賭,你一定遇到過這種情況:
某天下午,同事在群里問:"咋回事啊,用戶點了按鈕,狀態改了,但列表沒更新啊?"
你開始調試,F12 打開,state 里的數據明明改了,UI 就是沒反應。折騰半小時,最后發現——是直接改了數組,沒有創建新對象。
或者這樣:你看到前輩寫的代碼里,useState 十來個,各種奇怪的副作用代碼到處都是,改一個字段牽一發動全身。最后索性不敢動,怕出bug。
我也是這樣過來的。慢慢才明白,useState 看似簡單,但很多人用了好幾年也沒真正吃透。
我剛工作時的懵逼時刻
那時候我對 useState 的理解就是:"就是個變量唄,用 setCount 改改值。"
const [count, setCount] = useState(0);
function handleClick() {
setCount(count + 1);
console.log(count); // 我以為會是 1
}結果呢?還是 0。
我就很納悶啊,為什么我改了還是 0?我甚至問過 ChatGPT(那時候還沒有,我查的文檔)。文檔里說"狀態更新是異步的",我當時理解得一知半解——"異步","什么鬼?為什么要異步?"
后來在大佬的指點下才明白:
React 不是你改了就立刻生效的。 你調用 setCount 時,你只是告訴 React:"嘿,請幫我把這個事兒加到待辦清單里。" React 會合并你的所有更新,一起處理,然后才重新渲染頁面。
當前這一幀里,count 的值是凍住的。你讀不到新值。只有下一幀重新渲染時,你才能看到新的 count。
想象一下,你在銀行存錢。你告訴柜員:"我要存 100 塊。" 柜員記錄下來,然后排隊處理了你和其他 10 個人的請求,一起更新系統。你不能指望她立刻告訴你新余額——還得等系統更新完。
setState 之后立刻讀值?真正的坑來了
我第一次被這個坑害慘了。那是一個下午,做一個秒殺活動的功能:
function handleBuy() {
// 用戶點擊"立即下單"
setCount(count - 1);
// 我以為這里 count 已經改了
if (count <= 0) {
// 庫存不足,彈窗提示
alert('已售罄');
}
}結果呢?用戶一直能點"下單",count 怎么都減不完。
因為每次我判斷的 count,都是上一幀的值。我在 setState 之后立刻判斷,用的還是舊的 count。
后來在測試提bug的時候才發現——這和我們的庫存系統不一致!
我學到的第一課:不要試圖在 setState 之后立刻用新值。把你的邏輯分開。如果你需要根據新狀態做什么事,放到下一個組件渲染周期里,或者用 useEffect。
// 正確的做法
function handleBuy() {
const newCount = count - 1;
setCount(newCount);
// 不在這里判斷,而是在組件渲染時判斷
}
// 或者用 useEffect 監聽
useEffect(() => {
if (count <= 0) {
alert('已售罄');
}
}, [count]);多次 setState,為什么只有最后一個生效?
我還遇到過更尷尬的事兒。
我們的活動頁面有個禮券碼兌換的功能。用戶輸入一個優惠碼,我需要做三件事:
- 驗證碼的有效性
- 獲取折扣信息
- 更新用戶的優惠券列表
我一開始這樣寫的:
function redeemCoupon(code) {
setIsPending(true);
setError(null);
setDiscount(null);
// 驗證并獲取
const result = await validateCoupon(code);
if (result.success) {
setCoupons(result.coupons); // 更新券列表
setDiscount(result.discount); // 設置折扣
} else {
setError(result.message); // 設置錯誤
}
setIsPending(false);
}這看起來沒問題,但實際場景更復雜。后來我們發現,如果用戶快速操作(網絡稍微慢一點),前面的狀態會被后面的覆蓋。
我就很郁悶啊,明明設置了啊,為什么沒有?
真相是:我多次調用 setXxx,React 會合并這些更新。但每次都用的是同一個快照的舊值。
比如說,我有個遞增的需求:
function increment() {
setCount(count + 1);
setCount(count + 1);
setCount(count + 1);
}結果只加了 1,不是 3。因為三行代碼用的都是同一個 count 值。相當于:
setCount(5 + 1); // 6
setCount(5 + 1); // 還是 6
setCount(5 + 1); // 還是 6,最后取最后一個后來我的前輩教我用函數式更新:
function increment() {
setCount(c => c + 1); // React 給我最新的值
setCount(c => c + 1); // React 再給我新的值
setCount(c => c + 1); // React 再給我新的值
}這樣每次 React 都會把最新的值傳給我,我這樣操作就行了:
setCount(c => c + 1);這個單獨一行,看似簡單,但威力巨大。
從"列表管理"學到的狀態分組智慧
我們有個后臺系統,要管理一個用戶列表。一開始我就這樣做:
const [users, setUsers] = useState([]);
const [totalCount, setTotalCount] = useState(0);
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState(null);
const [currentPage, setCurrentPage] = useState(1);五個 state,感覺很"完善"。
但問題來了。有一次,我在刪除用戶的時候,更新了 users 列表,但忘了更新 totalCount。結果用戶界面顯示的總數和實際列表數不符。
我坐在那里狂敲代碼調試,最后發現——我在三個地方都需要同步這兩個值。改一個忘一個。
后來我看到老大哥怎么做的,才恍然大悟:
// 按照"更新頻率"和"相關性"分組
const [listData, setListData] = useState({
users: [],
totalCount: 0
});
const [pagination, setPagination] = useState({
currentPage: 1,
pageSize: 10
});
const [uiState, setUiState] = useState({
isLoading: false,
error: null
});這樣分組的好處是:
- listData 總是一起更新,你不會忘記同步 users 和 totalCount
- pagination 獨立變化,改頁碼時不會影響其他
- uiState 是通用的加載態和錯誤態,可以復用
更新時就變得清晰了:
function fetchUsers() {
setUiState({ isLoading: true, error: null });
api.getUsers(pagination.currentPage).then(res => {
setListData({
users: res.data,
totalCount: res.total
});
setUiState({ isLoading: false, error: null });
}).catch(err => {
setUiState({ isLoading: false, error: err.message });
});
}再也不會出現數據不一致的問題了。
那個"衍生數據"的坑,差點被我重復踩
有一次,我在做一個購物車頁面。用戶可以添加/刪除商品,我需要顯示:
- 購物車里的商品列表
- 購物車總價
- 商品數量
一開始我"聰明"地這樣做:
const [items, setItems] = useState([]);
const [totalPrice, setTotalPrice] = useState(0);
const [itemCount, setItemCount] = useState(0);
function addItem(product) {
const newItems = [...items, product];
setItems(newItems);
setTotalPrice(totalPrice + product.price); // 我自己維護總價
setItemCount(itemCount + 1); // 我自己維護數量
}
function removeItem(productId) {
const removed = items.find(i => i.id === productId);
setItems(items.filter(i => i.id !== productId));
setTotalPrice(totalPrice - removed.price);
setItemCount(itemCount - 1);
}你能看出問題嗎?
我在三個不同的地方維護著三份"真相"。只要有任何一個地方出錯,整個購物車就亂套。
果然,后來有個 bug 出現了:用戶點擊"全選"之后,數量顯示和實際列表對不上。我排查了半天,發現是某個角落的代碼沒有正確更新 itemCount。
那時候我才意識到——我根本不需要存這些值。
后來我改成:
const [items, setItems] = useState([]);
// 這些都是計算出來的,不需要 state
const itemCount = items.length;
const totalPrice = items.reduce((sum, item) => sum + item.price, 0);
function addItem(product) {
setItems([...items, product]);
// itemCount 和 totalPrice 會自動"更新"
}
function removeItem(productId) {
setItems(items.filter(i => i.id !== productId));
// itemCount 和 totalPrice 會自動"更新"
}這樣就再也不會出現同步問題了。因為根本沒有多個"真相源"。
那次異步 setState 教給我的
我們有一個"點贊"功能。用戶點點贊按鈕,我們要:
- 立刻改變 UI(點贊按鈕變亮)
- 同時發送請求給服務器
我最初是這樣寫的:
const [liked, setLiked] = useState(false);
function handleLike() {
setLiked(!liked);
// 發送請求
api.addLike(postId).catch(err => {
// 如果失敗,恢復狀態
setLiked(liked); // 這里有問題!
});
}看出來了嗎?我在 .catch() 里用的還是舊的 liked 值。
假設用戶很快點了點贊,然后又點了取消。現在 liked 是 false。但如果第一個請求失敗了,我的 catch 回調會把它改回 true(用的是當時的舊快照)。結果用戶的操作就被反轉了。
這是個經典的閉包陷阱。
正確做法是用函數式更新:
function handleLike() {
setLiked(prev => !prev);
api.addLike(postId).catch(() => {
setLiked(prev => !prev); // 恢復前一個狀態
});
}這樣不管發生了什么,我都是基于"最新的狀態"來操作,不會出錯。
計算型初始值,一個看不見的性能漏洞
我們有個很復雜的儀表板。首次加載時,需要做一堆初始化:處理大量數據、生成圖表配置、諸如此類的。
我一開始這樣做:
const [config, setConfig] = useState(generateComplexConfig(rawData));問題是:每次這個組件重新渲染,generateComplexConfig 都會被調用一遍。
雖然 React 最后不會真的用這個返回值(它只用第一次的),但 JavaScript 還是浪費了 CPU 去計算。在我們這個場景里,這個函數要跑兩秒鐘。組件每次重新渲染都要卡兩秒,那就離譜了。
后來老大哥教我一個技巧:
const [config, setConfig] = useState(() => generateComplexConfig(rawData));只需要包裝成一個函數,React 就只會在初始化時調用它。之后重新渲染時,它就不會再調用這個函數了。
這叫"懶初始化"。看起來簡單,但對性能的影響能很顯著。
真正的高手知道什么時候不用 useState
我剛工作時,什么東西都想放在 state 里。結果組件到處都是 useState,到處都是 re-render,到處都是 useEffect 來同步各種奇怪的東西。
后來我才學會問自己一個問題:這個東西真的需要是 state 嗎?
比如說,我們有一個表單,用戶不斷地輸入。每輸入一個字符,我都在計算"還能輸入多少字符"。
// ? 我最初想這樣做
const [text, setText] = useState('');
const [remainingChars, setRemainingChars] = useState(100);
function handleChange(e) {
const newText = e.target.value;
setText(newText);
setRemainingChars(100 - newText.length);
}但這樣的話,輸入框每次改變都會觸發兩次 state 更新,兩次 re-render。
實際上:
// ? 正確做法
const [text, setText] = useState('');
// 直接算,不需要 state
const remainingChars = 100 - text.length;
function handleChange(e) {
setText(e.target.value);
}一行代碼搞定,而且根本沒有多余的 re-render。
還有,我之前想用 state 來存一個"用戶操作了沒"的標志,用來控制要不要顯示某個提示:
// ? 不需要
const [hasOpened, setHasOpened] = useState(false);
if (someCondition && !hasOpened) {
showTip();
setHasOpened(true);
}這會讓組件重新渲染一遍(雖然 UI 可能不會變)。其實我只需要:
// ? 用 useRef
const hasOpenedRef = useRef(false);
if (someCondition && !hasOpenedRef.current) {
showTip();
hasOpenedRef.current = true;
}ref 改變時不會觸發 re-render,所以這里用它最合適。
寫在最后
我和你說這些,不是為了裝逼。而是想讓你知道:
我也是從各種坑里爬出來的。
每一個"原來是這樣"的時刻,都是在項目里被 bug 追著跑的時候學到的。
所以如果你現在寫的代碼不夠完美,項目里還有各種 setState 的問題,這很正常。關鍵是要去理解——為什么會這樣?為什么 React 要異步更新?為什么不能直接改對象?
一旦你真正理解了這些原理,不是背下來,而是在項目里用過幾次,踩過幾個坑,那么回頭看你最開始的代碼,你就會笑出聲來。
然后你會開始寫出更清晰、更少bug、更好維護的代碼。





























