はじめに
React Hooks において、useMemo や useEffect などの第二引数の依存リストに、オブジェクトを指定した場合の動作について、混乱を招くことが多くあったので、まとめてみました。
サンプルプログラムは、以下で実行可能です。
どういう場合?
以下のプログラムでは、初期表示時と counter1 ボタンがクリックされた場合のみ、メッセージを更新する
という仕様なのですが、実際には仕様通りには動きません。
import { useState, useCallback, useEffect } from "react";
const useCounter = () => {
const [count, setCount] = useState(0);
const countUp = useCallback(() => setCount(count + 1), [count]);
return {
count,
countUp,
};
};
export default () => {
const counter1 = useCounter();
const counter2 = useCounter();
const [message, setMessage] = useState();
useEffect(() => {
setMessage(`${new Date().toString()} に counter1 が変更されました`);
}, [counter1]);
return (
<>
<div>
<button onClick={counter1.countUp}>counter1</button>
</div>
<div>
<button onClick={counter2.countUp}>counter2</button>
</div>
<div>
{message}
</div>
</>
);
};
実際に実行してみると、counter2 をクリックした場合も、メッセージが更新されてしまいます。
なぜ更新されてしまうのか?
以下のように、useEffect では、依存リストに counter1 を指定しているのに、なぜ、counter2 が変更された場合も、メッセージが更新されてしまうのでしょうか?
useEffect(() => {
setMessage(`${new Date().toString()} に counter1 が変更されました`);
}, [counter1]);
理由は、依存リストの比較は shallow equal であるためです。つまり、依存リストの比較は 前回レンダリング時のcounter1 === 今回レンダリング時のcounter1
で比較されており、プロパティの値が変わっていなくても、インスタンスの実体が変更になった場合は、変更あり
と判定されます。
useCounter では、以下のように、毎回新しいインスタンスを作成しているため、counter1 のインスタンスが毎回変更されていると、判定されてしまいます。
return {
count,
countUp,
};
ではどうすれば良いのか?
この問題を解決するためには、以下の3個の方法が考えられます。
- 依存リストでプロパティを指定する
- useCounter の結果をメモ化する
- 分割代入を使う
依存リストでプロパティを指定する
以下のように、useEffect の依存リストでプロパティを指定すれば、プロパティ(counter1.count)の値が ===
で比較されるため、counter1 が変わった場合のみ、useEffect が実行され、解決できます。
import { useState, useCallback, useEffect } from "react";
const useCounter = () => {
const [count, setCount] = useState(0);
const countUp = useCallback(() => setCount(count + 1), [count]);
return {
count,
countUp,
};
};
export default () => {
const counter1 = useCounter();
const counter2 = useCounter();
const [message, setMessage] = useState();
useEffect(() => {
setMessage(`${new Date().toString()} に counter1 が変更されました`);
}, [counter1.count]);
return (
<>
<div>
<button onClick={counter1.countUp}>counter1</button>
</div>
<div>
<button onClick={counter2.countUp}>counter2</button>
</div>
<div>
{message}
</div>
</>
);
};
useCounter の結果をメモ化する
以下のように、useCounter の結果をメモ化すれば、counter1 の count が変更されなければ、useCounter が返すインスタンスが同じになり、counter1 の count が変更されれば useCounter が返すインスタンスが異なります。そのため、この方法でも、解決できます。
import { useState, useCallback, useEffect, useMemo } from "react";
const useCounter = () => {
const [count, setCount] = useState(0);
const countUp = useCallback(() => setCount(count + 1), [count]);
return useMemo(() => ({
count,
countUp,
}), [count, countUp]);
};
export default () => {
const counter1 = useCounter();
const counter2 = useCounter();
const [message, setMessage] = useState();
useEffect(() => {
setMessage(`${new Date().toString()} に counter1 が変更されました`);
}, [counter1]);
return (
<>
<div>
<button onClick={counter1.countUp}>counter1</button>
</div>
<div>
<button onClick={counter2.countUp}>counter2</button>
</div>
<div>
{message}
</div>
</>
);
};
分割代入を使う
以下のように、分割代入を使えば、依存リストにおいて数値の比較になるため、counter1 が変わった場合のみ、useEffect が実行され、解決できます。
import { useState, useCallback, useEffect } from "react";
const useCounter = () => {
const [count, setCount] = useState(0);
const countUp = useCallback(() => setCount(count + 1), [count]);
return {
count,
countUp,
};
};
export default () => {
const { count: count1, countUp: countUp1 } = useCounter();
const { count: count2, countUp: countUp2 } = useCounter();
const [message, setMessage] = useState();
useEffect(() => {
setMessage(`${new Date().toString()} に counter1 が変更されました`);
}, [count1]);
return (
<>
<div>
<button onClick={countUp1}>counter1</button>
</div>
<div>
<button onClick={countUp2}>counter2</button>
</div>
<div>
{message}
</div>
</>
);
};
おわりに
私の中では、useCounter の結果をメモ化する
と 分割代入を使う
の両方を採用するのが良いのではないかと思っています。
useCounter のようなカスタムフックでは、オブジェクトを返す場合は、結果を必ずメモ化して返すようにすれば、使う側がインスタンスを丸ごと依存リストに入れても安全です。
また、カスタムフックを使う側では、分割代入するようにすれば、結果のオブジェクトがメモ化されているかどうか意識しないで済みます。
ただ、両方を採用するのはやりすぎな気もして、片方だけの採用でも良いかなとも迷っているところです。
今回のサンプルプログラムでは、大した問題のようには見えませんが、useEffect の中で state を更新した処理が存在した場合は、無限に処理が実行されてしまうおそれがあるので、依存リストの比較が shallow equal であることは、意識しておいた方が良いかと思います。
別にこれは、React Hooks の問題ではありません。インスタンスの比較に deep equal などしていたら大変なことになりそうなので、shallow equal であることは当然かなとも思います。
以上!