エンジニアを目指す日常ブログ

日々勉強したことのメモ。独学ですので間違っていたらコメント等で教えてください。

【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処理が走っていることがわかる。

大量の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]);

結果、失敗①と全く同じ結果になった。。。

成功例

以下の記事を見つけた。

qiita.com

状態更新関数の引数に与えるものは、プリミティブに限りません。関数を与えることで、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の値が初期値のままになってしまっているが、これはイベント時の処理の定義が初回レンダリング時にしかされていないためである。

わかりにくいので画面描画時にもログを出力してみると、メッセージリストの更新前と更新後の値が出ているのみとなった。問題なさそう。

成功例の結果
成功例の結果

公式ドキュメントの記載

よく見ると公式ドキュメントにも記載があった。

ja.reactjs.org

新しい state が前の state に基づいて計算される場合は、setState に関数を渡すことができます。この関数は前回の state の値を受け取り、更新された値を返します。以下は、setState の両方の形式を用いたカウンタコンポーネントの例です。

おわりに

参考文献が少なかったり古かったりでかなり苦しんだが、チャットアプリの根幹にかかわる部分だったので解決できてよかった。