🚅

ReactのHooksのdepsが変更検知する条件を考察

2022/10/11に公開

株式会社ヴァージニアのエンジニアリング本部の福地祐太と申します。
今回はReactのHooksの第2引数のdepsがどのように変更を検知して、Hooksを再計算するのかについて考察していきたいと思います。
業務でパフォーマンス改善をするタスクがあり、タスク取り組み当初はどこが原因かわからない状態でした。そのためReactの基礎から見つめ直し、どう再レンダリングされているのか再確認する必要がありました。その時の知見を共有できたらと思い、記事作成に至りました。
useMemoを用いて、実践的な状況に近くなるようにして考察しましたので、参考になれば幸いです。

最初にこの記事の要約を話した上で検証内容の解説に入ります。

この記事の要約

  • HooksのdepsはObject.is()で比較されている
    • Object.is()では、プリミティブ型は等価性(値が等しいか)で比較され、オブジェクトは同一性(参照が等しいか)で比較される
  • Componentの再レンダリングにより、同一性(参照が等しいか)が変化する場合がある
    • let, constなどで宣言した値はComponentの再レンダリングにより再計算され参照が変わる
  • useStateのstateが同一性で比較されている値(Objectなど)だった場合、等価性の観点で等しい値をsetStateしても参照が変化する
    • setState(v=>v)とstateの値をそのままsetStateした場合は参照が変化しない

上述したObject.is()とComponentの再レンダリング、useStateの仕様の兼ね合いによって、depsに基づき、Hooksを再計算するか判定されます。
注意点は、Object.is()のみで考えると、同一性で比較されている値(Objectなど)の中身が変更されたとしてもdepsはHooksを再計算しないことになりますが、何らかの理由で同一性で比較されている値の参照が変化した場合、depsがHooksを再計算することになる点です。そのため同一性で比較されている値(Objectなど)の参照が変化するケースを実装者としてはしっかり認識しておくべきということになります。

Object.is()

Hooksのdepsの比較はObject.is()のpolyfillを使用しています。
https://github.com/facebook/react/blob/v17.0.2/packages/shared/objectIs.js
Object.is()は下記をご参照ください。
https://developer.mozilla.org/ja/docs/Web/JavaScript/Reference/Global_Objects/Object/is

primitiveな値の場合は等価性(値の中身が同じかどうか)で比較し、オブジェクト、関数、配列、JSXの場合は同一性(参照先が同じかどうか)で 比較しています。要するに同一性で比較されているものに関しては、値が変更されても変更検知で再計算はされません。

検証方法

検証内容は以下となります。
まず、useStateでstate 変数を宣言し、その変数をdepsにしたuseMemoを使いmemo化された変数を得ます。

  const [target, setTarget] = useState(0);
  const targetMemo = useMemo(() => {
    console.log("render useMemo targetMemo");
    return target;
  }, [target]);

そしてそのメモ化した変数の表示と、stateを更新するボタンの設置を行います。

<p>numMemo:{targetMemo}</p>
<button onClick={() => setTarget((v) => v)}>setTarget(v=>v)の場合</button>

state変数の種類と更新内容を変化させ、各ケースについて、ボタンを押すことでuseMemoのコールバックが再実行されるかをconsole.logを見ることで検証しました

また、state変数だけではなく、以下をdepsに入れたケースも調査しました。

  • letで宣言した変数
  • contextのvalue
  • props

useStateのstateをdepsに入れたパターン

useStateのstateの型が、Number、Object、Array、Function、JSXの場合を試しました。

Numberの場合

Numberの場合は、違う値をsetState()で代入したらmemoが再計算されます。(同じ値を代入しても再計算されない)

setNum((v) => v)

値が同一のため再計算されません。

setNum(0)

setNum実行前の値が0以外の場合は、値が変わるので再計算されます。

setNum(1)

setNum実行前の値が1以外の場合は、値が変わるので再計算されます。

setNum((v) => v + 1)

必ず値が変わるので再計算されます。

Object, Array, Function, JSXの場合

この4種類の場合は、setState(v=>v)を使用すれば再計算されないですが、値の同じオブジェクトを直接引数に指定した場合、参照が変わるので再計算されます。

