🤖

【1日1zenn - day8】ReactのライフサイクルからuseEffect, useCallbackの使い分けまで

に公開

1日1zennの8日目です。
昨日の以下の記事で、useEffectやuseCallbackなどの根本を理解してないことが顕在化したので、今日はそれについてまとめます。
https://zenn.dev/shunsuke108m/articles/d1a8adcdf95007
出社して疲れたのでサクッとやるイメージ。行動目標なので、質とにかく上げたかを大事にしていきたい。

流れとして、関連しそうな記事の学びや気になりを雑に箇条書きしていった上で、最終的に自分の理解を言語化しようと思ってます。

読書感想文

1記事目

https://zenn.dev/koya_tech/articles/16d8b11b5062bd

  • クラスコンポーネントと関数コンポーネントがあり、自分が普段使っているのは関数コンポーネントだが、これはクラスコンポーネントの概念を前提としているので齟齬が生じていそう
    ↓クラスコンポーネント
class Greeting extends Component {
  render() {
    return <h1>Hello, {this.props.name}!</h1>;
  }
}

↓関数コンポーネント

export default function Profile() {
  return (
    <img
      src="https://i.imgur.com/MK3eW3Am.jpg"
      alt="Katherine Johnson"
    />
  )
}
  • 関数コンポ × hookでクラスコンポのデメリットを消しているが、hook利用制限があって学習コストが高い
    • クラスコンポーネントよりもライフサイクルが複雑だし、ドキュメントに記載がないらしい。まあクラス前提で作られた仕様を使い回してる的なUX負債のイメージ。ドキュメントに記載してないなら今後変わり得るのか??
    • そして関数コンポーネントでは、厳密に言うとライフサイクルがあるわけではなく、hookによってライフサイクルを再現してるイメージっぽい。
  • ブラウザのレンダリングは「画面を描写する」だが、Reactのレンダリングは意味が違う。これで結構混乱してた。以下はクラスコンポーネントのライフサイクル
    • 【Triggering a render】最初に画面を表示するときと、画面更新するときに、renderのトリガーが引かれる
    • 【Rendering the component】トリガーが引かれた後、最初に画面を表示する時はルートコンポーネントからコンポーネント全体を取得し、画面更新するときには前回と差分があるコンポーネントだけを取得(≒特定)する
      • これがReactにおけるレンダリング。対象コンポーネントの特定。
      • ここには仮想DOMが使われている
        • 実際のDOMの複製品で、更新前の仮想DOMと更新後の仮想DOMを持ち、それらの差分を検出する
      • 元々Reactは1箇所の更新のために画面全体が再描画されるのを防ぐために作られたテンションだったので、この更新箇所の特定がReactの肝な臭いはする。
    • 【Committing to the DOM】描画対象のコンポーネントが特定されたら、そのコンポーネントを実行してDOMに反映
    • その後、DOMの内容に基づき画面に描画される
      • これがブラウザのレンダリングかな。
  • 関数コンポーネントでは、これがどう再現されているか
    • 初回表示時
      • 必要な場合はuseMemoが実行
      • その後DOMとrefを更新
      • その後useEffectとかが走る
        • 昔はcomponentDidUpdate()とかをしないと更新された旨を検出できなかった?
        • 要は更新対象を指定するために存在している?
      • その後必要に応じて?effectをクリーンアップする?
    • 更新時
      • useState、useReducer、useContextなどが実行
      • その後必要に応じてuseCallbackが実行
      • その後DOMとrefを更新
      • その後useEffectとかが走る
      • その後必要に応じて?effectをクリーンアップする?

まだよくわかってないので、無課金GPTとお話ししてみる。

