Motomichi Works Blog

モトミチワークスブログです。その日学習したことについて書いている日記みたいなものです。

Next.jsでuseEffectのクリーンアップ関数はクリーンアップのために使うという当たり前の話

参照したページ

はじめに

この記事は個人的な学習記録です。

useEffectのクリーンアップ関数の駄目な使い方について書いています。

クリーンアップ関数については公式の 副作用フックの利用法 – React あたりを読むと良さそうです。

コンポーネントの実行順序については Hooks時代のReactライフサイクル完全理解への道 を読むとわかりやすいのではないかと思います。

この記事を書いたきっかけは、useEffectのトリガーになった変数の「変更前の値」を取得したい。と思って調べていたときに、Reactの公式ドキュメントの「フックに関するよくある質問 – React」のページからリンクされている「前回の props や state はどうすれば取得できますか?」のページに掲載されている、エフェクトのクリーンアップ関数を使用した以下のようなサンプルコードの意味がわからなかったので、関数コンポーネントの処理の実行順序や、エフェクトのクリーンアップ関数の用途について学習したことがきっかけです。

useEffect(() => {
  ChatAPI.subscribeToSocket(props.userId);
  return () => ChatAPI.unsubscribeFromSocket(props.userId);
}, [props.userId]);

結局のところ「前回の props や state はどうすれば取得できますか?」というタイトルに惑わされましたが、自分が欲しかったのは上記のようなuseEffectのクリーンアップ関数を使う手法ではなく、単純に以下のように任意のタイミングで値を保持することでした。

const [ prevValue, setPrevValue ] = useState('');

あとは、同ページに以下のように記載がある、usePreviousを作成する方法もよく検索でヒットします。

これまで usePrevious というカスタムフックを使うことを提案していましたが、ほとんどのユースケースは上記の 2 つのパターンのいずれかに当てはまることがわかりました。

このように記載されているということは、usePrevious というカスタムフックを使う手法は現在はあまり推奨されていないように感じますがよくヒットしますね。

クリーンアップ関数については、当たり前のことなのですが、クリーンアップ関数なんだからクリーンアップの為だけに使う。
コンポーネントをunmountしただけでは残ってしまうような、useEffect()のコールバックで追加したイベントリスナーを破棄するとか、前回の値を使って元に戻すような処理をするということであって、「クリーンアップ関数を使って前回の値をどこかに保存するということではない。」ということですね。

この記事では「クリーンアップ関数を使って前回の値をどこかに保存してしまう」という「駄目な例」について書いています。

駄目な例

駄目なChild1.tsxの作成とその記述内容

Child1 コンポーネントを以下の記述内容で作成します。
あくまでこれは駄目な実装例です。

import React from 'react';

type Props = {
  currentStr: string;
};

export function Child1({ currentStr }: Props) {
  console.log('処理 子A');
  const [child1PrevStr, setChild1PrevStr] = React.useState('defaultChild1PrevStr');

  React.useEffect(() => {
    console.log('処理 子B');
  }, []);

  React.useEffect(() => {
    console.log('処理 子C-01');
    console.log('currentStr: ', currentStr);

    return () => {
      console.log('処理 子C-02');
      setChild1PrevStr(currentStr);
    };
  }, [currentStr]);

  React.useEffect(() => {
    console.log('処理 子D');
    console.log('child1PrevStr: ', child1PrevStr);
    console.log('currentStr: ', currentStr);
  }, [child1PrevStr, currentStr]);

  function getChild1Text() {
    console.log('処理 子E');
    return 'text';
  }

  return (
    <div>
      <div>Child1: child1PrevStr: {child1PrevStr}</div>
      <div>Child1: currentStr: {currentStr}</div>
      <div>{getChild1Text()}</div>
    </div>
  );
}

Parent.tsxの作成とその記述内容

Parent コンポーネントを以下の記述内容で作成します。

import React from 'react';
import { Child1 } from './Child1';

export function Parent() {
  console.log('処理 親A');
  const [str, setStr] = React.useState('defaultParentStr');

  React.useEffect(() => {
    console.log('処理 親B');
  }, []);

  function getParentText() {
    console.log('処理 親C');
    return 'parentText';
  }

  return (
    <div>
      <Child1 currentStr={str} />
      <div>{getParentText()}</div>
    </div>
  );
}

Parent.tsxをpages/index.tsxに配置する

以下の通り pages/index.tsx を記述します。

import { Parent } from '@/components/index/template/Parent';

export default function Home() {
  return <Parent />;
}

yarn devで実行してみる

yarn devで実行して、ブラウザをリロードしてみます。

コンソールログの出力結果

