【React/ useState】setState関数の書き方が下手で大量レンダリングが起こった話
はじめに
Reactを利用してアプリを作っていたところ、予期せぬ大量レンダリングが発生して画面描画が遅くなってしまった。
いろいろ試した結果、useStateのset関数の使い方が悪いことがわかったのでメモ。
ひとこと結論
setState関数の引数を「関数にする」
自分自身のstateを使う際は、setState関数の引数には「値」ではなく「関数」を入れる。 そして
全体を「useEffectで囲む」
実施内容
背景
Reactを利用して簡易チャットアプリを作成した。詳細は以下の記事を参照。
作成したチャットアプリ tomiko0404.hatenablog.com
その中で、以下のsetMessageList部分の処理で大量のレンダリングが走っていた。
// メッセージリスト(全員が送ったメッセージの一覧)をStateとして定義 const [messageList, setMessageList] = useState<string[]>([]); // サーバから、誰かからのメッセージが送信されたときの処理 // messageListの一番後ろに、サーバから受け取ったメッセージを追加する。 socket.on("chat", (msg) => { setMessageList([...messageList, msg]); console.log("setMessageListを実行:" + messageList); });
処理を簡単に説明すると、
- 画面側で、メッセージ一覧画面に表示するためのメッセージリスト
messageList
をStateとして管理している。 - 誰かがメッセージを送信したら、サーバがそれを受け取り、全員の画面に向けてメッセージを発信する。
- メッセージを受け取った画面側は、
messageList
にの一番後ろに今受け取ったメッセージを追加することで、画面に表示する。
という内容である。
失敗① 単純にsetMessageListを実行した場合
背景に記載したコードをそのまま実行した場合。
「1回目」「2回目」・・・「5回目」までメッセージを送信したところ、5回目にはかなり描画が遅くなっていた。(2秒ほど)
コンソールログを見てみる。わかりやすいように、メッセージを送信するたびに1行の空白行を入れている。 5回目のメッセージを送信した際は、2,098回ものsetMessageList処理が走っていることがわかる。
元々のイメージでは、例えば4回目のメッセージを送ったとき、messageList
の値は[1回目, 2回目, 3回目]
から [1回目, 2回目, 3回目, 4回目]
に変更されると思っていた。しかし、ログからは[1回目]
や[1回目, 2回目, 4回目]
などすべてのパターン一通り通ってから[1回目, 2回目, 3回目, 4回目]
に落ち着いているように見える…。
注)ちなみに表示されているmessageList
とログのmessageList
が合っていない(画面では5回目まで表示されているのにログには4回目までしか入っていない)のは、console.log
の位置の問題だと思われるが、詳細は不明。
失敗② useEffectを利用した場合
Reactでは、再レンダリングする条件として、何らかのステートが更新された場合という条件が存在する。
参考資料: 【React】再レンダリングの仕組みと最適化
今回、messageList
以外にもstateを用意していたので、それらの値が変化するたびに実行されてしまうのは問題がありそう。
ということで、useEffect()
を利用することにした。
useEffect(() => { socket.on("chat", (msg) => { setMessageList([...messageList, msg]); console.log("setMessageListを実行:" + messageList); }); }, []);
結果、以下のように最新のメッセージしか表示されなくなってしまった。
この理由は明白で、useEffect
の第二引数を空配列[]
にしていることである。
第二引数を空配列にすると、この関数は初回レンダリング時にしか実行されなくなる。
結果、サーバからメッセージを受け取った際に行う処理(関数)の定義を初回レンダリング時にしか行なわない。つまりこの関数は、「空配列(messageListの初期値)の後ろに受け取ったメッセージを付け加える」という関数となる。
失敗③ useEffectの第二引数にmessageListを加えた場合
失敗②では、関数が定義され直さないのがいけなかったということで、messageListの値が変更されたときだけ関数を定義しなおしてもらおうということで、useEffect
の第二引数にmessageList
を入れてみた。
useEffect(() => { socket.on("chat", (msg) => { setMessageList([...messageList, msg]); console.log("setMessageListを実行:" + messageList); }); }, [messageList]);
結果、失敗①と全く同じ結果になった。。。
成功例
以下の記事を見つけた。
状態更新関数の引数に与えるものは、プリミティブに限りません。関数を与えることで、prev state を参照出来ます。これにより、初期レンダリング時のみに handleClick関数定義を抑止し、正しく動かすことが出来ます。 useState で生成された関数は、同時に生成された状態を参照しない方が良さそう、という話でした。
つまり、setMessageList()
に値ではなく関数を与えれば、定義自体は初回レンダリング時にしか行なわれず(useEffectの効果)、さらに引数には最新の値を使ってくれる(関数を与えたことの効果)らしい。
setMssageList
の定義を書き換える。
// setMessageList([...messageList, msg]); // 変更前 setMessageList((messageList) => [...messageList, msg]); // 変更後
全体はこのようになる。useEffect
の第二引数は、[]
(空配列)に戻す。
useEffect(() => { socket.on("chat", (msg) => { setMessageList((messageList) => [...messageList, msg]); console.log("setMessageListを実行:" + messageList); }); }, []);
結果として、画面描画速度が大幅に向上した。
コンソールログ上は、messageList
の値が初期値のままになってしまっているが、これはイベント時の処理の定義が初回レンダリング時にしかされていないためである。
わかりにくいので画面描画時にもログを出力してみると、メッセージリストの更新前と更新後の値が出ているのみとなった。問題なさそう。
公式ドキュメントの記載
よく見ると公式ドキュメントにも記載があった。
新しい state が前の state に基づいて計算される場合は、setState に関数を渡すことができます。この関数は前回の state の値を受け取り、更新された値を返します。以下は、setState の両方の形式を用いたカウンタコンポーネントの例です。
おわりに
参考文献が少なかったり古かったりでかなり苦しんだが、チャットアプリの根幹にかかわる部分だったので解決できてよかった。