145
92

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

ReactAdvent Calendar 2022

Day 15

【React】誤解される useMemo と 誤用される useState ―― 「A の変更に反応して B の値が変わる」と考えるべきでない

Last updated at Posted at 2022-12-14

rerender_state.png

React において、 「フルネームは、名字と名前をつなげたもの(名字と名前は変わりうる)」 はどのように表せば良いのでしょうか?

useState + useEffect でしょうか? useEffect は変更検知のためのものではありません。

useState は、「Prop・他のステートが変化するたびに毎回反応する」ような書き方をするのに向いていません。コードは無駄に長くなりますし、SSoT (Single Source of Truth) の原則に則していないので読みづらいです。

むしろ「ほかの値が変わっても、この値は変わらない」というケースに有効ですが、この点については、 『2. useState は初回レンダー(マウント)時のみ代入される』の章で述べます。


詳しい人は 「useMemo を使えばいい」と御存知かもしれませんが、 useMemo のことを「依存配列の値が変わったら再計算するフック」だと思っていませんか?

これは半分合っていますが、半分間違っています。

useMemo は「変更を検知して再計算」するものではありません。むしろコンポーネント内の「生の式」は再レンダーのたびに再計算され、 useMemo は「しなくて良い場合には再計算をスキップ」してくれる機能と考えるべきだと思います。

(2024/02/12追記) Short 動画もあるよ

1. 生の式は再レンダーごとに計算される

function SomeComponent() {
  // ...
  const fullName = surname + " " + personalName;
  // ...
}

のように書くと、コンポーネントの再レンダーのたびに fullName の値が surname, personalName をもとに計算されます。const 宣言の右辺だけでなく React のコンポーネント関数の直下に書かれた式すべて(コールバック・フックではない) はコンポーネントの再レンダーのたびに計算されます。

上記のデモでは、 fullName の末尾に、この式を実行した瞬間の時刻 ("/last update @ (時刻)")を追加しています。

各ステートが初期化、変更される様子と、 fullName が再レンダーのたびに再計算される様子は次のような図に表すことが出来ます。 (下図では、時刻の文字列は省略しています。)

rerender_state.png

レンダーする瞬間の「状態のスナップショット」を、この図では白い一枚のシートにして表しています。この考え方が、 React の関数コンポーネントのメンタルモデルを理解する一助になるのでは、と思います。

シート内に書かれた 変数名: 値 の文字色は、次のような状況に対応しています。

    • 値が再計算された
  • 濃い黒
    • ステートが初期化・変更された
  • グレー
    • ステートが変わらなかった

参考:(コールバック関数等も、このようなメンタルモデルで使えるように設計されていることを述べている)

useMemo が計算をスキップしなかったら壊れる」ようなコードを書かない

useMemo はパフォーマンス最適化のために使うものであり、意味上の保証があるものだと考えないでください。」とReact の公式ドキュメントにも書かれている通り、特にプリミティブでないオブジェクトを使う時には、 === の結果が変わって予期せぬ挙動が起きないように注意が必要です。

(もっとも、現在の実装ではそれでも問題は起きないようですが、将来的にはメモリを解放するために画面外の要素についてメモ化した値を『忘れる』ようになる可能性があるようです。)

https://ja.reactjs.org/docs/hooks-reference.html#usememo

2. useState は初回レンダー(マウント)時のみ代入される

生の式が再レンダーごとに再計算される、と前の章では述べました。

それとは対照的に、useState(initialValue)初回レンダーのときにのみ代入されて、2回目以降は前回の結果を記憶して取り出している (set〇〇しない限り)かのように振る舞う、という違いがあります。

useState(0)useState("") のように、ありきたりな初期値を設定できるだけではありません。初回レンダー(=マウント)時に一度だけ初期化され、それ以降変わらない値(オブジェクトを含む)を用意するのにも useState() は使用できます。

以下の例は、 SSR すると Hydration 時に Warning が発生する可能性があります。

SSR 時の new Date() の値と、 Hydration 時の new Date() の値が一致しないからです。

上の例では、"Mounted at" の後に、コンポーネントがマウントされた時刻が表示されています。左上にある更新ボタンを押すとリロードされて時刻が更新されるものの、テキストを入力して再レンダーをいくら引き起こしてもこの時刻は更新されません

ちなみに useState(new Date()) のように書いた場合、 2回目以降のレンダーでは、その値が利用されないのにも関わらず new Date() が実行されます。 それを避けるために useState(() => new Date()) と書くことができます。

new Date() するコストはたかが知れてるので不要ですが、初期値の計算がヘビーな場合には役に立ちます。

変化の様子を図にすると次のようになります。

rerender_prevented_state.png

