useState 是 React 最基礎最常用的 Hook,它讓函數型組件可以有自己的狀態。簡單來說,useState 讓你在組件裡定義一個狀態值,當這個值變化時,組件就重新渲染。
const [state, setState] = useState(initialValue)
前面是當前狀態的值,後面是更新狀態的函數;你可以在任何地方調用 setState 來改變狀態,React 就會觸發重新渲染。
setState 是非同步的
'use client'
import { useState } from 'react'
export function Counter() {
const [count, setCount] = useState(0)
const handleClick = () => {
setCount(count + 1)
console.log(count)
}
return (
<div>
<p>{count}</p>
<button onClick={handleClick}>+1</button>
</div>
)
}這是 React 一個容易搞混的地方,為什麼 console.log 打出的還是 0,而不是 1?
關鍵原因是 setState 不是馬上生效,而是非同步的。當你調用 setCount(count + 1) 時,React 不會立刻更新狀態,它會把這個更新放到一個隊列裡,等事件處理完後才執行。為什麼 React 會這樣設計?很簡單,如果 setState 是同步的,每次調用都會導致重新渲染。假設你在一個事件裡調用了 10 個 setState,就會重新渲染 10 次,效能會很差。
React 選擇非同步更新,可以把多個 setState 合併成一次渲染,這叫批處理。所以上面的例子如果是這樣:
const handleClick = () => {
setCount(count + 1)
setCount(count + 1)
setCount(count + 1)
}結果是什麼?count 只會增加 1,不是 3。因為三次 setCount 都參考同一個 count(還是 0),所以最後的值還是 0 + 1 = 1。這就帶出了下一個問題:閉包陷阱。
閉包陷阱
假設你在做一個聊天 app,用戶點擊「傳送多條訊息」按鈕,要同時傳送三條訊息:
'use client'
import { useState } from 'react'
export function ChatList() {
const [messages, setMessages] = useState([])
const addMessage = (text) => {
setMessages([...messages, text])
}
const handleSendMultiple = () => {
addMessage('Abby')
addMessage('is')
addMessage('Pretty')
}
return (
<div>
{messages.map((m, i) => <div key={i}>{m}</div>)}
<button onClick={handleSendMultiple}>Send Multiple</button>
</div>
)
}猜猜結果是什麼?只會顯示「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,而是傳一個函數,這個函數接收前一個狀態,返回新狀態:
const addMessage = (text) => {
setMessages(prev => [...prev, text])
}這樣的話,每次 setMessages 都會基於最新的 prev 值計算新狀態。即使三次調用在同一個事件裡,React 也會按順序執行,結果就會是三條訊息。
而用函數式更新改過後:
'use client'
import { useState } from 'react'
export function ChatList() {
const [messages, setMessages] = useState([])
const addMessage = (text) => {
setMessages(prev => [...prev, text])
}
const handleSendMultiple = () => {
addMessage('Abby')
addMessage('is')
addMessage('Pretty')
}
return (
<div>
{messages.map((m, i) => <div key={i}>{m}</div>)}
<button onClick={handleSendMultiple}>Send Multiple</button>
</div>
)
}結果就會顯示三條訊息:Abby、is、Pretty。
一個原則:當新狀態依賴前一個狀態時,用函數式更新。
物件狀態的更新
當狀態是一個物件時,容易犯另一個錯誤。比如一個表單,有 name 和 email 兩個欄位:
'use client'
import { useState } from 'react'
export function Form() {
const [form, setForm] = useState({ name: '', email: '' })
const handleNameChange = (e) => {
form.name = e.target.value
setForm(form)
}
return (
<div>
<input
value={form.name}
onChange={handleNameChange}
placeholder="Name"
/>
</div>
)
}為什麼不好?因為你直接改了 form 物件,然後傳給 setForm。React 比較的是物件的引用,不是內容。同一個物件引用,React 認為沒變,所以不會重新渲染。用戶改了輸入框,但頁面不更新。
正確的做法是建立一個新物件:
const handleNameChange = (e) => {
setForm({
...form,
name: e.target.value
})
}
const handleEmailChange = (e) => {
setForm({
...form,
email: e.target.value
})
}用擴展運算符 ...form 建立新物件,改一個欄位,保留其他欄位。或者用函數式更新會更清楚:
const handleNameChange = (e) => {
setForm(prev => ({
...prev,
name: e.target.value
}))
}如果表單欄位很多,這樣寫會很繁瑣,我覺得這時候用 useReducer 會更好
Lazy 初始化
有時候初始值的計算很昂貴。比如從 localStorage 讀取,或者執行一個複雜的運算。
不好的做法是直接寫在 useState 裡:
const [count, setCount] = useState(expensiveCalculation())
這樣的話,每次組件重新渲染,expensiveCalculation() 都會執行一遍,很浪費。
好的做法是傳一個函數,React 只會在初始化時執行一次:
const [count, setCount] = useState(() => expensiveCalculation())
React 看到你傳的是函數,就會在初始化時執行一次,然後把結果作為初始值,下次重新渲染時就不會再執行了。
實際例子,從 localStorage 讀取:
'use client'
import { useState } from 'react'
export function ThemeSwitcher() {
const [theme, setTheme] = useState(() => {
const saved = window.localStorage.getItem('theme')
return saved || 'light'
})
const toggleTheme = () => {
setTheme(prev => {
const newTheme = prev === 'light' ? 'dark' : 'light'
window.localStorage.setItem('theme', newTheme)
return newTheme
})
}
return (
<button onClick={toggleTheme}>
Current theme: {theme}
</button>
)
}注意這裡用了函數式初始化讀取 localStorage,也用了函數式更新保存新值。這樣組合很常見。
批次處理的實際影響
我覺得理解批次處理能幫你寫出更高效的 code。
在事件處理裡,React 會自動批處理多個 setState:
const handleClick = () => {
setCount(c => c + 1)
setName('John')
setEmail('[email protected]')
} 但在 Promise、setTimeout、事件委派等非同步場景裡,React 18 之前是不會批處理的。React 18 開始,自動批處理擴展到所有地方。
舉個例子,在 Promise 的 .then 裡:
const handleAsyncClick = async () => {
const data = await fetch('/api/data').then(r => r.json())
setCount(data.count)
setName(data.name)
} 以前版本的 React 會重新渲染兩次,現在只會一次,這對效能很有幫助。
小結
寫 useState 時,可以問問自己三個問題。首先是新狀態會去依賴前一個狀態嗎?如果依賴,就用函數式更新。再來狀態是物件嗎?如果是那要記得建立新物件,不能直接改。最後還有初始值計算貴嗎?如果貴,用 lazy 初始化。
如果狀態有多個欄位,比如表單的 name、email、password,我覺得用 useReducer 會更清楚。但簡單的情況,useState 就夠了。
下次講講 useEffect,會深入執行順序、cleanup、dependency array 的陷阱。useEffect 也是容易踩坑的地方。