# [React] React batch update 以及使用 updater function 更新 state ###### tags: `React` `前端筆記` `Udemy 課程筆記` ## component 不是在 re-render 的時候更新 state,而是先更新 state 後,component 才會 re-render (更新完 state 後才會觸發 re-render 使 UI 依照 state 更新,且每一次的 render 都有它自己版本的 state 值,同一次 render 中的 state 值是固定且永遠不變的。) ![](https://hackmd.io/_uploads/Hkgv-femi.png) ## batch update ### 什麼是 batch update? 在 React 中要更新 UI 的就必須透過呼叫 `setStateFunc`,讓 React 更新 state 後 re-render component,但如果開發者在同一個 handler(時機點)叫用多次 `setStateFunc`,React 就會因為叫用幾次 `setStateFunc` 而重新 re-render component 幾次嗎? ```javascript= import { useState } from 'react'; export default function Counter() { console.log('re-render counter'); const [number, setNumber] = useState(0); return ( <> <h1>{number}</h1> <button onClick={() => { setNumber(1); // state 會立即更新並 re-render 這個 component 嗎? // 這樣子看起來就會 re-render 至少 3 次 setNumber(2); setNumber(3); }} > +3 </button> </> ); } ``` 結果是不會的,點擊按鈕後 `<Counter />` 只會被 re-render 一次而已,也就是說在同一個 handler 內 React 並不會馬上更新 state。 > React waits until all code in the event handlers has run before processing your state updates. > React 會等到 handler 內的程式碼執行完畢後才會更新 state。 ```javascript= import { useState } from 'react'; export default function Counter() { console.log('re-render counter'); const [number, setNumber] = useState(0); return ( <> <h1>{number}</h1> <button onClick={() => { setNumber(1); // 並不會馬上更新 state setNumber(2); // 並不會馬上更新 state setNumber(3); // 並不會馬上更新 state }} > +3 </button> </> ); } ``` ==React 會保證執行 `setStateFunc` 的順序(依照開發者呼叫的順序),將 `setStateFunc` 存放在 queue(就跟 event queue 的概念差不多,但是這個 queue 中是存放 `setStateFunc`)==,然後合併 queue 內的 `setStateFunc`,並執行一次 re-render,減少不必要的 re-render,因為 re-render = 函式叫用 = 函式內的計算(其 children component 也要全部 re-render)。 > React does not batch across multiple intentional events like clicks—each click is handled separately. Rest assured that React only does batching when it’s generally safe to do. This ensures that, for example, if the first button click disables a form, the second click would not submit it again. > 但 React 不會橫跨 handler,將其 handler 的 `setStateFunc` 合併,比方來說第一次點擊按鈕後 form 被 disabled,不可能第二次點擊就發送 form(因為已經被 disabled了) 所以套上 queue 的概念,上方程式碼其實應該是這樣子執行的: ```javascript= import { useState } from 'react'; export default function Counter() { console.log('re-render counter'); const [number, setNumber] = useState(0); return ( <> <h1>{number}</h1> <button onClick={() => { setNumber(1); // 並不會馬上更新 state,因為這個 handler 內還有其他程式碼需要執行,所以這個 setStateFunc 就會被推入 queue。 setNumber(2); // 並不會馬上更新 state,因為這個 handler 內還有其他程式碼需要執行,所以這個 setStateFunc 就會被推入 queue。 setNumber(3); // 將這個 setStateFunc 推入 queue 中,因為這個 handler 內沒有其他程式碼需要執行了,React 準備著手 state 更新 // STEP1: 依序執行 queue 內的 setStateFucn // - setNumber(1), setNumber(2), setNumber(3) // STEP2: state 更新完畢 // STEP3: componenet re-render,達到 UI 更新的目標 // - number 為 3 }} > +3 </button> </> ); } ``` ![](https://hackmd.io/_uploads/SyhoUVlmj.png) 像這樣子等待 handler 內無其他程式碼後且為了效能減少不必要的 re-render,React 官方稱做「batch update」。 ### 即便是不同 state 的更新,只要在該 handler 內且都是同個區塊以及同步執行,React 都會 batch update ```javascript= import { useState } from 'react'; export default function Counter() { console.log('re-render counter'); const [number, setNumber] = useState(0); const [username, setUsername] = useState(''); const clickHandler = () => { setNumber(1); // 因為 handler 內還有其他程式碼需要執行,因此會先把 setStateFunc 推入 queue setNumber(2); // 因為 handler 內還有其他程式碼需要執行,因此會先把 setStateFunc 推入 queue setNumber(3); // 因為 handler 內還有其他程式碼需要執行,因此會先把 setStateFunc 推入 queue setUsername('Lun'); // handler 內已無其他需要執行的程式碼,因此把該 setStateFunc 推入 queue 後就開始依照 queue 著手更新 state // 所以最後 number 會被更新為 3,username 為 'Lun',且多虧 React queue,畫面只會 re-render 一次 }; return ( <> <h1>{number}</h1> <h1>{username}</h1> <button onClick={clickHandler}>+3</button> </> ); } ``` ### effect function 在同一回叫用 setStateFunc 也會有 batch update > effect function 在元件第一次初始載入後就一定會叫用一次,之後就看 dependency array 的條件叫用。 > 每一次 render 都會有自己的 states, functions 及 effect functions。 如果在不同 effect function 中叫用 setStateFunc,在同一回叫用時也會 batch update: ```javascript! export default function App() { const [stateOne, setStateOne] = useState(); const [stateTwo, setStateTwo] = useState(); const renderCounter = useRef(0); renderCounter.current++; useEffect(() => { console.log("effect two"); setStateTwo(2); }, []); useEffect(() => { console.log("effect one"); setStateOne(1); }, []); return ( <div className="App"> <p>stateOne: {stateOne}</p> <p>stateTwo: {stateTwo}</p> <p>renderCounter: {renderCounter.current}</p> </div> ); } ``` ![](https://hackmd.io/_uploads/Hyde7XQr2.png) (會發現元件載入兩次) [這裡可以看到另一個範例-同一回 effect 也是會 batch update](https://codesandbox.io/s/tong-yi-hui-effect-ye-shi-hui-batch-update-pln824?file=/src/App.js) #### 更進一步看執行的樣子 元件第一次載入: ```javascript! export default function App() { const stateOne = undefined const stateTwo = undefined const renderCounter = 1 // 因為有 renderCounter.current ++ // 第一次載入完會叫用 effect functions useEffect(() => { console.log("effect two"); setStateTwo(2); }, []); useEffect(() => { console.log("effect one"); setStateOne(1); }, []); /* 可以想像成這樣: * function outerUseEffect () => { * useEffect() * useEffect() * } * * outerUseEffect() * */ } ``` 元件第一次載入後預設叫用 effect functions: ```javascript! export default function App() { // effect functions 更新 state const stateOne = 1 const stateTwo = 2 // 這邊只 + 1 而不是 + 2 // 因為兩個 effect functions 叫用的時機點是元件第一次載入後叫用,所以 React 為了節省效能,而自動排程 batch update const renderCounter = 2 useEffect(() => { console.log("effect two"); setStateTwo(2); }, []); useEffect(() => { console.log("effect one"); setStateOne(1); }, []); } ``` ![](https://hackmd.io/_uploads/SJRzeHQr3.png) 所以可以發現 effect function 在叫用 setStateFunc 時也會維持同一次 render 是自己的 scope 及 batch update state 的規則。 #### 為什麼會有這個結論? 1. 要不然就會在第一個 effect function 內就更新 state -> render 2. 那第二個就會等到 render 完在做重複的事情,多次 render #### React 18+ 以前非同步要注意 React 18+ 後才有不管怎麼樣都會 batch update 的功能(即便非同步執行): ```javascript! // 在 18+ 非同步還是會執行 batch update const handleAsyncIncrese = () => { Promise.resolve().then(() => { setStateOne((prev) => prev + 1); setStateOne((prev) => prev + 1); setStateOne((prev) => prev + 1); }); }; ``` 但是在 18+ 以前並不支援,所以在非同步內使用 setStateFunction 會立即觸發 React 比對當前歷史 state(如果有不同就會 render,而不會先堆疊上一次 render 時非同步程式碼內的 setStateFunction): ```javascript! // 所以這端 Promise 內更新 state,導致元件渲染三次 // ... console.log('[outer]') const handleAsyncIncrease = () => { Promise.resolve().then(() => { setStateOne((prev) => prev + 1); // 馬上比較上一次 render 的 state,發現有不同就 render console.log('[end first]') setStateOne((prev) => prev + 1); // 馬上比較上一次 render 的 state,發現有不同就 render setStateOne((prev) => prev + 1); // 馬上比較上一次 render 的 state,發現有不同就 render }); }; ``` ### 程式範例 [effect batching update with react18+](https://codesandbox.io/s/effect-batching-update-with-react18-j2k38c) [effect batching update with react less 18+](https://codesandbox.io/s/effect-batching-update-with-react-less-18-kxrqgh?file=/src/App.js) ## updater function(使用函式更新 state) `setStateFunc` 除了可以直接丟新的值當作 argument 之外,還可以丟一個完整的函式。被丟入的函式有一個 parameter 可以使用,React 會保證 paramter 為該 state 上一次的值,因此如果更新的值有需要依靠上一次的值,直接用 updater function 是最好的更新手段。(比方來說根據上一次的 state + 1 為新的 state `setStateFunc((prevCounter) => prevCounter + 1)`) ### 更靠近看直接給值,跟使用 updater function 所代表的意義 #### 直接給值 -> replace 直接給值就是把 state 替換成另一個 state: ![](https://hackmd.io/_uploads/Bkqc2Ve7o.png) > 其實替換 state 就等同於使用 updater function 但回傳的結果並不依賴於 argument:`setStateFunc((prevState) => newState)` #### updater function -> Do something with the state value instead of just replacing it 使用 updater function 就是在告訴 React:「新的 state 是依賴上次 state 運算後的結果,而非用某個新的值取代舊的 state」。updater function 一樣會被塞入 queue,並等待該區塊的 handler 結束再按照順序執行,唯一不同的是,==這次是整個 function 都會被塞入 queue,而且執行第一個 updater function 時,React 會直接把起始 state 帶入第一個 updater function 的 parameter 叫用,接下來便會以上一個 updater function return 的結果當作下一個 updater function parameter 叫用,使每一個 updater function 都可以正確地得到上一次的 state,並依賴上一次 state 計算新的 state。== ```javascript= import { useState } from 'react'; export default function Counter() { const [number, setNumber] = useState(0); const clickHandler = () => { setNumber(number => number + 1); // handler 內還有程式碼需要執行,因此 updater function 被塞入 queue setNumber(number => number + 1); // handler 內還有程式碼需要執行,因此 updater function 被塞入 queue setNumber(number => number + 1); // 此 updater function 被塞入 queue 中,因為 handler 沒有其他的程式碼需要執行了,所以開始依照 queue 內的順序執行更新 state 的流程 // STEP1: (number) => number + 1,為第一個執行的任務,因為是 updater function,所以起始 state 0 會被帶入 paramter,當作任務函式的 argument 叫用函式,並得到 0 + 1 = 1; // STEP2: (number) => number + 1,以上一次 queueItem 的結果當作 argument 叫用此次任務的 updater function,並得到 1 + 1 = 2; // STEP3: (number) => number + 1,以上一次 queueItem 的結果當作 argument 叫用此次任務的 updater function,並得到 2 + 1 = 3; // STEP4: queue 內的任務都執行完畢,最終 number 為 3,並統一只 re-render 1 次,UI 呈現 3 }; return ( <> <h1>{number}</h1> <button onClick={clickHandler}>+3</button> </> ); } ``` ![](https://hackmd.io/_uploads/Syu7LSgQj.png) > Updater functions run during rendering, so updater functions must be pure and only return the result. Don’t try to set state from inside of them or run other side effects. > 切記,updater function 必須為 pure function(因此只需要處理回傳的結果,別在裡面又 set new state 或者做一些有 side effects 的東西) #### 替換與依賴更新可以併用 ```javascript= import { useState } from 'react'; export default function Counter() { console.log('re-render counter'); const [number, setNumber] = useState(0); const clickHandler = () => { setNumber(number => number + 1); // updater function 被推入 queue setNumber(number => number + 1); // updater function 被推入 queue setNumber(100); // 把 state 替換成 100 被推入 queue setNumber(number => number + 1); // updater function 被推入 queue,且因 handler 無其他需要處理的程式碼了,因此會依照 queue 的順序執行 setStateFunc // STEP1: (number) => number + 1,起始 state 被帶入為 argument 叫用函式,得到 0 + 1 = 1; // STEP2: (number) => number + 1,上一次 updater function 的結果被帶入為 argument 叫用函式,得到 1 + 1 = 2; // STEP3: state 被替換成 100 // STEP4: (number) => number + 1,上一次替換的結果 100 被帶入為 argument 叫用函式,得到 100 + 1 = 101; // STEP5: state 更新完畢,並統一 re-render 一次更新 UI }; return ( <> <h1>{number}</h1> <button onClick={clickHandler}>increase</button> </> ); } ``` ![](https://hackmd.io/_uploads/S1ek9rlmj.png) ## 有非同步時就更凸顯 updater function 的重要性 以官方的練習為[例子](https://codesandbox.io/s/fh0rkn?file=/App.js:0-624&utm_medium=sandpack),因為 batch update 必須要等到 handler 內的程式碼執行完畢才會開始更新 state,所以如果這時候塞入非同步且新 state 也仰賴於舊 state 為計算基礎的話,不使用 updater function 更新 state 就會發生非同步程式碼抓到錯誤 state 的問題: 目標:點擊按鈕後 pending 會按照點擊的次數新增,且三秒後 pending 會遞減,completed 則是遞增。 ```javascript= // fork from (Queueing a Series of State Updates)[https://beta.reactjs.org/learn/queueing-a-series-of-state-updates] import { useState } from 'react'; export default function RequestTracker() { const [pending, setPending] = useState(0); const [completed, setCompleted] = useState(0); async function handleClick() { setPending(pending + 1); await delay(3000); setPending(pending - 1); setCompleted(completed + 1); } return ( <> <h3> Pending: {pending} </h3> <h3> Completed: {completed} </h3> <button onClick={handleClick}> Buy </button> </> ); } function delay(ms) { return new Promise(resolve => { setTimeout(resolve, ms); }); } ``` 以當前的程式碼點擊按鈕後發現以下問題: 1. 只點擊一下,pending 確實會更新,但是過了三秒後 pending 會變成 -1,completed 則是 +1 2. 點擊數下,發現最終 pending 會變成點擊字數 -2 值,且 completed 只會 + 1 ### 為什麼會出現這樣子的情況? 因為 scope、React batch update 以及沒使用 updater function 更新值: 1. scope:state 更新後就會 re-render,functional component 內的程式碼就會一行一行執行,且每一次 re-render 都是再建立一個新的 scope 2. React batch update:`setStateFunc` 會依序被推入 queue,React 會等到 handler 內的程式碼執行完畢才會更新 state 並渲染,不過,如果 handler 內有非同步執行的程式碼,那麼 React queue 就會開始著手更新 state,非同步執行的程式碼會待 call stack 清空,event loop 就會把非同步執行任務推入 call stack 3. 未使用 updater function 是在告訴 React 「將 state 替換成另一個值」 所以實際執行的樣子應該是: - STEP1: 點擊按鈕觸發 handler,並開始執行 handler 內的任務 - STEP2: 將 `setPending(pending + 1)` 推入 queue 內 - STEP3: 設定一個三秒的 timer,因為有 `asyc / await` 的協助,在該 handler 內會確保 `await` 的任務結束才會繼續往下執行 - STEP4: timer 開始倒數 - STEP5: 此時 handler 內無其他程式碼需要執行,因此 react 開始查看 queue 內的 `setStateFunc`,並依照順序執行更新 state 的行為 - STEP6: 執行 queue 內的 `setPending(pending + 1)` 將 `pending` 替換成 `pending + 1` - STEP7: 更新完 state,開始 re-render - STEP8: 完成 re-render,functional component 內取得更新後的 state - STEP9: timer 時間到,event loop 開始反覆查看 call stack 是否為空 - STEP10: event loop 發現 call stack 為空,所以開始執行非同步的程式碼 - STEP11: 非同步程式碼執行完畢,所以繼續往下執行 re-render 以前 `async / await` 之後的程式碼 - STEP12: 將 `setPending(pending - 1)` 推入 queue - STEP13: 將 `setCompleted(completed + 1)` 推入 queue - STEP14: React 開始執行 queue 內的 `setStateFuncs` >`setPending(pending - 1)`,因為這個是 `setPending(pending + 1)` re-render 以前的 state,所以 `setPending(pending - 1)` 中會抓到過去的 `pending`,並將 `pending` 取代成 `pending - 1`(也就是 0 - 1) - STEP16: React 開始執行 queue 內的 `setCompleted(completed + 1)` >`setCompleted(completed + 1)`,因為這個是 `setPending(pending + 1)` re-render 以前的 state,所以 `setCompleted(completed + 1)` 中會抓到過去的 `completed`,並將 `completed` 取代成 `completed + 1`(也就是 0 + 1) - STEP17: 完成 state 更新,並合併統一 re-render 一次 之後點擊按鈕後就會重複執行上方的流程。 可以發現 `await` 之後的程式碼都會抓到上方 `setPending` 上一次未更新的 state,進而導致 `await` 之後的 `setStateFunc` 都更新錯誤的資訊。 這時候就會發現 updater function 的好處了: ```javascript= // ... async function handleClick() { // 以 updater function 改變單純地 replace setPending((pending) => pending + 1); await delay(3000); // 以 updater function 改變單純地 replace setPending((pending) => pending - 1); // 以 updater function 改變單純地 replace setCompleted((completed) => completed + 1); } // ... ``` 即便 `await` 後面的 `setStateFuncs` 還是拿到上一次 `setPending` 更新前的值(比如說點擊按鈕,UI 顯示 0 -> 1,但是 `await` 後面還是只拿到 0),但是因為使用 updater function,react 會把上一次正確的 state 塞入 argument 叫用 updater function,所以就可以達成目標! - STEP1: 點擊按鈕觸發 handler,並開始執行 handler 內的任務 - STEP2: 將 `setPending(pending + 1)` 推入 queue 內 - STEP3: 設定一個三秒的 timer,因為有 `asyc / await` 的協助,在該 handler 內會確保 `await` 的任務結束才會繼續往下執行 - STEP4: timer 開始倒數 - STEP5: 此時 handler 內無其他程式碼需要執行,因此 react 開始查看 queue 內的 `setStateFunc`,並依照順序執行更新 state 的行為 - STEP6: 執行 queue 內的 `setPending((pending) => pending + 1)`,因為是 updater function,所以 react 會把上一次正確的 `pending` 帶入 updater function 之內,並執行 updater function,其結果為新的 `pending` - STEP7: 更新完 state,開始 re-render - STEP8: 完成 re-render,functional component 內取得更新後的 state - STEP9: timer 時間到,event loop 開始反覆查看 call stack 是否為空 - STEP10: event loop 發現 call stack 為空,所以開始執行非同步的程式碼 - STEP11: 非同步程式碼執行完畢,所以繼續往下執行 re-render 以前 `async / await` 之後的程式碼 - STEP12: 將 `setPending((pending) => pending - 1)` 推入 queue - STEP13: 將 `setCompleted((completed) => completed + 1)` 推入 queue - STEP14: React 開始執行 queue 內的 `setStateFuncs` > `setPending((pending) => pending - 1)`,react 會將上一次正確的 `pending` 塞入 updater function 更新 state,並更新 `pending`(上一次 `pending` - 1) - STEP16: React 開始執行 queue 內的 `setCompleted((completed) => completed + 1)` > `setCompleted((completed) => completed + 1)`,react 會將上一次正確的 `completed` 塞入 updater function 更新 state,並更新 `completed`(上一次 `completed` + 1) - STEP17: 完成 state 更新,並合併統一 re-render 一次 因為是 updater function,所以可以安心地取得正確地上一次 state。 ## 若子層元件也是需要依賴原資料更新,只傳 setter 可以省略額外傳 state 的 props ```typescript! import { Dispatch, SetStateAction } from "react"; type Props = { onCounterChange: Dispatch<SetStateAction<number>>; }; const Test = ({ onCounterChange }: Props) => { /** 透過傳遞 updater function 直接拿取 state setter,這樣子就不用額外傳 count 當作 props 讓 Test 可以讀取 */ const hanldeIncrease = () => { onCounterChange((prev) => prev + 1); }; const handleDecrease = () => { onCounterChange((prev) => prev - 1); }; const handleReset = () => { onCounterChange(0); }; return ( <div> <button onClick={hanldeIncrease}>Incrase in Test</button> <button onClick={handleDecrease}>Decrese in Test</button> <button onClick={handleReset}>Reset in Test</button> </div> ); }; export default Test; ``` [[Note] 子層 component 也需要依賴原資料更新 state](https://codesandbox.io/p/devbox/note-zi-ceng-component-ye-xu-yao-yi-lai-yuan-zi-liao-geng-xin-state-tfmwsz?file=%2Fsrc%2FTest.tsx%3A5%2C3) ## 簡單模擬 react queue 的執行方式 - 以 immutable 建立每一次 render 的 state - 會查看 queue 內每個 element - replace 就是單純替換值 - updater function 就會把上一次更新的 state 帶入 updater function 叫用 ```javascript= // ref.(Queueing a Series of State Updates)[https://beta.reactjs.org/learn/queueing-a-series-of-state-updates] export function getFinalState(baseState, queue) { let finalState = baseState; for (const q of queue) { if (typeof q === 'function') { finalState = q(finalState); } else { finalState = q; } } return finalState; } ``` ## Recap 1. `setStateFunc` 不會直接更改當前存在的 state,而是會觸發更新 state,以便下次 render 更新 UI 2. React 會保證 `setStateFunc` 的順序,`setStateFunc` 會先被推入 queue,handler 內沒有其他需要執行的程式碼時,React 會依照其順序更新 state,並統一 re-render,避免多次的 re-render 所導致的效能問題(但不會橫跨不同的 handler,亦即第一次點擊跟第二次點擊不會合併成單次 re-render) 3. 如果新 state 必須仰賴於前次 state,就用 updater function,React 會將上一次正確的 state 塞入 updater function 叫用 4. 同一回 effect function 中有使用 `setStateFunc`,React 也會堆疊 batch update - React 18+ 採用全部 batch update - 其餘版本在非同步時不會 batch update,必須使用 `ReactDOM.unstable_batchedUpdates` React 才會 batch update ## 參考資料 1. [React - The Complete Guide (incl Hooks, React Router, Redux) - 160. Understanding State Scheduling & Batching](https://www.udemy.com/course/react-the-complete-guide-incl-redux/learn/lecture/25599626#notes) 2. [Queueing a Series of State Updates](https://beta.reactjs.org/learn/queueing-a-series-of-state-updates) 3. [[Day 13] 深入理解 batch update](https://ithelp.ithome.com.tw/articles/10300091) 4. [[Day 14] 以 functional updater 來呼叫 setState](https://ithelp.ithome.com.tw/articles/10300743) 5. [React 的 batch update 策略,包含 React 18 和 hooks](https://lance.coderbridge.io/2021/06/10/react-batch-update-in-hooks-and-react18/) 6. [React batch updates for multiple setState() calls inside useEffect hook](https://stackoverflow.com/questions/56885037/react-batch-updates-for-multiple-setstate-calls-inside-useeffect-hook)