参考:

初期値が変更されてもステートは変更されない

裏を返せば、「初期値は Props から、あるいは他のステートから受け取る」といった場合には、注意が必要です。

initialTextValue の値が変わったときに、textValue ステートがその値でリセットされてほしい」という要件を満たしたいときに、以下のように書いてしまうと、要件が満たせません。

// 使用側
<SomeComponent 
  initialTextValue={initialTextValue} 
/>

// 定義
function SomeComponent({ initialTextValue }) {
  const [textValue, setTextValue] = setState(initialTextValue)
  // ...
} 

ステートの初期値が設定されるのは初回レンダーの時のみなので、それより後のタイミングで initialTextValue をいくら変更しても、初期化されません。笛吹けど踊らず。

解決方法1:keyを設定する

React は、コンポーネントの key が変わったとき(例: "" -> "Alice")、「変わる前の SomeComponent (key="") 」「変わったあとの SomeComponent (key="Alice")」 を別物とみなし、前者のコンポーネントをアンマウントして後者のコンポーネントをマウントします。なので、ステートの初期値が再設定される、という算段です。

「初期値が変わる」→「コンポーネント自体をリセットする」と読み替える必要があるので少し取っつきにくく、親側にそのコントロールの責務が渡ってしまうので取っつきにくいですが、コロンブスの卵的でシンプルな解決法だと思います。

(ただ、複数の値を組み合わせる場合には、ハッシュ化したりしないとダメ...なのかな?)

解決法その1 keyを使う
  // 使用側
  <SomeComponent
+   // initialTextValue が変わるたびにリセットする
+   key={initialTextValue}
    initialTextValue={initialTextValue} 
  />

解決方法2:「値が変わった時に〇〇する」イディオム

もう一つには、「値が変わった時に〇〇する」イディオムを使う方法です。
素朴で分かりやすいですが、記述が多く、「コンポーネントを全てリセットする」というシンプルさが無い。(このステートをリセットすればあれも...という依存関係がある場合はどうする?)のが特徴です。

ただ、アンマウント・再マウントされてしまうと困るような場合や、一部のステートだけをリセットしないといけない場合には活躍するかも知れません。

解決法その2 keyを使う
  // 定義
  function SomeComponent({ initialTextValue }) {
    const [textValue, setTextValue] = setState(initialTextValue)

+   // initialTextValue の変化を検出し、その値を textValue ステートにセットする
+   const [prevInitialTextValue, setPrevInitialTextValue] = useState(initialTextValue)
+   if (prevInitialTextValue !== initialTextValue) { 
+     setPrevInitialTextValue(initialTextValue)
+     setTextValue(initialTextValue)
+   }
    // ...
  } 

いつもの

詳しくはいつもの 「You Might Not Need an Effect」を参照して下さい。

まとめ

React でコーディングする際には「〇〇の値が変わったから、それに依存した▲▲も更新する」と考えると思った通りの挙動にならないことがあります。それよりも、様々な式は「以下の例外を除いて全て、再レンダーごとに実行される」と考えたほうが良いでしょう。

  • useMemo, useCallback
    • 依存配列を比較して、更新する必要がなければ前のレンダー時の結果を使い回す
  • useState, useRef, etc.
    • 変更(〇〇Ref.current=newValue, set〇〇)しない限り、再レンダーされても同じオブジェクトを保持しつづける
    • useState, useReducer の更新関数 (set〇〇, dispatch) はつねに変わらない。

馴染んでくると、「ステートを変更するごとに関数が何回も呼ばれるだけ」「コンポーネント定義はレンダー時の『状態のスナップショット』の様子を記述している」という単純なメンタルモデルだけでほとんどの記述できるようになり、Proxy などに頼らずにモデル・ UI の変化を記述できる React の設計の美しさに感動するでしょう。

(2024/02/17 変更) 旧: 「Proxy などに頼らず」とありましたが、ここは訂正します。

React は「ステート更新のために、『React というシステム』にリクエストを投げる」という面倒なワンクッションがあります。

その代わり、Vue (script ではなく template 側) や Svelte と違って、コンパイル時に「count = 2 と書くと setCount(2) に変換される」といったコード変換が挟まらないので、「どういうコードが目的通りに変換されるのか」と考えることが無いのが嬉しいです。

Vue や Svelte の人は、それでも使いこなしてるのでしょうけど…

ともかく、裏側で Proxy が使われているとかは関係ありませんでした。(むしろ、Vue は Proxy や setter, getter を使うことでコンパイル時の変換を減らして、『素のJS』に寄るので分かりやすそう)

(2024/02/17 16:34 さらに一部修正)

145
92
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
145
92

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?