クラスコンポーネントでは、更新対象のDOM要素がどこかを特定するRendering the componentという処理があったと思います。関数コンポーネントではどのように特定していますか?

  • クラスコンポーネントではrender()メソッドを呼び出して新しい仮想DOMを生成していた
  • 関数コンポーネントではrender()が存在しないが、関数コンポーネントを単なる関数として実行することで仮想DOM(JSX)を生成し、以下を比較する
    • リストなどでkey属性が設定されている場合、Reactはそのkeyを使って効率的に要素の変更を検出します。
    • 要素の型(例: <div>やコンポーネント名)が同じ場合、Reactはその要素を「同じもの」とみなして差分を計算します。
    • 要素の型(例: <div>やコンポーネント名)が同じ場合、Reactはその要素を「同じもの」とみなして差分を計算します。
  • 関数コンポーネントが再実行されるタイミング
    • useStateやuseReducerを使って状態が変化すると、そのコンポーネントが再実行されます(関数がもう一度呼び出される)。
    • useContextで取得している値が変化すると、関数コンポーネントが再実行されます。
  • 再実行する必要ない(変更がなかった)関数が再実行されないための制御
    • React.memoを使うと、親コンポーネントが再レンダリングされても、子コンポーネントが不要な再レンダリングを防げます。
    • 関数や値をメモ化して、無駄な再レンダリングを防ぎます。

render()がない代わりに、関数コンポーネントの場合は全部一度実行された上で、変更があった部分だけDOMに反映するイメージですか?正確に理解したいので、少しでも違いそうな部分や抜け落ちている観点、エッジケースなどがあれば補足してください。

  • 基本合ってる
  • 補足
    • 「全部実行」の意味の正確性
      • 再実行時にuseStateやuseRefの値は維持されます
      • 再実行が発生しても、useEffectやuseLayoutEffectは必要な場合にのみ実行されます。依存配列([dependencies])を使うことで、不要な処理を防ぐ設計が可能です。
    • 全てのDOM要素が破棄→再生成されるわけではない
      • Reactは同一性を維持するための最適化を行います。
        • 要素のkeyや型(<div>など)が変わらない場合、リアルDOMは再利用されます。
        • 属性(例: classNameやstyle)や子要素のみ変更される場合、その部分だけを更新します。
    • エッジケース:キー(Key)が重要な場合
      • リストをレンダリングする場合、key属性が不適切だと、Reactの差分検出が失敗することがあります。
        • ex)keyがMath.randomで割り振られる場合などは、キーが毎回変わるため、Reactは「全ての<li>が異なる」と認識し、毎回DOMを破棄して再作成します。

useMemo等を使うことで再実行を防いでいないと、ルートコンポーネントから全ての関数が実行されるイメージであってますか?実行された上で、実行結果に差分がない関数についてはDOMが再生成されない?

  • その理解でほぼ正しいですが、さらに正確な動作を細かく説明します。
  • 結論から言うと
    • useMemoやReact.memoを使わない場合、ルートコンポーネントからすべての関数コンポーネントが再実行されます。
      • 再実行とは、各関数コンポーネントが呼び出されて新しい仮想DOMが生成されるということです。
    • 再実行された結果(新しい仮想DOM)が、前回の仮想DOMと比較されます。
      • 差分が見つからない場合、その部分のリアルDOMは更新されません(差分検出=Reconciliationによる最適化)。
  • 以下のファイルで説明してくれた。
function App() {
  const [count, setCount] = useState(0);
  return (
    <div>
      <button onClick={() => setCount((c) => c + 1)}>Click</button>
      <Parent count={count} />
    </div>
  );
}

function Parent({ count }) {
  const message = `Count is: ${count}`;
  return <Child message={message} />;
}

