LoginSignup
82

More than 1 year has passed since last update.

【React】useEffect解体新書

Posted at

はじめに

  • 最近、周囲から「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

はい。まぁなんとなくそんな気がしていましたが無限ループになりました。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

はい。 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

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

では useEffect は何のために存在しているのかというと「クリーンアップ処理を指定できるか否か」にありそうです。
例として、以下は 「現在オンラインかどうか」を判定して表示するロジックです。(こちらの記事を参考にさせていただきました)

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 には無い特性のため、ここが差別化ポイントになっていると理解しました。

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

クリーンアップ処理が何故必要かは、以下の例(公式ドキュメントから一部コピー)を見ていただけると良いと思います。
この例は、入力 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

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

SS 2023-02-19 15.15.10.png

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

外部のstoreを購読する場合

スキップするだけならuseEffectいらなくね? で登場した「オンラインかどうかを判定する例」で useEffect を使っていましたが、どうやら専用のhooks useSyncExternalStore が用意されており、そちらを使うことが推奨されているようです(こちらも例の参考にさせていただいた記事からの情報です)

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

まとめ

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

最後に

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

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
82