非同期処理を挟んで同期的に setState すると個別に render 関数が実行される
- カテゴリ:
- JavaScript
- コメント数:
- Comments: 2
◆ 非同期処理のあとに 連続して setState すると 毎回関数が実行される
◆ 中途半端な状態の画面は見えないけど 関数の実行時にエラーになる可能性はある
◆ state1 があるなら state2 もあるはず みたいなのが信頼できない
◆ React 18 だと起きない
◆ 中途半端な状態の画面は見えないけど 関数の実行時にエラーになる可能性はある
◆ state1 があるなら state2 もあるはず みたいなのが信頼できない
◆ React 18 だと起きない
今更 React 17 以下でのみ発生する問題に出会いました
useEffect の中で 非同期処理を挟んでから複数の setState を呼び出すと 想定しない状態で render 関数が実行されてしまい 実行時エラーになります
親から受け取る value が変わるごとに setState で state1 と state2 を更新します
最初は state1, state2 の両方が 100 で useEffect での更新後には両方が 101 となってます
ここまでは想定通りです
useEffect の中で setState する前に非同期処理をはさみます
更新されるのが一瞬遅くなるくらいで同じ結果かと思ったら違いました
state1 が 101 で state2 が 100 の状態で この関数が実行されるケースが出てきます
その直後に state1 も state2 も 101 で再実行されるので おかしな状態の画面は見えずあまり問題にならないかもです
ですが state1 と state2 の関係によっては実行時エラーが起きます
例えば
みたいなコードがあって state1 が null のときは必ず state2 に配列が入ってることを期待します
実装上 そうなるようにしているはずなのに state1 だけが先に更新されて実行されることで
みたいなコードではエラーになります
毎回 state2 が配列かのチェックをすることもできますが 無駄な処理が増えます
本来はそのチェックを state1 で行ってるはずです
なので 更新順を変えるだけで対処できる場合もあります
上の例だと先に setState2 をするということで対処できます
しかし 順番だけでどうにもならないこともあります
完全に更新タイミングが一致するものなら まとめて 1 つの state として持たせると確実に同時に更新されるのでいいかもしれません
ただ そうしづらいケースもあるので 最終的には不正な状態は処理をスキップするようになるかと思います
使用箇所ごとに個別にやるのは面倒なので最初にチェックして不正なら即 return します
1 つ目の例の state1 と state2 をあわせて 1 ずつ増やすケースだと
という感じです
両方が同じ数値のはずなのでそうでない場合は不正な状態です
そのときに画面を表示する必要はないので return します
実際はこんな簡単に不正な状態を判断できないことは多いので 更新途中であることを示す state を追加するのがいいかもしれません
という感じで更新します
valid という state が false の内は不正な状態なので
を入れておきます
そういえば React 18 の変更点に更新処理をまとめるみたいなのがありました
パフォーマンス的な話で動作に影響するものとは思ってなかったのですが こういう問題を解決していたのですね
更新できるなら 18 にしてしまうのがいいかもしれませんが 17 から 18 って結構変更点が多いんですよね
ある程度大きなものだと影響が大きいし 18 の機能は SSR とかサスペンドとか使わない機能ばかりだし それでライブラリサイズも大きくなるなら 17 のものはずっと 17 でいいかと考えてました
ですが今回のは結構大きな問題です
18 にアップデートするか悩むところです
……とは言ったものの これまで結構 17 を使ってきてます
それでいまさら気づいたようなものなら問題になることはほぼ無いのかもです
複雑になってくると useReducer にして 1 つにまとめたりしてますしね
setState を連続で呼び出すこと自体が珍しいかもです
ボタンを押すと親コンポーネントで state を更新して 子コンポーネントに渡します
これを検知して useEffect が実行されます
コンソールを見て確認できます
React 17
React 18
実行結果はこんな感じです
() の中の操作は実際に表示されない補足です
React 17
React 18
もちろん setState 間に非同期処理があれば React 18 でも分けて更新されます
ただし マイクロタスクの場合はまとめてくれるようです
や
だと同期処理のときのように 1 回の更新です
非同期処理後のまとめてアップデートしたい部分をコールバック関数でまとめると良いみたいです
unstable マークされてるのが気になりますが React の unstable_ プレフィックスは 変更があってもメジャーアップデートとしないというもので 動作が安定していないというわけではなさそうなので 使っても大丈夫そうです
すでに 18 が出ているので 17 以前でこれらの動作を変えるマイナーアップデートなんてもうないでしょうし
一応比較の確認用:
React 18 のときと同じ結果になりました
useEffect の中で 非同期処理を挟んでから複数の setState を呼び出すと 想定しない状態で render 関数が実行されてしまい 実行時エラーになります
setState ごとにレンダリングされる
こんなコンポーネントを用意しますconst Child = ({ value }) => {
const [state1, setState1] = useState(100)
const [state2, setState2] = useState(100)
useEffect(() => {
setState1(state1 + 1)
setState2(state2 + 1)
}, [value])
console.log("child render", state1, state2, value)
return <div></div>
}
親から受け取る value が変わるごとに setState で state1 と state2 を更新します
最初は state1, state2 の両方が 100 で useEffect での更新後には両方が 101 となってます
ここまでは想定通りです
useEffect の中で setState する前に非同期処理をはさみます
const Child = ({ value }) => {
const [state1, setState1] = useState(100)
const [state2, setState2] = useState(100)
useEffect(() => {
;(async () => {
await new Promise(r => setTimeout(r, 100))
setState1(state1 + 1)
setState2(state2 + 1)
})()
}, [value])
console.log("child render", state1, state2, value)
return <div></div>
}
更新されるのが一瞬遅くなるくらいで同じ結果かと思ったら違いました
state1 が 101 で state2 が 100 の状態で この関数が実行されるケースが出てきます
その直後に state1 も state2 も 101 で再実行されるので おかしな状態の画面は見えずあまり問題にならないかもです
ですが state1 と state2 の関係によっては実行時エラーが起きます
例えば
const x = state1 ?? state2.map(...)
みたいなコードがあって state1 が null のときは必ず state2 に配列が入ってることを期待します
実装上 そうなるようにしているはずなのに state1 だけが先に更新されて実行されることで
setState1(null)
setState2([1, 2, 3])
みたいなコードではエラーになります
毎回 state2 が配列かのチェックをすることもできますが 無駄な処理が増えます
本来はそのチェックを state1 で行ってるはずです
対処
実行時エラーさえ起きなければ 画面表示が想定されないものになってもすぐ正常な状態になってユーザーは気づかないですなので 更新順を変えるだけで対処できる場合もあります
上の例だと先に setState2 をするということで対処できます
しかし 順番だけでどうにもならないこともあります
完全に更新タイミングが一致するものなら まとめて 1 つの state として持たせると確実に同時に更新されるのでいいかもしれません
ただ そうしづらいケースもあるので 最終的には不正な状態は処理をスキップするようになるかと思います
使用箇所ごとに個別にやるのは面倒なので最初にチェックして不正なら即 return します
1 つ目の例の state1 と state2 をあわせて 1 ずつ増やすケースだと
if (state1 !== state2) {
return
}
という感じです
両方が同じ数値のはずなのでそうでない場合は不正な状態です
そのときに画面を表示する必要はないので return します
実際はこんな簡単に不正な状態を判断できないことは多いので 更新途中であることを示す state を追加するのがいいかもしれません
setValid(false)
setState1(state1 + 1)
setState2(state2 + 1)
setValid(true)
という感じで更新します
valid という state が false の内は不正な状態なので
if (!valid) {
return
}
を入れておきます
React 18
この問題は React 18 だと発生しませんそういえば React 18 の変更点に更新処理をまとめるみたいなのがありました
パフォーマンス的な話で動作に影響するものとは思ってなかったのですが こういう問題を解決していたのですね
更新できるなら 18 にしてしまうのがいいかもしれませんが 17 から 18 って結構変更点が多いんですよね
ある程度大きなものだと影響が大きいし 18 の機能は SSR とかサスペンドとか使わない機能ばかりだし それでライブラリサイズも大きくなるなら 17 のものはずっと 17 でいいかと考えてました
ですが今回のは結構大きな問題です
18 にアップデートするか悩むところです
……とは言ったものの これまで結構 17 を使ってきてます
それでいまさら気づいたようなものなら問題になることはほぼ無いのかもです
複雑になってくると useReducer にして 1 つにまとめたりしてますしね
setState を連続で呼び出すこと自体が珍しいかもです
確認用
確認用のプレビューできるコードを置いておきますボタンを押すと親コンポーネントで state を更新して 子コンポーネントに渡します
これを検知して useEffect が実行されます
コンソールを見て確認できます
React 17
<!doctype html>
<meta charset="utf-8" />
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useEffect } = React
const App = () => {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child value={count} />
</div>
)
}
const Child = ({ value }) => {
const [state1, setState1] = useState(100)
const [state2, setState2] = useState(100)
useEffect(() => {
;(async () => {
await new Promise(r => setTimeout(r, 100))
setState1(state1 + 1)
setState2(state2 + 1)
})()
}, [value])
console.log("child render", state1, state2, value)
return <div></div>
}
ReactDOM.render(<App/>, root)
</script>
<div id="root"></div>
React 18
<!doctype html>
<meta charset="utf-8" />
<script src="https://unpkg.com/react@18/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@18/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useEffect } = React
const App = () => {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child value={count} />
</div>
)
}
const Child = ({ value }) => {
const [state1, setState1] = useState(100)
const [state2, setState2] = useState(100)
useEffect(() => {
;(async () => {
await new Promise(r => setTimeout(r, 100))
setState1(state1 + 1)
setState2(state2 + 1)
})()
}, [value])
console.log("child render", state1, state2, value)
return <div></div>
}
ReactDOM.createRoot(root).render(<App/>)
</script>
<div id="root"></div>
実行結果はこんな感じです
() の中の操作は実際に表示されない補足です
React 17
(ページを開く)
child render 100 100 0
child render 101 100 0
child render 101 101 0
(ボタンクリック)
child render 101 101 1
child render 102 101 1
child render 102 102 1
(ボタンクリック)
child render 102 102 2
child render 103 102 2
child render 103 103 2
React 18
(ページを開く)
child render 100 100 0
child render 101 101 0
(ボタンクリック)
child render 101 101 1
child render 102 102 1
(ボタンクリック)
child render 102 102 2
child render 103 103 2
補足
非同期処理を挟めば onClick の中の処理でも同じですconst Child = () => {
const [state1, setState1] = useState(100)
const [state2, setState2] = useState(100)
const onClick = async () => {
await new Promise(r => setTimeout(r, 100))
setState1(state1 + 1)
setState2(state2 + 1)
}
console.log("child render", state1, state2)
return <button onClick={onClick}>Click!</button>
}
もちろん setState 間に非同期処理があれば React 18 でも分けて更新されます
const onClick = async () => {
await new Promise(r => setTimeout(r, 100))
setState1(state1 + 1)
await new Promise(r => setTimeout(r, 0))
setState2(state2 + 1)
}
ただし マイクロタスクの場合はまとめてくれるようです
const onClick = async () => {
await new Promise(r => setTimeout(r, 100))
setState1(state1 + 1)
await Promise.resolve()
setState2(state2 + 1)
}
や
const onClick = async () => {
await new Promise(r => setTimeout(r, 100))
setState1(state1 + 1)
queueMicrotask(() => {
setState2(state2 + 1)
})
}
だと同期処理のときのように 1 回の更新です
追記:unstable_batchedUpdates
unstable_batchedUpdates を使えば良いとコメントいただいたので 試してみました非同期処理後のまとめてアップデートしたい部分をコールバック関数でまとめると良いみたいです
unstable_batchedUpdates(() => {
setState1(state1 + 1)
setState2(state2 + 1)
})
unstable マークされてるのが気になりますが React の unstable_ プレフィックスは 変更があってもメジャーアップデートとしないというもので 動作が安定していないというわけではなさそうなので 使っても大丈夫そうです
すでに 18 が出ているので 17 以前でこれらの動作を変えるマイナーアップデートなんてもうないでしょうし
一応比較の確認用:
<!doctype html>
<meta charset="utf-8" />
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
const { useState, useEffect } = React
const { unstable_batchedUpdates } = ReactDOM
const App = () => {
const [count, setCount] = useState(0)
return (
<div>
<button onClick={() => setCount(count + 1)}>{count}</button>
<Child value={count} />
</div>
)
}
const Child = ({ value }) => {
const [state1, setState1] = useState(100)
const [state2, setState2] = useState(100)
useEffect(() => {
;(async () => {
await new Promise(r => setTimeout(r, 100))
unstable_batchedUpdates(() => {
setState1(state1 + 1)
setState2(state2 + 1)
})
})()
}, [value])
console.log("child render", state1, state2, value)
return <div></div>
}
ReactDOM.render(<App/>, root)
</script>
<div id="root"></div>
(ページを開く)
child render 100 100 0
child render 101 101 0
(ボタンクリック)
child render 101 101 1
child render 102 102 1
(ボタンクリック)
child render 102 102 2
child render 103 103 2
React 18 のときと同じ結果になりました