useEffectの第二引数の配列を空にするとstateが更新されない件
概要
タイトルの通りになりますが、この前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
を使用することによって、更新可能な値が定義できるということでした。たまーにこういうケースと出会うのですが、結構トリッキーな解決方法に感じるので忘れないようにしたいですね。
Discussion
自分もこういうケース対応したことあるのですが、解決パターン違ってたので共有します!
※どっちが良いかはわからないです
@null さん
ご共有ありがとうございます!
本文の中で少しだけ触れてますが、null さんのおっしゃられてるのはこのパターンかなと思います。
state を依存配列に渡すと、state が更新されるたびに、useEffect のコールバック関数が実行されるので、state を依存配列に渡すことが正しいケースでは問題ありません。
今回の記事のケースですと、マウント時の一度だけuseEffect を実行したいので、依存配列を空にしたい時の対処法になりますね。
返信ありがとうございます
記事上のコードだけでは依存配列にstateを含めない理由がわからず適当なコメントしちゃったなって反省です…
自分の中ではフォームでstate参照するかref参照するかみたいな話なのかなって思いました
useEffectの中でキャプチャした変数が更新されなくて困っていて、最終的にその値でstateを更新するたけであれば更新関数(例ではsetFruit)に値ではなく関数を渡す形式(
setFruit(currentFruit => newFruit)
)でも良いかなと思いました。アラートくらいであればこの中でやれそうです。さらに複雑なことをstateを更新せずに行う場合は、お書きになったようなuseRefを使うパターンがよさそうだと思いました!
採用する値とは別に選択した値もそれぞれstateに入れる事もありそうです(fruitとselectedFruitをそれぞれstateにする)