281
168

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 1

【React】useEffect の標準動作は「依存配列の中身が変わると実行」ではない

Last updated at Posted at 2022-11-30

useEffect とは何か、ご存知ですか?

useEffect? 知ってるよ。
依存配列に入れた値が変更されるたびに関数が実行されるフックでしょ?

これは半分正解ですが、半分間違っています。

  1. useEffect のデフォルトの挙動は「レンダーのたびに毎回実行」です。
    • 依存配列は「変わった時に実行する」というより「変わらなければスキップ」と捉えたほうが良いかもしれません。
  2. useEffect は再レンダー以外の変化を検知できません。
    • 特にミュータブルなオブジェクトが絡む場合は注意

React 公式のドキュメントの解説を見ながら、以上の2つのポイントに絞って、誤解を解いていこうと思います。

2023/03/21 追記: この記事では、 旧ドキュメントにそって説明します。 react.dev の新ドキュメント も読むことをオススメします!


宣伝 useMemo, useState についても記事を書きました。よかったらこちらも確認してください。

2023/10/03 追記: ブラッシュアップしました

ブラッシュアップしたので、そちらの記事も確認してみてください。

2023/03/21 追記: いきなり結論

  • レンダーのためのデータ変換にエフェクトは必要ありません。 (中略)
  • ユーザイベントの処理にエフェクトは必要ありません。 (中略)

エフェクトは、外部システムと同期したい場合には必要です。
https://ja.react.dev/learn/you-might-not-need-an-effect#how-to-remove-unnecessary-effects

React の新ドキュメント ja.react.dev/learn では、 useEffect の使い方は Escape Hatches (緊急避難口) の章に押し込まれています。 この章のラベルには "ADVANCED"、 つまり「発展的な内容」と書かれています。

私の見方では、 Effect (および Ref) は、「イベントハンドラ (on▲▲ Prop) によってステートを変更する」という基礎的な枠組みだけでは実装できないような Reactのステートシステムから外れた操作 が出てきたときに、はじめて検討の対象に上がる要素だと考えています。

1. useEffect の依存配列は「変わった時に実行する」というより「変わらなければスキップ」

はじめてこの記事を読んだ人は最初のコードを見た時に驚くと思います。

なんと、最初のコード例では依存配列を使用していないのです。

// ドキュメントより引用

// Similar to componentDidMount and componentDidUpdate:
useEffect(() => {
  // Update the document title using the browser API
  document.title = `You clicked ${count} times`;
});

そして、依存配列についての解説は、『ヒント:副作用のスキップによるパフォーマンス改善』の節 でようやく始まります。

なぜなら、 依存配列を指定しなかった場合の「副作用がレンダーごとに欠かさず実行される」というのが useEffect のデフォルトの挙動だからです。

(2023/06/17 挿絵を追加しました)

▼ 依存配列を指定せず、再レンダリングのたびに副作用が発生している様子(後述のクリーンアップ関数も含めています)

挿絵

▼ 依存配列を指定して、副作用の実行がスキップされる様子

挿絵

依存配列は、「列挙した値が変わったら、関数の中身を実行する」という意味ではなく、「レンダー後に実行する副作用を記述し、列挙した値が変わらなかった場合は実行をスキップする」という意味になります。

つまり、実行のトリガーを記述しているのではなく、実行回数を減らしてパフォーマンスを改善するために必要な情報を記述しているわけです。

そう考えると、 useMemo・useCallback・依存配列指定 useEffect がいずれも「パフォーマンスのために(計算|参照の更新|副作用の実行)をスキップする」と解釈できますね。これで useEffect の「依存配列なし」「依存配列が空配列」の挙動がそれぞれどっちだったか忘れる心配が無くなりました。

「フックを使った例」の節でも、以下のように書かれています。

useEffect は毎回のレンダー後に呼ばれるのか? その通りです! デフォルトでは、副作用関数は初回のレンダー時および毎回の更新時に呼び出されます。あとでカスタマイズする方法 (注:「ヒント:副作用のスキップによるパフォーマンス改善」の節へのリンク) について説明します。「マウント」と「更新」という観点で考えるのではなく、「レンダーの後」に副作用は起こる、というように考える方が簡単かもしれません。React は、副作用が実行される時点では DOM が正しく更新され終わっていることを保証します。

ただし、ステートの書き換えが無限ループを起こしてしまう場合には、依存配列を使ってそれを止めることが必須になります。

余談:⚠強く推奨⚠ eslint の react-hooks/exhaustive-deps を使う

React で開発する際には、 eslint の react-hooks/exhaustive-deps ルールを設定することを 強くオススメ します。

const [name, setName] = useState("")
useEffect(() => {
  console.log(`name の変更を検知しました: ${name}`);
}, []); // [name] と指定する必要がある

依存配列を指定しない場合は、抜け・漏れなく副作用が実行されてくれますが、指定する場合は配列の中に必ず name を入れる必要があります。 name は state なのでリアクティブな値であり、指定を忘れた場合、 name の値が変化したときに副作用が実行されません。

このように console.log() を出すだけなら問題ありませんが、 実際の開発では、このような書き忘れが思いがけない挙動・バグを引き起こす原因になります。

副作用の処理内で参照されているリアクティブな値は、全て依存配列に入っている のが理想です。

われわれ人間の注意力にも限界があるので、それは eslint にお任せしてしまいましょう。

eslint の react-hooks/exhaustive-deps 警告に全面的に従うことを強くおすすめします。 off にしたり ignore したりすると、バグが入り込む割れ窓になってしまいます。

eslint のエラーを誤魔化さずに副作用の不要な実行を抑止する方法については、 次節も参考になるかもしれません。

▼ eslint を無視せずに依存配列から不要なモノを排除する方法についての記事

