🌊

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

2021/10/29に公開
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;

上記のコードだとWebSocketmessageイベントで参照されている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を使用することによって、更新可能な値が定義できるということでした。たまーにこういうケースと出会うのですが、結構トリッキーな解決方法に感じるので忘れないようにしたいですね。

参考

Discussion

nullnull

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

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

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

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

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

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

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

nullnull

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

ka2nka2n

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

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

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