😇

useEffectの依存配列は自分で決めていいの?

2024/11/15に公開

だめです。絶対にやらないでください。

少し前の僕:「このset関数、このステートが変更された時に実行したいなぁ。依存配列をこうしてっと。ん?なんかlintのerrorがでてる。コメントアウトしたら消えるらしい。これでよし!」

すべてだめです。

ただ、Reactに携わっている方なら、一度は依存配列の扱いに悩んだことがあると思います。

少し前の僕:「じゃあ、どうすればいいの?」

そうですね、それでは順を追って説明していきます。

レンダリングについて理解しよう

少し前の僕:「コンポーネント内の関数がいつ実行されるかわからんから、useEffectで制御してるんだよ!」

大丈夫です。わかるようになります。

まず、Reactにおけるレンダリングとは、Reactがコンポーネントを呼び出すことを指します。
呼び出されたコンポーネント内で定義されている関数や変数は、特定のケースを除き(後ほど説明します)、実行されます。

では、Reactがコンポーネントを呼び出すタイミングはいつかというと、以下の3つのケースに限られます。

  1. 初回レンダリング
  2. state の更新(set 関数の実行)
  3. 親コンポーネントのレンダリング

つまり、初回レンダリングと set 関数の実行タイミング(子コンポーネントの場合、親コンポーネントの set 関数の実行タイミングも含む)を把握することで、コンポーネントがどのタイミングで実行されるかを理解できます。

具体例をみてみましょう。
この Form コンポーネントは、firstNamelastName の2つのstateで状態を管理しています。
そして、useEffect を使って、firstName または lastName が更新されるたびにfullNameも更新されるようにしています。

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);
  // ...
}

ただ、firstNamelastName の値が変更されるのは set 関数が実行されたときなので、実は useEffect を使わなくても、単に fullNamefirstName + ' ' + lastName として定義しておけば、自動的に更新されます。

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // firstName,lastNameの値が変更されるときはset関数が実行された時
  // レンダリングが発生するたびにfullNameの値も更新されるので、useEffectは不要!
  const fullName = firstName + ' ' + lastName;
  // ...
}

そのため、「stateprops が変更されたときに関数を実行したい!」という場合は、基本的に useEffect を使わずに制御できます。

ちなみにpropsが変更されたらレンダリングされるというのはあまり正しくないです。
なぜなら、propsを変更するには結局のところ親コンポーネントをレンダリングする必要があるからです。
さらに、memo化をしていない場合、propsの変更の有無に関わらず、レンダリングは実行されます。
そのため、memo化をしている場合にのみ、propsの変更がレンダリングに影響を与えることになります。

副作用とは何かを理解しよう

少し前の僕:「なるほど、じゃあこのstateが更新された時にこのfetchでデータ取得をしたいから、そのままコンポーネント内に書いてっと、、」

だめです。副作用です。

少し前の僕:「副作用ってなんやねん!」

そうですね、それでは次に副作用について説明していきます。

まず、プログラミングにおける副作用がない関数とは、「計算内容が引数にのみ依存し、その結果は戻り値にのみ影響する」ことです。
また、副作用がない関数には以下のことが成立します。

  1. 同じ入力に対して常に同じ結果が得られる(冪等である)
  2. 他のいかなる機能(ローカル変数以外)の結果にも影響を与えない
function double(number) {
  return 2 * number;
}

この関数では、

  • numberが1であれば2
  • numberが2であれば4
  • numberが3であれば6

を常に返します。
このように同じ入力に対して常に同じ結果が得られる関数は、冪等であると言えます。

これに対して、副作用がある関数の条件は次の通りです。(副作用は広い意味で使われているので、これ以外にも該当ケースがあるかもしれません。)

  1. 外部の状態に依存して冪等ではない。
  2. 外部の状態(ローカル変数以外)を変化させる。

例えば fetch でデータを取得・更新するような操作がこれに該当し、こうした要因を「副作用」と呼びます。

let multiplier = 2;

function double(number) {
 // グローバル変数のmultiplierが関数呼び出しごとにインクリメントされている = 副作用!
  multiplier++; 
  return number * multiplier; 
}

この関数では、

  • numberが1であれば3
  • 再びnumberを1にしたら4
  • さらに再びnumberを1にしたら5

を返します。

このように、同じ入力に対して異なる結果が返るため、この関数は冪等ではありません。

それでは次に冪等ではないコンポーネントの例を見てみましょう。

function Clock() {
  const time = new Date();
  return <span>{time.toLocaleString()}</span>
}

コンポーネント内では Dateをインスタンス化していますが、
これではClockコンポーネントを呼び出すたびに日付(コンポーネントの結果)が変わるため、冪等ではありません。