function Child({ message }) {
  console.log('Child rendered');
  return <div>{message}</div>;
}
  • 状態更新の流れ
    • buttonのクリックによりsetCountが呼び出される
    • Appが再実行され、新しい仮想DOMが生成される
    • 併せてParentとChildも再実行される(関数がもう一度呼び出される)。
    • Childが再実行された結果、新しい仮想DOMを生成
    • その仮想DOMが前回の仮想DOMと比較され、差分がなければリアルDOMの更新は発生しない
    • Child renderedが再度表示される
  • useMemoなどが役立つ理由
    • たとえば上記の```message``が毎回実行されると重い処理の場合、countが変化しなければ実行しないよう制御できる
    • そもそもParentなども実行させたくない場合は、const Child = React.memo(function Child({ message })みたいに書くとよい
    • 再実行自体はDOM更新と比べると軽量な処理だが、中でも重い処理がある場合は再実行自体されないように制御した方がいい
    • なお再実行した場合でも、useStateやuseRefの値は保持される

再実行後に差分があるかは、その関数のreturn結果に差分があるかで見ますか?

  • はい、その通りです!再実行後に差分があるかどうかは、関数コンポーネントのreturn結果であるJSXが、前回の結果(仮想DOM)と比較されて判断されます。
  • しかし、この「差分を見る」という仕組みをもう少し細かく分解して説明します。
  • Reactが差分を検出する仕組み
    • 関数コンポーネントの再実行
      • 状態やpropsの変化によって関数コンポーネントが再実行されます。
      • 再実行されると、新しいJSXツリー(仮想DOM)が生成されます。
    • 仮想DOMの比較(Reconciliation)
      • 新しい仮想DOMと前回の仮想DOMを比較します。
      • 比較する際、以下の要素を考慮します
        • 要素の型(タグの種類やコンポーネントの種類)
        • key属性(リストや動的要素の場合)
        • 属性(propsやイベントハンドラなど)
        • 子要素
    • 差分がある場合のみ、リアルDOMを更新
  • 差分の基準:関数のreturn結果
    • Reactでは、関数コンポーネントのreturn結果が仮想DOMの構造に変換されます。この結果が前回の仮想DOMと比較され、差分があるかどうかを以下のように判断します。
      差分がない場合
function Component({ value }) {
  return <div>{value}</div>;
}
  • このvalueが変わらないときは、DOM更新されない

差分がある場合

function Component({ value }) {
  return <div>{value}</div>;
}
  • このvalueが変わったときは、returnの内容が変わるのでDOM更新される

要素の型が変わる場合

function Component({ isButton }) {
  return isButton ? <button>Click</button> : <div>Not a button</div>;
}
  • isButtonじゃなくなった時は、buttonではなくdivがreturnされるのでDOMが更新される
    なお、
  • React.memoは、propsが変わらない限り関数コンポーネントの再実行をスキップします。この仕組みでは、return結果ではなくpropsの比較に基づいて処理が最適化されます。
    とのこと。

なるほどね。
ハルシネーション怖いけど、結構理解深まった気がする。
現状の理解をまとめながらググってファクトチェックする。公式ドキュメントはまだ一旦見ない。

  • 再実行されるタイミングはstateやpropsが変わったとき

    • https://zenn.dev/b1essk/articles/react-re-rendering
      • stateとpropsの更新時だけでなく、親コンポーネントが再レンダリングされた時も再実行される
      • それらを防ぐためにmemoやuseCallback、useMemoがある
        • 子コンポーネントをmemo化することで親コンポーネントの再レンダリングの影響を受けないようにしていたとしても、親コンポーネントで生成した関数をpropsで子コンポーネントに渡すと、関数の内容が同じだとしても子コンポーネントでは毎回新しい関数を渡されたと認識するらしい
        • そうならないように、子コンポーネントに渡したい関数はuseCallbackで定義し、必要な時だけ本当に新しい関数を渡したと認識させるために第二引数にdependencyを定義する
      • 疑問、useEffectもdependencyに書いた値が変化しないと再実行しないはずだが、メモ化はしない?
        一旦AIに聞く。
        ちなみにuseEffectとuseCallbackは、再実行されるタイミングや条件は完全に一緒ですか?
        また違いとして、useEffectで書いた値をpropsで子コンポーネントに渡していた場合、useCallbackと違って子コンポーネントは毎回再実行されますか?
  • 結論から言うと:

    • useEffectとuseCallbackの再実行タイミングや条件は似ていますが、完全には一致しません。
    • 両者とも依存配列(dependency array)を基に動作しますが、再実行される「対象」と「目的」が異なります。
    • useEffectで生成した値や関数をpropsとして子コンポーネントに渡すと、子コンポーネントが毎回再実行される可能性が高いです。
    • 特に、useEffectで新しい関数や値を生成する場合、それがpropsとして渡されるたびに子コンポーネントは再レンダリング(再実行)されます。
    • 一方、useCallbackで関数をメモ化すれば、propsとして渡された関数が再生成されないため、子コンポーネントの再実行を防ぐことができます。
      ざっくり合ってそう。
  • useEffectとuseCallbackの役割と再実行タイミング

    • useEffect
      • 目的
        • 副作用(データフェッチ、DOM操作、イベントリスナー登録など)を実行するため。
      • タイミング(トリガー)
        • 初回レンダリング後
        • dependency内の値が変わった時
      • 再実行の対象
        • 関数内の副作用が再実行される(=毎回新しい値や関数を生成する可能性がある)
    • useCallback
      • 目的
        • 関数をメモ化して、毎回新しい関数を生成することを防ぐ。
      • タイミング(トリガー)
        • 初回レンダリング後
        • dependency内の値が変わった時
      • 再実行の対象
        • コールバック関数を作り直すが、関数自体を再実行はしない
          なるほど。useCallbackの場合は、stateの変化で再レンダリングが走った上でdependencyが変わっていたからといって、その中で記載しているconsole.logが呼ばれたりするほどではない感じなのね。あくまで実行は、useCallbackの結果の関数を受け取っている要素が再レンダリングされたとき。
          合ってたらいいな。今度ちゃんとドキュメントを読もう。今なら理解できる気がする。

改めて理解をまとめ直す。

  • 前提、普段自分が使っているものは関数コンポーネントであり、それらは文字通り全て関数で、実行結果を仮想DOMとして保持し、比較する
  • 再実行されるタイミングはstateやpropsが変わったときと、親コンポーネントが再実行された時
    • なお子コンポーネントにpropsで関数を渡す場合、関数の内容が全く変わらない場合でも「新しいpropsが渡された」と認識されて再実行されてしまうので、それを防ぐためにuseCallbackなどで関数のメモ化を行う。
  • 再実行された後には、useMemoやuseCallback、useEffectなどで特段制御されていない場合、関数コンポーネントやその中の関数は全部再実行される
    • なおuseCallbackの場合は再実行されたところで関数が再生成されるだけでその中身までは実行されないが、useEffectは関数の中身まで実行される
  • 再実行された結果JSXツリー(仮想DOM)が作られて、全体のJSXツリーと差分がある部分だけDOMが更新される
    • 差分は関数のreturn結果で以下が変わっているかに応じて判断される
      • 要素の型(タグの種類やコンポーネントの種類)
        • コンポーネントを出しわけていたり、divとbuttonを出しわけていたり。
      • key属性(リストや動的要素の場合)
        • item.idとか。
      • 属性(propsやイベントハンドラなど)
        • 単にpropsの内容が変わっていたらそりゃ描画するよね的な。
      • 子要素
    • ここもうちょっと正確に理解したい。
  • 更新された分のDOMがブラウザに再描画される

これを踏まえて思考実験。
たとえば昨日の、1秒ごとにcountを追加する処理を作りたい場合にuseEffectなりuseCallbackなり何を使うかで言うと、なるほど、子コンポーネントに渡すか次第になりそう。昨日読んだ記事が腹落ちした。
一方で、実務でuseEffectあんま使わない方がいいと言われることが多いが、これは無駄に再実行されやすいからなのかな。子コンポーネントに渡さないならいいのでは?と思いつつ、テスト書きやすいようにhooksに切り出したりしてるのが関係してるのかなとか。hooksって親子関係ってイメージじゃないから直感的には分かりにくいのと、ビール飲みながら書いてて頭回らなくなってきたので積み残そうと思う。

今日の学びと積み残し

  • 関数コンポーネントが再現している、本来クラスコンポーネントで行われていたライフサイクルの流れはざっくり把握できた
    • 公式ドキュメントをがっつり読んでファクトチェックは必要
      • AIとの会話で得た情報も多いので、ハルシネーションありそう。
    • 各論や使用方法的なのもまだ必要。以下シリーズは読もうと思う。
  • そもそもReactの仕組み的な部分を結構ディグれた
    • 高速化のために再DOM描画させたくないし、そもそも再実行すらさせたくない的な思想面とか感じた
  • useCallbackとuseEffectの違いについて、今まで何度記事を読んでも腹落ちしなかったのが腹落ちできた
    • メモ化ってそういうことね、という。
  • 元々これ踏まえてテストコード書こうと思っていたが、あんま関係なかった

Discussion