🧑‍🎓

学びをアウトプット:useEffectへの理解を深めよう!

2024/09/30に公開

はじめに

「副作用ってなに」「useEffectはどのタイミングで実行されるのか」
正直、これまであまり理解せずに実装していました。

そんな私がuseEffectについて学んだことをまとめてみました💫
同じように悩んでいる方々の手助けになれれば嬉しいです!!

コンポーネントを純粋に保つ

useEffectを語る前にコンポーネントの純粋性について簡単にまとめます。
まず、純粋なコンポーネントとは公式には以下のように記載されてます。

  • 冪等 (idempotent) であること
  • レンダリング時に副作用がない
  • ローカルな値以外を変更しない

原則、この純粋性に従ってコンポーネントを実装する必要があります。
そこで、各原則について説明していこうと思います。

冪等 (idempotent) であること

以下に引数の値を2倍に返すシンプルな関数があります。

function double(number) {
  return 2 * number;
}

この関数では、

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

を常に返します。

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

let multiplier = 2;

function double(number) {
  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コンポーネントを呼び出すたびに日付(コンポーネントの結果)が変わるため、冪等ではありません。

レンダリング時に副作用がない

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

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

この2つの性質を持つ関数は、参照透過性を持つ関数といわれます。
逆に副作用がある関数はこの参照透過性を持ちません。
ので、副作用の条件は次の通りです。(副作用は広い意味で使われているので、これ以外にも該当ケースがあるかもしれません。)

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

先に挙げた冪等でない関数の例は、let multiplier = 2を関数のスコープ内でmultiplier++によって値を変化させています。
これはグローバル変数(ローカル変数以外)を変更しているので、副作用があると言えます。

// グローバル変数
let multiplier = 2;

function double(number) {
  // グローバル変数に変化を与えてる = 副作用
  multiplier++; 
  return number * multiplier; 
}

これをコンポーネントに置き換えると以下のようになります。

let multiplier = 2;

const Double = (number) => {
  multiplier++; 
  return <div>Count: { number * multiplier }</div>;
};

コンポーネントが呼び出される(レンダリングされる)たびにlet multiplier = 2は変化します。
もちろん、numberの値が一定でも出力される結果は異なるので、冪等ではないことがわかります。
また、ローカル変数以外の状態を変化させることなので、console.logやDOMの変更、データの更新なども副作用に含まれます。

ローカルな値以外を変更しない

「レンダリング時に副作用がない」で述べたように、コンポーネント内ではローカルな値以外の状態を変更してはいけません。しかし、逆に言えば、ローカル変数であれば変更しても問題ありません(一般的にReactでは変数の変更は避けるべきですが)。
例えば、先ほどのlet multiplier = 2をコンポーネント内でローカル変数として定義してみます。

const Double = (number) => {
  // ローカル変数であるため、レンダリングのたびにリセットされる
  let multiplier = 2;
  multiplier++; 
  return <div>Count: { number * multiplier }</div>;
};

multiplierを書き換えているため、冪等ではないコンポーネントに見えるかもしれません。
しかし、multiplierレンダリングのたびにリセットされるため、コンポーネントは常に同じ結果を返します

副作用を扱うには

レンダリング中に副作用を含めてはいけないと言われても、開発中には副作用が必要なケースが多々あります。
そこで、Reactはレンダリングを純粋に保ちつつ、副作用を安全に扱うための方法を2つ提供しています。

1つ目はイベントハンドラーです。
イベントハンドラーは、ユーザーの操作(クリックやキー入力)に応じて実行されるため、レンダリングには影響を与えません。
基本的には、イベントハンドラー内で副作用を記述するべきです。

しかし、イベントハンドラーだけでは対応できない場合もあります。
たとえば、コンポーネントの初回レンダリング時にデータを取得する必要がある場合です。
サーバーへの接続やデータの取得は純粋な計算ではなく副作用であり、レンダリング中には行えません。
また、初回レンダリングに対応するイベントも存在しません。

そこで、Reactはレンダリング時に発生する副作用を管理するためのフック(hooks)を用意しています。
それが2つ目の方法、useEffectです。
useEffectはレンダリング終了後に実行されるフックで(詳細は後述)、レンダリングの純粋性を保ちながら副作用を安全に扱うことができます。

ちなみにレンダリング時に発生する副作用のことをReactではEffectと呼ばれています。
このあたりの名称に困惑することが多いので、まとめておきます。

  • side effect:副作用のこと
  • Effect:レンダリング時に発生する副作用のこと
  • useEffect:Effectを扱うためのhooksのこと

useEffectで同期を行う

useEffectはEffectを扱うためのフックと述べましたが、公式では以下のように記載されています。

useEffect は、コンポーネントを外部システムと同期させるための React フックです。

ここでの「外部システム」とは、React(コンポーネント)によって制御されていないシステムのことを指します。
要するに、Effectの対象です(DOM操作を行うならDOM、データ取得ならAPIサーバーなど)。

それでは「冪等 (idempotent) であること」で取り上げたClockコンポーネントを改めて見てみましょう。
仮の要件としてリアルタイムで時刻を表示する必要があるとします。

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

先ほども述べたように、Clockコンポーネントは冪等ではなく、new Dateという外部システム(システムクロック)に依存しているため、副作用を持つコンポーネントといえます。
また、ユーザーの操作(イベント)に関係なく時刻を表示させる必要があるので、この副作用はEffectといえます。

ここでuseEffectの出番です。
今回はnew Date()という外部システムとReactを同期させるために、useEffectを使用します。

// Good: new Date()との同期をuseEffectで行う
const Clock = () => {
  const [dateTime, setDateTime] = useState(() => new Date());

  useEffect(() => {
    const id = setInterval(() => {
      setDateTime(new Date());
    }, 1000);

    return () => {
      clearInterval(id);
    };
  }, []);

  return <div>{dateTime.toLocaleString()}</p>;
};

これでnew Date()の計算をレンダリングの外側に移動させることができました。

同期の開始と停止

同期を開始したのならば、もちろん停止も必要です。
コンポーネントがアンマウントされたときに、その影響を元に戻す必要があるからです。
useEffectでは、同期の開始を行う関数のことをエフェクト関数、停止を行う関数のことをクリーンアップ関数といいます。

先ほどのClock関数で説明すると以下のようになります。

useEffect(() => {
  // 同期を開始(エフェクト関数)
  const id = setInterval(() => {
    setTime(new Date());
  }, 1000);

  // 同期を停止(クリーンアップ関数)
  return () => {
    clearInterval(id);
  };
}, []);

setInterval()はJavaScript のタイマー関数であり、一度設定されると、その間隔でコールバック関数を繰り返し実行し続けます。
もしコンポーネントがアンマウントされても(例えば、他のページに移動した場合)、このタイマーはメモリ上で生き続け、不要なコールバックを呼び出し続けます。
したがって、clearInterval(id) を呼び出して、不要なコールバックを停止する必要があります。

useEffect(エフェクト・クリーンアップ関数)はいつ実行されるのか

結論から言うと、useEffectはレンダリング後(最後だけアンマウント時)に実行されます
レンダリング時に副作用(Effect)を含めることができないので、レンダリングが終わってから実行されるというわけです。
ただし、レンダリングされるたびに実行するのは無駄が多いので、依存配列で制御する必要があります。
依存配列については後ほど説明しますが、ここではuseEffectはレンダリング後(最後だけアンマウント時)に実行される。
ただし、依存配列による制御がある
とだけ覚えておいてください。

では、これをエフェクト・クリーンアップ関数に分けて解説すると以下の通りです。

  1. マウント時(初回レンダリング時)にエフェクト関数を実行
  2. アンマウント時(例えば、他のページに移動した場合)にクリーンアップ関数を実行

これに再レンダリングの要素が加わると、上記の1と2の間に以下が行われます。

  1. (再レンダリングが発生する直前の状態で)クリーンアップ関数を実行
  2. (クリーンアップ関数が実行された後、再レンダリング後の状態で)エフェクト関数を実行

よくわからないと思うので具体例をもとに説明します。

function Input() {
  const [lastName, setLastName] = useState<string>("");
  const [firstName, setFirstName] = useState<string>("");

  const fullName = lastName + firstName;

  useEffect(() => {
    // エフェクト関数
    console.log(`count: ${fullName}, Effect fired!`);

    return () => {
      // クリーンアップ関数
      console.log(`count: ${fullName}, Effect cleaned-up!`);
    };
  }, [fullName]);

  return (
    <>
      <input onChange={(e) => setLastName(e.target.value)} value={lastName} />
      <input onChange={(e) => setFirstName(e.target.value)} value={firstName} />
      {fullName}
    </>
  );
}

上記は姓・名のInputで構成されたシンプルなコンポーネントです。
このコンポーネントをマウント(例えばページにアクセス)し、そのままアンマウント(例えば、他のページにアクセス)した場合、コンソールには以下が表示されます。

count: , Effect fired!
count: , Effect cleaned-up!

このようにマウント時にはエフェクト関数、アンマウント時にはクリーンアップ関数が実行されます。
では、Inputに"あ"と入力(再レンダリングが発生)してからアンマウントした場合はどうなるでしょうか。

count: , Effect fired!
count: , Effect cleaned-up!
count: あ, Effect fired!
count: あ, Effect cleaned-up!
  1. マウント時にエフェクト関数を実行(count: , Effect fired!
  2. ユーザーが"あ"を入力
  3. 再レンダリングが発生
  4. lastNameを"あ"に更新
  5. レンダリング後、クリーンアップ関数を実行(count: , Effect cleaned-up!
  6. エフェクト関数を実行(count: あ, Effect fired!
  7. アンマウント時にクリーンアップ関数を実行(count: あ, Effect cleaned-up!

ここで注目すべき点は、再レンダリング後のクリーンアップ関数が再レンダリング前の状態で実行されている点です。
再レンダリング前の状態でクリーンアップ関数が実行されることで、前に実行したエフェクト関数(同期の開始)を矛盾することなく止めることができます。

依存配列は機械的に

「このタイミングで実行したいから依存配列はこれに設定している」というのはNGです。
先ほど述べた通り、useEffectはレンダリング後に実行されます。
これはどう足掻いても変えることはできません。
「レンダリングされるたびに実行するのは無駄が多いので、依存配列で制御する必要があるんじゃないの??」
はい、その通りですがこれは勝手に(linterが)設定してくれます。

では、linterはどのような条件で依存配列を設定しているのでしょうか。
公式には以下のように記載されています。

依存配列には、エフェクトで読み取るすべてのリアクティブな値を含める必要があります。
リンタがこれを強制します。
これにより、無限ループや、エフェクトの再同期が頻発してしまうことがありますが、リンタを抑制してこれらの問題を解決としないでください!

ここの「リアクティブな値」とはレンダリング時に変化する可能性のある値 = コンポーネント内のすべての値(props、state、コンポーネント本体の変数を含む) のことを指します。
つまり、コンポーネント内の値をuseEffect内で使用した場合は必ず依存配列に含める必要があります。
これを無視するとバグに繋がります。
linterに従った結果、意図しない暴発が起こるのであれば、依存配列を調整するのではなく、useEffectやコンポーネントの設計を見直してください!!
解決方法のヒントとして以下を参考にするといいと思います。
https://ja.react.dev/learn/you-might-not-need-an-effect
https://ja.react.dev/learn/removing-effect-dependencies

また、useEffectはいつ実行され、なぜ依存配列は機械的に設定すべきかをわかりやすくまとめた動画もありますので、リンクを貼っておきます。
https://youtu.be/BAkAd2cEIno?si=tpGGAYBcxjNj79IC

全てのEffectはuseEffectでラップをすべきか

例えば、要件としてレンダリング時にconsole.log("life")を出力する必要があったとしましょう。
console.log("life")は外部システムに影響を与えるので、もちろん副作用です。
なので、以下のようにuseEffectでラップする必要があるように思えます。

  useEffect(() => {
    console.log("life")
  });

ただし、この場合はuseEffectでラップをしなくてもOKです。
なぜなら、console.log("life")は外部の状態を変化させているけど、コンポーネント自体は冪等に保たれているためです。
このことに関して、公式は以下のように述べています。

コンポーネントを複数回呼び出しても安全であり、他のコンポーネントのレンダーに影響を与えないのであれば、React はそれが厳密な関数型プログラミングの意味で 100% 純粋であるかどうかを気にしません。より重要なのは、コンポーネントは冪等でなければならないということです。

このようにEffectでもuseEffectでラップをしなくても良い場合があります。

ではuseEffectをいつ使用すべきか

「EffectでもuseEffectをしなくてもいい場合があるし、そもそもどれが副作用なのかイマイチわからんわ!」
と、思う方がいらっしゃるかもしれません。
つらつら記事を書いておきながら、僕もわからなくなることがあります。
ので、僕なりのuseEffectをどのタイミングで使うべきか見分ける方法をお伝えします。

まず前提として、この方法はNext.js✖node.js環境でしか役に立ちません。(他の環境でも通用するかも知れませんが、試したことがないので。。)

とりあえず脳死でコンポーネント内に書く

これだけです。
これだけですが、副作用がコンポーネント内にあれば基本的にエラーを吐いてくれます。
なぜ、エラーを吐くのかというと、next.jsの機能に起因しています。
next.jsはデフォルトでサーバーとクライアントの2環境でレンダリングが発生します。
この際サーバー側はnode.js上でレンダリングしますが、ブラウザのようなユーザーインターフェースやブラウザAPIを持っていません。
ので、例えばwindowオブジェクトをそのままコンポーネント内で宣言してしまうと、ReferenceError: window is not definedというエラーが吐かれます。
また、new Date()のような標準JavaScriptオブジェクトを使用した場合でも、サーバー側とクライアント側でレンダリングした結果が一致していなければならないという制約があるので、基本的にはエラーが吐かれます。(ただし、タイムゾーンが一致していればエラーは吐かれない。)

ので、エラーが出たら(内容にもよるけど)useEffectを使用しても良いタイミングです。

一応、動的importを使用すればエラーを回避することができます。
ただ、個人的にはあまりお勧めはしません。
なぜなら、動的importを使用してしまうと、サーバー側でのレンダリングが行われなくなるので、next.jsの良さ(この場合は予めにHTMLをCDNに置いておくとか)が失われる可能性があります。(ただ、クライアント側のロジックやUIを非同期にロードし、パフォーマンスを最適化できる利点もあるので場合によりけりかな。。)

終わりに

この記事ではuseEffectの基本的な概念をまとめました。
副作用は難しく感じるかもしれませんが、学んでいくうちにその重要性と便利さに気づくはずです!

ぜひ、実際のプロジェクトで試してみてください💫

参考資料

https://ja.react.dev/
https://zenn.dev/yend724/articles/20240711-qfbqiba6m9iul2al
https://zenn.dev/yumemi_inc/articles/react-effect-simply-explained

Discussion