setNum((v) => v)

等価性の観点で等しい値が入るため、再計算されません。

setObj({ a: 0 })

useStateのstateのobjに元々入っていた値と同一のオブジェクトをsetObjで代入したとしても、参照が異なるため再レンダリングされます。

Object, Array, Function, JSXの挙動も同様となります。

letで宣言した変数をdepsに入れ、letの変数に再代入したパターン

letで宣言したnumの値を、onClick={() => (num = 1)}で更新します。

こちらは使用方法が間違っている検証になります。
letで宣言した変数に対して、値を再代入してもuseMemoは再計算はされません。再レンダリング時にuseMemoを再計算するかを判定するのですが、そもそもletで定義した変数の、参照を変えない単なる値変更ではコンポーネントが再レンダリングされないからです。
letで宣言されたObject、Array、Function、JSXは、Componentの再レンダリング時に再計算され参照が変わるため、Componentの再レンダリング時に再計算されないようにuseMemoを使用するなどの注意が必要です。

Contextのvalueをdepsに入れたパターン

useStateのsetState関数でstateが更新されると、stateを使用しているComponentが再レンダリングされるというReactの仕様があります。それによってlet宣言のObjectなどは再計算され参照が代わるので、contextValue1memoはcontextValue1をdepsに指定しているのにも関わらず、その都度再計算されてしまいます。Contextの優秀なところは、Providerで流している他の値が変化しても変化していない値はそのままの参照になっていることです。それによってcontextValue3の更新によってcontextValue2のuseMemoが(逆の場合も然り)再計算にならない点が使い勝手が良いと思います。

contextValue1 = a:111の場合

こちらは使用方法が間違っている検証になります。
letで定義された変数に値を代入しても、TestContextが再レンダリングされないためbutton押下でlog出力は起きません。
また、TestContext内でcontextValue1が宣言されているので、TestContext Componentが再レンダリング時にcontextValue1の参照が変わり、contextValue1memoを再計算されます。

setcontextValue2((v) => v)の場合

同一性の観点で等しい値が入るため、再計算されません

setcontextValue2(b: 1)の場合

等価性の観点では等しくても、同一性が変わる(参照が変わる)ため毎回contextValue2memoのみ再レンダリングされます。(contextValue3memoは再計算されない)

setcontextValue3((v) => v)の場合

等価性の観点で等しい値が入るため、再計算されません。

setcontextValue3(0)の場合

0は等価性の観点で値が等しいため、再計算されません。

setcontextValue3((v) => v + 1)の場合

等価性の観点で値が毎回変わるため、contextValue3memoは再計算されます。(contextValue2memoは再計算されない)

Propsをdepsに入れたパターン

PropsのパターンもContextの場合と同じです。useStateのsetStateでstateを変更した際はそのComponentが再レンダリングされるため、そのComponent内で定義されたプレーンなObjectは参照が変更され、子ComponentでuseMemoを使用していてもrenderされてしまいます。

setcount((v) => v)の場合

等価性の観点で等しい値が入るため、再計算されません。

setcount((v) => v + 1)の場合

countMemoが再計算されます。obj2もcomponentの再レンダリングに伴い再生成され参照が変わるため、obj2Memoも再計算されます。
count2Memoは、同一性の観点で等しい値がPropsで渡っているため、再計算されません。
※Object.is()の比較方法の違いを意識するべき点がここになります。

setcount(0)の場合

0は等価性の観点で値が等しいため、再計算されません。

setobj((v) => v)の場合

等価性と同一性ともに等しい値が入るため、再計算されません。

setobj( a: 0 )の場合

等価性が同じの場合でも、同一性が変わる(参照が変わる)ため毎回再計算されます。

まとめ

Reactは単方向データバインディングなので、余計なレンダリングを実装者が制御できパフォーマンス良く実装できる点がメリットの1つだと思いますが、その反面どのように不要なレンダリングを減らすかという点に難しさが出てくるのではないでしょうか。今回の記事でその難しさを少しでも紐解ければ幸いです。

参考文献

https://scrapbox.io/terrierscript/React_hooksのdependencyについて

VIRGINIA Tech Blog

Discussion