Blog
/
Frontend
Next.js - useState
useState 最常見的陷阱:setState 不馬上生效、直接改物件不會更新、昂貴計算重複執行。今天講清楚為什麼會這樣,以及怎麼用函數式更新、懶惰初始化來解決。
Posted at
2024年10月24日
Posted on
Frontend
useState 是 React 最基礎嘴常用的 Hook,它讓函數型組件可以有自己的狀態。簡單來說,useState 讓你在組件裡定義一個狀態值,當這個值變化時,組件就重新渲染。
前面是當前狀態的值,後面是更新狀態的函數;你可以在任何地方調用 setState 來改變狀態,React 就會觸發重新渲染。
setState 是非同步的
這是 React 一個容易搞混的地方,為什麼 console.log 打出的還是 0,而不是 1?
關鍵原因是 setState 不是馬上生效,而是非同步的。當你調用 setCount(count + 1) 時,React 不會立刻更新狀態,它會把這個更新放到一個隊列裡,等事件處理完後才執行。為什麼 React 會這樣設計?很簡單,如果 setState 是同步的,每次調用都會導致重新渲染。假設你在一個事件裡調用了 10 個 setState,就會重新渲染 10 次,效能會很差。
React 選擇非同步更新,可以把多個 setState 合併成一次渲染,這叫批處理。所以上面的例子如果是這樣:
結果是什麼?count 只會增加 1,不是 3。因為三次 setCount 都參考同一個 count(還是 0),所以最後的值還是 0 + 1 = 1。
這就帶出了下一個問題:閉包陷阱。
閉包陷阱
我覺得這個陷阱值得特別深入講,因為它很容易導致 bug,而且除錯很困難。
假設你在做一個聊天 app,用戶點擊「傳送多條訊息」按鈕,要同時傳送三條訊息:
猜猜結果是什麼?只會顯示「Pretty」。
為什麼?因為三次 addMessage 調用都在同一個事件裡,它們都參考同一個 messages 值(空陣列)。執行流程是這樣的:
第一步,addMessage('Abby') 調用 setMessages([...messages, 'Abby']),也就是 setMessages(['Abby'])。這個更新排隊,還沒執行。
第二步,addMessage('is') 調用 setMessages([...messages, 'is'])。但 messages 還是空陣列,所以是 setMessages(['is'])。這個新的更新覆蓋了前一個。
第三步,addMessage('Pretty') 調用 setMessages(['Pretty']),又覆蓋了。
事件結束,React 執行最後一個排隊的更新,setMessages(['Pretty']),所以最終結果只有一條訊息。解決辦法就是用函數式更新,不要傳一個新值給 setState,而是傳一個函數,這個函數接收前一個狀態,返回新狀態:
這樣的話,每次 setMessages 都會基於最新的 prev 值計算新狀態。即使三次調用在同一個事件裡,React 也會按順序執行,結果就會是三條訊息。
試試看,用函數式更新改過後:
現在結果就對了,會顯示三條訊息:Abby、is、Pretty。
這個模式很重要。記住一個原則,當新狀態依賴前一個狀態時,用函數式更新。
物件狀態的更新
當狀態是一個物件時,容易犯另一個錯誤。比如一個表單,有 name 和 email 兩個欄位:
為什麼不好?因為你直接改了 form 物件,然後傳給 setForm。React 比較的是物件的引用,不是內容。同一個物件引用,React 認為沒變,所以不會重新渲染。用戶改了輸入框,但頁面不更新。
正確的做法是建立一個新物件:
用擴展運算符 ...form 建立新物件,改一個欄位,保留其他欄位。或者用函數式更新會更清楚:
如果表單欄位很多,這樣寫會很繁瑣,我覺得這時候用 useReducer 會更好
Lazy 初始化
有時候初始值的計算很昂貴。比如從 localStorage 讀取,或者執行一個複雜的運算。
不好的做法是直接寫在 useState 裡:
這樣的話,每次組件重新渲染,expensiveCalculation() 都會執行一遍,很浪費。
好的做法是傳一個函數,React 只會在初始化時執行一次:
React 看到你傳的是函數,就會在初始化時執行一次,然後把結果作為初始值,下次重新渲染時就不會再執行了。
實際例子,從 localStorage 讀取:
注意這裡用了函數式初始化讀取 localStorage,也用了函數式更新保存新值。這樣組合很常見。
批次處理的實際影響
我覺得理解批次處理能幫你寫出更高效的 code。
在事件處理裡,React 會自動批處理多個 setState:
但在 Promise、setTimeout、事件委派等非同步場景裡,React 18 之前是不會批處理的。React 18 開始,自動批處理擴展到所有地方。
舉個例子,在 Promise 的 .then 裡:
以前版本的 React 會重新渲染兩次,現在只會一次,這對效能很有幫助。
小結
寫 useState 時,可以問問自己三個問題。首先是新狀態會去依賴前一個狀態嗎?如果依賴,就用函數式更新。再來狀態是物件嗎?如果是那要記得建立新物件,不能直接改。最後還有初始值計算貴嗎?如果貴,用 lazy 初始化。
如果狀態有多個欄位,比如表單的 name、email、password,我覺得用 useReducer 會更清楚。但簡單的情況,useState 就夠了。
下次講講 useEffect,會深入執行順序、cleanup、dependency array 的陷阱。useEffect 也是容易踩坑的地方。