「これでもコンポーネントは動作しそうだけど?」と思うかもしれませんが、コンポーネントは純粋に保つべき(副作用がない状態)と定めらています。
https://ja.react.dev/learn/keeping-components-pure

useEffectを使用するタイミングについて理解しよう

少し前の僕:「じゃあ、さっきのnew Date()fetchなどの副作用をReactでは使うなってことか!?」

違います。ちゃんと使えます。

まず前述の通り、コンポーネントは純粋に保つ必要があるため、レンダリング中に副作用を含めることはできません。
ただし、裏を返せば、レンダリング中でさえなければ副作用を扱うことが可能です。
そのため、副作用を扱う場所として、イベントハンドラーが適しています。
Reactでは、副作用を基本的にイベントハンドラー内で処理するよう推奨されています。

function ChatRoom({ roomId }) {
  const [message, setMessage] = useState('');
  // ...
  function handleSendClick() {
    sendMessage(message);
  }
  // ...
  return (
    <>
      <input value={message} onChange={e => setMessage(e.target.value)} />
      <button onClick={handleSendClick}>Send</button>
    </>
  );
}

ただし、副作用を処理する適切なイベントハンドラーがない場合もあります。
例えば、『初回レンダリング直後に fetch を呼び出したい』といったケースです。
このような場合、初回レンダリング後に発火するイベントトリガーは存在しません。
そこで登場するのが useEffect です。
useEffectはレンダリング直後に実行されるいわばイベントハンドラーみたいなものです(厳密にはレンダリング開始前にもクリーンアップ関数がよばれるわけですが、説明を省きます)。

依存配列について理解しよう

少し前の僕:「なんとなく理解したで!さて、初回レンダリング時に呼びたいからこうしてと、、ん?なんかlintのerrorがでてる。コメントアウトしt」

const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
  }, []); 
}

だからだめです。依存配列は絶対lintに従ってください。

そもそもuseEffectはレンダー直後に呼ばれるイベントハンドラーみたいなものです。
なので、本来であればレンダリング直後に毎回再同期が行われます。
ただ、毎回再同期しても結果が変わらなければ無駄なので、レンダリング中に値が変更されたときのみ再同期されるようになっています。
ここでいう『値』とは、レンダリングによって変化する可能性があるstatepropsなどのリアクティブな値のことです。
もしリアクティブな値を含めなければ、ReactとuseEffect内のロジックに差分が生じ、これはバグの要因になり得ます。

先ほどのコンポーネントで言うと、roomId は props なので、レンダリングによって値が変わる可能性があります。
そのため、必ず roomId を依存配列に含めなければなりません。
もし含めなければ、useEffect 内の roomId と現在の roomId に差分が発生します。

一方、serverUrl はコンポーネント外で宣言されているため、依存配列には含めません。serverUrl はレンダリング中に変わることがないからです。

// コンポーネント外なので依存配列には含めない
const serverUrl = 'https://localhost:1234';

function ChatRoom({ roomId }) {
  useEffect(() => {
    const connection = createConnection(serverUrl, roomId);
    connection.connect();
    return () => connection.disconnect();
    // roomIdはリアクティブな値なので変更を検知するたびに再同期
  }, [roomId]); 
}

長々と説明してきましたが、依存配列は自分で勝手に決めるものではなく、あくまでもReactとuseEffect間における必要最低限の再同期のために存在しているということを覚えておいてください。

少し前の僕:「でも、依存配列に従ったら意図した挙動にならないんだけど。。。」

依存配列を操作する方法以外でなんとかしてください。

少し前の僕:「えぇ、、」

ケースバイケースなので、絶対にこれをしなければならないという決まりはありません(もしあれば教えてください!)。
ただし、公式ドキュメントには、解決のための有益なヒントが記載されています。

https://ja.react.dev/learn/removing-effect-dependencies
https://ja.react.dev/learn/you-might-not-need-an-effect

終わりに

少し前の僕:「なんとかしてくださいって投げやりすぎん?」

だってあなたエンジニアでしょ

少し前の僕:「はい。。」

ものすごく参考にした資料

https://ja.react.dev/
https://zenn.dev/yumemi_inc/articles/react-effect-simply-explained
https://qiita.com/honey32/items/75c57fa0e465f0080030
https://qiita.com/honey32/items/f8f12afa1bd69e714e40
https://zenn.dev/aishift/articles/d046335a98bc34
https://zenn.dev/yend724/articles/20240711-qfbqiba6m9iul2al
https://zenn.dev/uhyo/articles/useeffect-taught-by-extremist

Discussion