コンソールには以下のように出力されました。

  • Parent.tsx:5 処理 親A
  • Parent.tsx:13 処理 親C
  • installHook.js:342 処理 親A
  • installHook.js:342 処理 親C
  • Child1.tsx:8 処理 子A
  • Child1.tsx:32 処理 子E
  • installHook.js:342 処理 子A
  • installHook.js:342 処理 子E
  • Child1.tsx:12 処理 子B
  • Child1.tsx:16 処理 子C-01
  • Child1.tsx:17 currentStr: defaultParentStr
  • Child1.tsx:26 処理 子D
  • Child1.tsx:27 child1PrevStr: defaultChild1PrevStr
  • Child1.tsx:28 currentStr: defaultParentStr
  • Parent.tsx:9 処理 親B
  • Child1.tsx:20 処理 子C-02
  • Child1.tsx:12 処理 子B
  • Child1.tsx:16 処理 子C-01
  • Child1.tsx:17 currentStr: defaultParentStr
  • Child1.tsx:26 処理 子D
  • Child1.tsx:27 child1PrevStr: defaultChild1PrevStr
  • Child1.tsx:28 currentStr: defaultParentStr
  • Parent.tsx:9 処理 親B
  • Child1.tsx:8 処理 子A
  • Child1.tsx:32 処理 子E
  • react_devtools_backend_compact.js:2421 処理 子A
  • react_devtools_backend_compact.js:2421 処理 子E
  • Child1.tsx:26 処理 子D
  • Child1.tsx:27 child1PrevStr: defaultParentStr
  • Child1.tsx:28 currentStr: defaultParentStr

コンソールログの出力結果を読んでみる

installHook.jsとreact_devtools_backend_compact.jsの実行を省いて処理を読んでみます。

  1. 処理 親A
  2. 処理 親C
    • 親が実行された。
  3. 処理 子A
  4. 処理 子E
    • 子が実行された。
      • このときuseEffect()のコールバックは後まわし
  5. 処理 子B
    • 子のuseEffect()のコールバックが実行された。
      • 第二引数に [] を渡したuseEffect()のコールバック
  6. 処理 子C-01
    • 子のuseEffect()のコールバックが実行された。
      • 親のuseEffect()のコールバックより先に実行された。
      • 第二引数に [currentStr] を渡したuseEffect()のコールバック
      • このとき、親から受け取った props.currentStr は 'defaultParentStr'
  7. 処理 子D
    • 子のuseEffect()のコールバックが実行された。
      • 親のuseEffect()のコールバックより先に実行された。
      • 第二引数に [child1PrevStr, currentStr] を渡したuseEffect()のコールバック
      • このとき、変数 child1PrevStr は 'defaultChild1PrevStr'
      • このとき、親から受け取った props.currentStr は 'defaultParentStr'
  8. 処理 親B
    • 親のuseEffect()のコールバックが実行された。
      • 第二引数に [] を渡したuseEffect()のコールバック
  9. 処理 子C-02
    • 子のuseEffect()のコールバックのクリーンアップ関数が実行された。
      • このとき変数 child1PrevStr には「7」の 'defaultParentStr' で上書きされる
  10. 処理 子B(上記の「5」と同じ処理)
  11. 処理 子C-01(上記の「6」と同じ処理)
  12. 処理 子D(上記の「7」と同じ処理)
  13. 処理 親B(上記の「8」と同じ処理)
  14. 処理 子A
  15. 処理 子E
    • 上記の「9」によって子のstate child1PrevStrが更新されたので子が再実行された。
  16. 処理 子D
    • 子のuseEffect()のコールバックが実行された。
      • 第二引数に [child1PrevStr, currentStr] を渡したuseEffect()のコールバック
      • 上記の「9」によって依存配列の child1PrevStr が更新されたのでコールバックが実行された。
      • このとき、変数 child1PrevStr は 'defaultParentStr'
      • このとき、親から受け取った props.currentStr は当然変化なしで 'defaultParentStr'

ここまで思ったこと

  • なぜ「5〜8」と同じ処理が「10〜13」で再実行されるのか
    • サーバーサイドとクライアントサイドで実行しているから
      • それなら yarn build + yarn start で起動したら1回になりそう
  • なぜ「9」は実行されてしまうのか
    • クリーンアップ関数は、子コンポーネントをunmountするときまたは、「処理 子C-01」のエフェクト実行前にも実行される
      • どっちの条件によるものかはたぶん後者
  • 「9」が実行されることによって意図していない「14〜16」が実行されている
    • 「9」のクリーンアップ処理で setChild1PrevStr(currentStr); していたのをやめると「14〜16」が実行されなくなるのでこれは明らか。

yarn build + yarn start で実行してみる

コンソールログの出力結果を読んでみる

  • 処理 親A
  • 処理 親C
  • 処理 子A
  • 処理 子E
  • 処理 子B
  • 処理 子C-01
    • currentStr: defaultParentStr
  • 処理 子D
    • child1PrevStr: defaultChild1PrevStr
    • currentStr: defaultParentStr
  • 処理 親B

ここまでで思ったこと

  • 当初実装したかった挙動になった。
  • よく理解せずにuseEffectのクリーンアップ関数を使うとdevとprodで違う挙動になる。
    • クリーンアップ関数なのだから、unmountしただけでは残ってしまうものをクリーンアップするためにだけ使う。
    • この記事の「駄目なChild1.tsx」のコードは、副作用にクリーンアップ関数を使ってさらなる副作用をかけている。

installHook.jsとは

javascript - What is chrome-extension:// installHook.js that is being downloaded for my pages - Stack Overflow によると、React Dev Tools Chrome 拡張機能 に含まれているファイルらしいです。