🌊

useEffect の第二引数の配列を空にすると state が更新されない件

6 min read 4

概要

タイトルの通りになりますが、この前 useEffect の挙動をうっかり忘れてハマってしまったので、その解決策をメモ。

state が更新されない状況とは

useEffect で第二引数の依存配列を空にしてコンポーネントのマウント時になんらかの処理させたい時ありますよね。
そんな時に state を参照しても更新されないよという話です。

基本的は依存配列に state 渡せよって話なので、あんまり上記のようなケースはないのですが、問題になるのはsetIntervalとかaddEventListenerとかをマウント時に使う時ですね。

私の場合はちょっと特殊だったのですが、WebSocket を実装したケースで問題になったので、今回はそれを例にしたいと思います。

WebSocket わからない方は非同期通信を行ってるってことだけわかれば大丈夫だと思います。

参考 URL: https://developer.mozilla.org/ja/docs/Web/API/WebSocket

state が更新されないコード

まずは全体のコードから。

import React, { useEffect, useState, useRef } from "react";

const Index = () => {
  const [fruit, setFruit] = useState("orange");
  const ws = useRef();

  const selectFruit = (e) => {
    // データの送信
    // 今回は"wss://echo.websocket.org"にデータを送ってるので、送信が成功すると、送った内容がそのまま返ってくる
    // 結果として message イベントが発火する
    ws.current.send(e.currentTarget.getAttribute("data-fruit"));
  };

  useEffect(() => {
    // この URL にWebSocketでデータを送ったら、送ったデータがそのまま返ってくる
    const url = "wss://echo.websocket.org";

    // WebSocket 接続を作成
    ws.current = new WebSocket(url);

    // 接続が開始できた時
    ws.current.addEventListener("open", (e) => {
      console.log("接続開始");
    });

    // メッセージを受け取った時
    // 今回は selectFruit 関数で send したデータがそのまま返ってくる
    ws.current.addEventListener("message", (e) => {
      // fruit が既に選択されている値の場合はアラートを出す
      if (fruit === e.data) {
        alert("Select different fruit.");
      }
      setFruit(e.data);
    });

    // エラーが発生した時
    ws.current.addEventListener("error", (e) => {
      console.log("エラー : " + e.data);
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  return (
    <div>
      <p>fruit: {fruit}</p>
      <div>
        <button onClick={selectFruit} data-fruit="orange">
          orange
        </button>
        <button onClick={selectFruit} data-fruit="apple">
          apple
        </button>
        <button onClick={selectFruit} data-fruit="banana">
          banana
        </button>
      </div>
    </div>
  );
};

export default Index;

上記のコードだと WebSocket の messageイベント で参照されている state が更新されないため、 fruit が既に選択されている値の場合はアラートを出す の箇所が理想通りの挙動になりません。

なぜ state が更新されないのか

以下の部分をみてください。

ws.current.addEventListener("message", (e) => {
  // fruit が既に選択されている値の場合はアラートを出す
  if (fruit === e.data) {
    alert("Select different fruit.");
  }
  setFruit(e.data);
});

本来なら fruit の state と新しく選択したボタンを比較して、

  • 同じ値ならアラートを出す
  • 違う値なら setFruit 関数を発火するだけ

ということをしたいコードになります。

しかしながら、これだと messageイベント 内の fruit には常に orange が入ってる状態になります。

setFruit 自体は機能してるので、return の中の fruit(下記部分)は更新されます。

<p>fruit: {fruit}</p>

これ初めてみた人には不思議な挙動だと思いますが、クロージャを知っていればなんとなく理解できると思います。

const [fruit, setFruit] = useState("orange");

//マウント時のみ実行
useEffect(() => {
  ws.current.addEventListener("message", (e) => {
    // 初回レンダー時のfruit(orange)をキャプチャ
    if (fruit === e.data) {
      alert("Select different fruit.");
    }
    setFruit(e.data);
  });
}, []);

useEffect が呼び出された時に、addEventListener のコールバックが fruit をキャプチャするのですが、今回はuseEffectの第二引数を空にしているため、マウント時のみしか useEffect が実行されません。つまり fruit の値がマウント時以降、更新されないわけです。

その結果、常に addEventListener 内の fruit が初期値(orange)を参照することになります。

解決策

では、どうするかということですが、まずは解決したコードを載せます。

import React, { useEffect, useState, useRef } from "react";

const Index = () => {
  const [fruit, setFruit] = useState("orange");
  const ws = useRef();
  const refFruit = useRef(fruit);

  const selectFruit = (e) => {
    ws.current.send(e.currentTarget.getAttribute("data-fruit"));
  };

  useEffect(() => {
    const url = "wss://echo.websocket.org";
    ws.current = new WebSocket(url);

    ws.current.addEventListener("open", (e) => {
      console.log("接続開始");
    });

    ws.current.addEventListener("message", (e) => {
      if (refFruit.current === e.data) {
        alert("Select different fruit.");
      }
      setFruit(e.data);
    });

    ws.current.addEventListener("error", (e) => {
      console.log("エラー : " + e.data);
    });
    // eslint-disable-next-line react-hooks/exhaustive-deps
  }, []);

  useEffect(() => {
    refFruit.current = fruit;
  }, [fruit]);

  return (
    <div>
      <p>fruit: {fruit}</p>
      <div>
        <button onClick={selectFruit} data-fruit="orange">
          orange
        </button>
        <button onClick={selectFruit} data-fruit="apple">
          apple
        </button>
        <button onClick={selectFruit} data-fruit="banana">
          banana
        </button>
      </div>
    </div>
  );
};

export default Index;

以下の三箇所が重要になります。

// useRefで新しく定義
const refFruit = useRef(fruit);

// useRefで定義した変数を比較する
ws.current.addEventListener("message", (e) => {
  if (refFruit.current === e.data) {
    alert("Select different fruit.");
  }
  setFruit(e.data);
});

// fruit を依存配列に入れて refFruitを更新する
useEffect(() => {
  refFruit.current = fruit;
}, [fruit]);

詳しく内部でどうなってるのかはよくわかりませんが、useRef を使うことによって、いい感じに変更可能な値を定義できるみたいです。

簡単にいうとクラスにおける this のような挙動を実現してくれるわけです。

まとめ

useRef を使用することによって、更新可能な値が定義できるということでした。
たまーにこういうケースに出会うんですが、結構トリッキーな解決方法に感じるので忘れないようにしたいですね。

参考

副作用の依存リストが頻繁に変わりすぎる場合はどうすればよいですか? - フックに関するよくある質問 – React

React Hooks の useEffect 内で setInterval 等を呼び出すと state 等の値が変化しない問題の解決策 - Tkr Blog

Discussion

自分もこういうケース対応したことあるのですが、解決パターン違ってたので共有します!
※どっちが良いかはわからないです

  1. useEffectの第2引数を[fruit]にする
  2. useEffectのreturnに()=>removeEventListener設置

@null さん
ご共有ありがとうございます!

基本的は依存配列に state 渡せよって話なので、あんまり上記のようなケースはないのですが、

本文の中で少しだけ触れてますが、null さんのおっしゃられてるのはこのパターンかなと思います。

state を依存配列に渡すと、state が更新されるたびに、useEffect のコールバック関数が実行されるので、state を依存配列に渡すことが正しいケースでは問題ありません。

今回の記事のケースですと、マウント時の一度だけuseEffect を実行したいので、依存配列を空にしたい時の対処法になりますね。

返信ありがとうございます
記事上のコードだけでは依存配列にstateを含めない理由がわからず適当なコメントしちゃったなって反省です…
自分の中ではフォームでstate参照するかref参照するかみたいな話なのかなって思いました

useEffectの中でキャプチャした変数が更新されなくて困っていて、最終的にその値でstateを更新するたけであれば更新関数(例ではsetFruit)に値ではなく関数を渡す形式(setFruit(currentFruit => newFruit))でも良いかなと思いました。アラートくらいであればこの中でやれそうです。

さらに複雑なことをstateを更新せずに行う場合は、お書きになったようなuseRefを使うパターンがよさそうだと思いました!

採用する値とは別に選択した値もそれぞれstateに入れる事もありそうです(fruitとselectedFruitをそれぞれstateにする)

ログインするとコメントできます