You Might Not Need an Effect

そもそも、依存配列を消したり、exhaustive-deps を ignore したら挙動が変わってしまうような useEffect は、冪(べき)等でない可能性があります。そのような副作用は、React 本来の意図から外れているようです。 (参考: React 18 alpha版発表まとめ # StrictMode での useEffect の挙動の変化)

下の記事を参考に useEffect を使わない書き方を試してみることをオススメします。 useEffect を使わない書き方だと、コードがシンプルになり追いやすくなり、バグの発生を防げる利点もあります。

2. useEffect は再レンダー以外の変化を検知できない。

間違った例.tsx
// ❌ 動かない
const ref = useRef<HTMLDivElement>(null)
useEffect(() => {
  const elem = ref.current
  if(!elem) return 

  console.log(`(${elem.clientWidth}, ${elem.clientHeight})`)
}, [ref.current])

実のDOM要素の幅・高さを出力したいとき、上記のようなコードを書いても意図の通り動いてくれません。特に気をつける必要があるのは、ブラウザのウィンドウのサイズを変えて要素のサイズが変わっても、console.log が表示されるわけではない点です。

eslint を設定している場合は、次のような warning が表示されると思います。

React Hook useEffect has an unnecessary dependency: 'ref.current'. Either exclude it or remove the dependency array. Mutable values like 'ref.current' aren't valid dependencies because mutating them doesn't re-render the component. eslint(react-hooks/exhaustive-deps)

このメッセージを見れば分かる通り、ミュータブルなオブジェクトの中身が変わっても、それ自体を検知する機能を useEffect は持ち合わせていないのです。 (公式ドキュメントを読めば分かるように) あくまでも 副作用が実行されるタイミングは、レンダーの後 なのです。


React において、「レンダー」は、原則的に「useState() で宣言した『ステート』がsetXxx()関数の呼び出しによって更新された時」に起こります。(useReducer の dispatch() 関数も同じ機能なので同様)

以下の出来事については、React の『ステート』の変化ではないため、useEffect では検知できない、ということです。

  • let 変数への再代入
  • mutable なオブジェクトの変更
  • ref の中身の変更 (ref.current = newValue)
  • 画面のリサイズなど、React を通さずにブラウザ側で起こった変化

では、これらの変更をどのように検知すればよいのでしょうか。

  1. useEffect とクリーンアップ関数で、イベントリスナーを登録して変更を検知する
  2. useExternalStore で、外部のストアを監視して最新の値を取得する (上級者向け)

の2つの方法があります。

検知する方法1: useEffect の中でイベントリスナーを登録する

「クリーンアップを有する副作用」の節 を見てみましょう。

useEffect 変更の監視を開始(subscribe)するコードと、終了(unsubscribe)する関数を、コード例のように組み合わせて書くと、画面のリサイズ等のタイミングで React 側の『ステート』を更新する処理(ここでは setIsOnline 関数)を呼び出し、画面表示に反映することができます。

useEffect の関数から返された関数をクリーンアップ関数といいます。 React はクリーンアップ関数を次回の副作用実行の直前およびアンマウント時に呼び出してくれます。

// 公式ドキュメントより引用
useEffect(() => {
  function handleStatusChange(status) {
    setIsOnline(status.isOnline);
  }
  ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange);
  // Specify how to clean up after this effect:
  return function cleanup() {
    ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange);
  };
});

「副作用」としてリスナーなどを登録して、クリーンアップ関数で「副作用の後片付けをする」

というイディオムを一箇所にまとめて書かせることで、「引数などが変わるたびにイベントリスナー等追加されて、解除を忘れて大量のリスナーが繋ぎっぱなし」といった事故を防いでいる、といった感覚でしょうか。

このパターンで利用できるブラウザ標準の機能には、以下のようなものがあります。

  • スクロールによって特定の要素が見えるところに入ってきたことを検知
  • DOM要素のサイズ変更を検知
  • 色々なイベントリスナの利用
    • .addEventListener() / .removeEventListener()
    • 例: Window の 'popstate' イベント
      • 「戻る」ボタン押下を検知

対応する機能が標準機能やライブラリに含まれない場合は、 setInterval 等を使って力技で解決...するのでしょうか。
出会ったことがないケースなので分かりませんが。

先程の「要素のサイズが変わったら表示する」例で言えば、 ResizeObserver 機能を利用して、要素のサイズの変更を監視し、変わるごとにそのサイズを出力することができます。

これを応用すれば、直接の親子関係にない要素であっても、「ある要素のサイズに追従してもうひとつの要素のサイズを変更する」といったケースに使えます。

const ref = useRef<HTMLDivElement>(null);
useEffect(() => {
  const elem = ref.current;
  if (!elem) return;

  const observer = new ResizeObserver((entries) => {
    entries.forEach((entry) => {
      const size = entry.borderBoxSize[0];
      console.log(`(${size.inlineSize}, ${size.blockSize})`);
    });
  });

  // オブザーバをDOM要素に接続する
  observer.observe(elem);

  return () => {
    // クリーンアップ関数で、オブザーバによる監視をすべて終了する
    observer.disconnect();
  };
}, []);

検知する方法2: (上級者向け) useSyncExternalStore

React 18 で追加された、ライブラリ作者向けの新 Hook、useSyncExternalStore も利用できます。

Advanced な内容なので説明は省略しますが、navigator.onLine のように、React の『ステート』外の状態を監視して、その値を読み出すのに利用することが出来ます。

You might not need an Effect # subscribing to an external store が参考になると思います。

おわりに

useEffect の適切な使い方・使わないほうが良い場面を知れば、バグの予防・不要なコードの削除にもつながります。
みなさんも良い React ライフを〜

281
168
4

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
281
168

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?