【React】useEffect解体新書

## はじめに
- 最近、周囲から「useEffectでバグった」という声を聞くことが増えました。そこで、今までなんとなくで使っていた `useEffect` とは何かを正しく理解したいと思い記事を書くに至りました。
- この記事はReactの再レンダリングの仕組み(propsやstateが変更される度に再レンダリングされる)を理解している方向けです。


## 環境
- TypeScript: 4.9.3
- React: 18.2.0

## そもそもuseEffectって？

公式ドキュメントには以下のように記載されていました
https://beta.reactjs.org/learn/synchronizing-with-effects

> 一部のコンポーネントは、外部システムと同期する必要があります。たとえば、React の状態に基づいて非 React コンポーネントを制御したり、サーバー接続をセットアップしたり、コンポーネントが画面に表示されたときに分析ログを送信したりすることができます。エフェクトを使用すると、レンダリング後にコードを実行できるため、コンポーネントを React 外部のシステムと同期させることができます。

正確には `useEffect` で取り扱う「エフェクト」という概念についての説明のようですが、要は「レンダリング後に外部システムとの同期などを行うことが出来るもの」のようです。
おそらく、この記事を読んでいる方は一度は「レンダリング時にデータをフェッチして表示する」的なことをやったことがあると思いますので、そこまで違和感はないかなと思います。

しかし、私はこう思いました。「レンダリング時にデータをフェッチして表示する」だけであれば `useEffect` なんて使わなくてもベタ書きすればいいのでは？と。

```App.tsx
const wait = (t: number) =>
  new Promise<number>((r) => setTimeout(() => r(t), t));

const fetchData = async () => {
  await wait(1000);
  const data = { test: 'test' };
  return data;
};

const App = () => {
  const [data, setData] = useState<unknown>(null);

  fetchData().then((r) => {
    setData(r);
  });
  console.log('render!', data);

  return <div>{JSON.stringify(data, null, 2)}</div>;
};
```

こんな感じにデータをフェッチして、取得できたらstateに入れるだけです。実際に動かしてみましょう。

![SS 2023-02-19 13.42.08.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/2daf52df-8b23-bed7-e433-685eff836c48.png)

はい。まぁなんとなくそんな気がしていましたが無限ループになりました。Reactコンポーネントが再レンダリングされるタイミングは「propsやstateが変更された時」なので「フェッチ→setData→stateの変更を検知して再レンダリング→フェッチ…」の無限ループが発生してしまいました。
そこで `useEffect` の出番です。 「フェッチ→setData」の部分を `useEffect` の中でやるようにします。

```App.tsx
const App = () => {
  const [data, setData] = useState<unknown>(null);

  console.log('render!', data);

  useEffect(() => {
    fetchData().then((r) => {
      setData(r);
    });
  }, []);

  return <div>{JSON.stringify(data, null, 2)}</div>;
};
```

![SS 2023-02-19 13.53.22.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/9b526c34-022d-7d5e-ff37-2069eb61d7c2.png)

はい。 `StrictMode` の影響で2回実行されていますが、それ以上は再レンダリングされていないですね。

このことから `useEffect` の本質は「レンダリング時にコードを実行するもの」ではなく「再レンダリング時に不要なコードの実行をスキップするもの」であると理解しました。

## スキップするだけならuseEffectいらなくね？

前章の結果から `useEffect` は「再レンダリング時に不要なコードの実行をスキップするもの」であるとしました。
で、ふと思いました。それだけなら別に `useMemo` でよくね？と。 `useMemo` は「再レンダリング時にコードの実行結果をできるだけキャッシュするもの」ですが本質的には `useEffect` と同じはずです。

```App.tsx
const App = () => {
  const [data, setData] = useState<unknown>(null);

  console.log('render!', data);

  useMemo(() => {
    fetchData().then((r) => {
      setData(r);
    });
  }, []);

  return <div>{JSON.stringify(data, null, 2)}</div>;
};
```

![SS 2023-02-19 14.05.19.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/a221685e-a09e-6328-7574-a65656348b95.png)

はい。なんかうまく動いていそうですね…。というわけで「再レンダリング時に不要なコードの実行をスキップする」だけであれば `useEffect` は `useMemo` で代用可能であることが分かりました。
それに、第2引数に依存のある値をセットすることで依存関係にある変数が変更された時には再実行させることが出来る（キャッシュ・スキップしない）点も同じです。

