React useState 與 batch update

Lastor
Code 隨筆放置場
7 min readJan 29, 2022

原本是要寫 React.memo() 的話題,結果查資料的過程又看到一些關於 useState hook 的有趣內容,就先記錄一下這個關於 useState 的事情吧。

下一篇:
React.memo 防止無意義的 re-render

弊社主要是用 Class 組件,所以都沒怎麼研究過 Function 組件。最近自己在摸 FC hook,發覺這真的跟 Class 組件有蠻大的差異,甚至讓人懷疑根本是兩種不同的框架。

Class 組件的 setState

在 Class 組件中,state 就是一個 object 形式的包裹,天生就可以透過 setState 方法來批次修改 state,這在 Class 組件中,是很正常不過的事情。

class MyComponent extends React.Component {
state = {
name: 'unknown',
age: 0,
}
async componentDidMount() {
console.log('set state')
this.setState({
name: 'Tom',
age: 20,
})

}
render() {
console.log('render')
return (
<>
<div>{this.state.name}</div>
<div>{this.state.age}</div>
</>
)
}
}

上面的例子中,簡單設置兩個 state,並在 mount 之後修改它們,可以很容易的預測 console.log 的結果如下:

// console.log
render
set state
render

這個在 Class 組件看似非常自然的操作,到了 Function 組件就忽然變得複雜了起來。

Function 組件的 useState

在 Function 組件裡面,使用 useState hook 建立狀態。這背後的原理似乎是類似 fucntion 閉包的概念來做的。但這先不管,useState 一般會是一個狀態建立一個。

function MyFC () {
const [name, setName] = useState('')
const [age, setAge] = useState(0)
console.log('render')
return (
<>
<div>{name}</div>
<div>{age}</div>
</>
)
}

接著比照 Class 組件,在 mount 後一次性修改它們,但 useState 的特性,會需要進行 n 次。

useEffect(() => {
console.log('set state')
setName('Tom')
setAge(20)
}, [])

React 的設計上,只要狀態被改變了,就會做一次 re-render。如果按照這個邏輯,狀態被修改了兩次,勢必也會 re-render 兩次,造成效能上的無謂消耗。

ps.
爬了一些討論,有許多人表示,其實 React re-render 速度是很快的,除非效能真的出現了明顯問題,或是出了 bug。不然這類 re-render 的狀況其實可以無視。因為 React render 的執行,並不會每次都真的變更瀏覽器 DOM。花時間去做防 re-render 的機制,反而會浪費許多人力、時間成本

所以 React 在底層會幫忙做個 batch update 的動作。比照 Class 組件的行為,批次處理。因此最後查看 log,可以看到 set state 之後,依舊只重渲了一次。

// console.log
render
set state
render

當 useState 撞上非同步

到目前為止都跟 Class 組件一樣,但通常我們會在 mount 後改狀態是非同步 fetch data 的情況。不幸的是,這個 batch update 的機制,碰到非同步就會掛掉。

// Helper function
function waitTime(ms: number) {
return new Promise((resolve) => {
setTimeout(() => {
resolve('ok')
}, ms)
})
}
// Use waitTime() to simulate fetch action
useEffect(() => {
const fetchAPI = async () => {
console.log('fetch start')
await waitTime(1000)
setStateA('Tom')
setStateB(20)
}
fetchAPI()
}, [])

上面寫了一個簡單的 waitTime function,把 setTimeout 包裹到 Promise 裡面,用來模擬 async fetch 的情況。

React 的 FC hook 在碰到異步函式時,batch update 的功能就會失效,產生了多餘的重複渲染。

// console.log
render
fetch start
render
render

這個...... 嗯,姑且稱之為問題吧,似乎會在 React v18 中得到改善。但 v18 還沒出,該怎麼解決這個問題呢!?

最直接的做法,應該就是比照 Class 組件,直接把 state 用一個 object 包裹。

function MyFC () {
const [user, setUser] = useState({
name: 'unknown',
age: 0,
})
useEffect(() => {
const fetchAPI = async () => {
console.log('fetch start')
await waitTime(1000)

setUser({ name: 'Tom', age: 20 })
}
fetchAPI()
}, [])
}

這作法姑且看著還行,但 Function 組件的許多 hook 都有個依賴選項,如果把這個 object state 拿來當作依賴項,可以預期八成會撞上淺比較(shallow compare) 的坑。這個等哪天實際遇到再來看看怎麼辦吧 (攤手)。

另一個要注意的問題是,Class 組件的 setState() 是會幫忙自動 merge 新舊值的。例如我有 name 與 age 兩個 state,只更新 age 的話,沒被設定的 name 會被自動補上去。

但 useState 就沒這麼智能了,它只是一個單純的賦值動作,所以更新狀態時,如果只寫了 age,最後狀態就會只剩下 age。

可以參考官方文件,用 callback 的方式取得舊值,再手動用自己習慣的 JS 語法來 merge。這邊跟著官方用解構賦值的方式來處理。

const [obj, setObj] = useState({ ... })setObj((oldVal) => {
...oldVal,
key: 'newVal'
})

上述這些狀況,對於 Class 組件起步的我來說,真的是想都沒想過的問題…… 看網上有些人說 function 組件比較簡單好學,我真心對這個觀點抱持著懷疑態度。

Class 本身的特性,要管理 state 跟控制 lifecycle hook,感覺都比 FC 要清晰容易許多,畢竟 Class 是一個包,instance 出來之後,render method 與其他屬性、方法都是獨立的。不像 function 每次都會全 run 一次。在做複雜內容的時候,或許 Class 會比 FC 要好維護許多。

最後再補充一個爬文剛好爬到的,關於 React FC,batch update 面試考題的文章,這也是一個很有趣的問題啊:

React 的 batch update 策略,包含 React 18 和 hooks

其他參考:
Multiple calls to state updater from useState in component causes multiple re-renders — Stack Overflow

--

--

Lastor
Code 隨筆放置場

Web Frontend / 3D Modeling / Game and Animation. 設計本科生,前遊戲業 3D Artist,專擅日本動畫與遊戲相關領域。現在轉職為前端工程師,以專業遊戲美術的角度涉足 Web 前端開發。