Blog

/

Frontend

Next.js - useState

useState setState

Posted at

2024年10月24日

Posted on

Frontend

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) // 還是 0,不是 1
  }

  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。

這就帶出了下一個問題:閉包陷阱。

閉包陷阱

我覺得這個陷阱值得特別深入講,因為它很容易導致 bug,而且除錯很困難。

假設你在做一個聊天 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 18+ 也會批處理,只一次重新渲染

以前版本的 React 會重新渲染兩次,現在只會一次,這對效能很有幫助。

小結

寫 useState 時,可以問問自己三個問題。首先是新狀態會去依賴前一個狀態嗎?如果依賴,就用函數式更新。再來狀態是物件嗎?如果是那要記得建立新物件,不能直接改。最後還有初始值計算貴嗎?如果貴,用 lazy 初始化。

如果狀態有多個欄位,比如表單的 name、email、password,我覺得用 useReducer 會更清楚。但簡單的情況,useState 就夠了。

下次講講 useEffect,會深入執行順序、cleanup、dependency array 的陷阱。useEffect 也是容易踩坑的地方。

More Blog