では `useEffect` は何のために存在しているのかというと「クリーンアップ処理を指定できるか否か」にありそうです。
例として、以下は　「現在オンラインかどうか」を判定して表示するロジックです。（[こちらの記事](https://zenn.dev/fujiyama/articles/c26acc641c4e30#%E5%A4%96%E9%83%A8%E3%81%AEstore%E3%82%92%E8%B3%BC%E8%AA%AD%E3%81%99%E3%82%8B)を参考にさせていただきました）

```App.tsx
const App = () => {
  const [isOnline, setIsOnline] = useState(true);

  console.log('render!', isOnline);

  useEffect(() => {
    console.log('useEffect start!');
    const listener = () => setIsOnline(navigator.onLine);

    window.addEventListener('online', listener);
    window.addEventListener('offline', listener);
    // クリーンアップ処理
    return () => {
      console.log('cleanup!');
      window.removeEventListener('online', listener);
      window.removeEventListener('offline', listener);
    };
  }, []);

  return <div>{isOnline.toString()}</div>;
};
```

クリーンアップ処理って何？という話になりますが「useEffectの実行前とコンポーネントのアンマウント時に実行されるもの」です。要は購読が不要になった時点でリスナーを破棄する処理などが必要な場合は処理（コールバック）をreturnすることでセットしておくことが出来るわけです。これは `useMemo` には無い特性のため、ここが差別化ポイントになっていると理解しました。

## クリーンアップ処理について

クリーンアップ処理が何故必要かは、以下の例（[公式ドキュメント](https://beta.reactjs.org/learn/synchronizing-with-effects#putting-it-all-together)から一部コピー）を見ていただけると良いと思います。
この例は、入力 `text` が変化するたびに**ランダムな時間**にその時の入力値を出力するタイマーをセットするというものです。これによって、データフェッチにかかる時間にばらつきがある場合を再現しています。

```App.tsx
const getRandomInt = (max: number) => Math.floor(Math.random() * max);

const App = () => {
  const [text, setText] = useState('a');

  useEffect(() => {
    function onTimeout() {
      console.log('⏰ ' + text);
    }

    console.log('🔵 Schedule "' + text + '" log');
    const timeoutId = setTimeout(onTimeout, getRandomInt(3000));

    return () => {
      console.log('🟡 Cancel "' + text + '" log');
      clearTimeout(timeoutId);
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input value={text} onChange={(e) => setText(e.target.value)} />
      </label>
      <h1>{text}</h1>
    </>
  );
};
```

![SS 2023-02-19 15.23.17.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/a49495ba-1003-bc3a-6305-788c4767630d.png)

試しに何度か文字を打ってみると、入力が変わった際にクリーンアップ処理が発火してタイマーをキャンセルして、最後の入力の結果のみが出力されていることが確認できると思います。
しかし、クリーンアップ処理をコメントアウトするとどうのようになるでしょうか？

![SS 2023-02-19 15.15.10.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/3989274f-cc2b-8f4f-e445-e526f54f0b67.png)

最後に入力されたのは `abc` であるのにも関わらず、最後に出力されたのは `ab` でした。クリーンアップ処理が正しく無いと、ユーザーの最新の入力と結果がズレてしまうというバグが発生する恐れがあることが分かりますね。

## 外部のstoreを購読する場合

[スキップするだけならuseEffectいらなくね？](#スキップするだけならuseEffectいらなくね？) で登場した「オンラインかどうかを判定する例」で `useEffect` を使っていましたが、どうやら専用のhooks `useSyncExternalStore` が用意されており、そちらを使うことが推奨されているようです（こちらも[例の参考にさせていただいた記事](https://zenn.dev/fujiyama/articles/c26acc641c4e30#%E5%A4%96%E9%83%A8%E3%81%AEstore%E3%82%92%E8%B3%BC%E8%AA%AD%E3%81%99%E3%82%8B)からの情報です）

```App.tsx
const subscribeOnline = (onStoreChange: () => void) => {
  console.log('subscribe start!');
  window.addEventListener('online', onStoreChange);
  window.addEventListener('offline', onStoreChange);
  return () => {
    console.log('cleanup!');
    window.removeEventListener('online', onStoreChange);
    window.removeEventListener('offline', onStoreChange);
  };
};

const useOnline = () =>
  useSyncExternalStore(subscribeOnline, () => navigator.onLine);

const App = () => {
  const isOnline = useOnline();

  console.log('render!', isOnline);

  return <div>{isOnline.toString()}</div>;
};
```

うまく動くんですが、 初回の `console.log` が出力されないのが謎ですね…（オンライン状態に変化があれば出る）

![SS 2023-02-19 16.03.11.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/252036/9f95ca10-2076-ccd2-66e1-72c17af3f289.png)


## まとめ

- `useEffect` の本質は「レンダリング時にコードを実行するもの」ではなく「再レンダリング時に不要なコードの実行をスキップするもの」である
- 正しいクリーンアップ処理によって潜在的なバグを防ぐことが出来る（ `useMemo` との差別化ポイント）
- 外部のstoreを購読する場合は `useSyncExternalStore` を使うことが推奨されている

## 最後に

「解体新書」を名乗れるほどの内容じゃなかった気がします…（今更）
ここまで読んでいただきありがとうございました。