React useEffectの非同期処理とクリーンアップ

12913 ワード

ReactのuseEffectでクリーンアップが必要な非同期処理を行う際に、注意が必要だと思った点について例示します。

おかしな点や改善点がありましたらご指摘いただけますと幸いです。

題材

指定された音源を再生するコンポーネントを作ります。音源の指定されていれば即座に再生し、指定が変われば元の音を停止して、新たな音を再生します。

実装としては、useEffectの中で HTMLAudioElement を作成して操作します。

以下でコードと動作を確認できます。

不完全なクリーンアップ例

指定された音を再生するコンポーネントの基本骨格は以下のようになります。(React v18.0.0、TypeScript v4.4.2環境)

Example1.tsx
import { FC, useEffect } from "react";

type Props = { soundSrc: string };

export const Example1: FC<Props> = ({ soundSrc }) => {
  useEffect(() => {
    const audio = new Audio(soundSrc);
    // audio.play()は、ロード完了時にfulfillされるPromiseを返し、再生を開始する
    audio.play().catch((e) => alert(e));

    return () => {
      // ロード完了前にクリーンアップされる場合を考慮していない
      audio.pause();
    };
  }, [soundSrc]);

  return <p>Example1</p>;
};

このコードは audio.play() によるロードが完了する前にクリーンアップが実行される場合が考慮できていません。

とは言っても、ロードは通常とても短い時間で完了するため、そのわずかな時間でコンポーネントのアンマウントまたは音源の変更がない限り、この問題は露呈しません。

React v18 StrictMode時のuseEffect

話は変わりますが、React v18から、 <StrictMode> で囲まれたコンポーネントは、開発ビルドの時だけ、マウント時にuseEffectが2回呼ばれるようになっています。

分かりやすい記事: https://www.pandanoir.info/entry/2021/07/11/143303

問題点

この機能により、上記のコンポーネントは、マウント時にuseEffect -> クリーンアップ -> useEffect と高速で実行され、以下のようにエラーが発生することが分かります。

  1. 1回目のロード開始
  2. 1回目のクリーンアップの実行
  3. 2回目のロード開始
  4. 1回目のロード完了後、既に停止されているためエラー発生
  5. 2回目のロード完了後、音再生

4で発生するのはchromeの場合以下のようなエラーです。

AbortError: The play() request was interrupted by a call to pause().

play() の準備が完了した時に既に pause() が呼ばれている場合に、上記エラーが発生して再生がキャンセルされるようです。

今回の場合、上記エラーを無視すれば結果的に正しい挙動になるのですが、望ましい実装ではありません。

改善例

長くなってしまいますが、以下のように2つのフラグを用いることで、ロード前にクリーンアップが実行された場合にも対処できるようにしました。

Example3.tsx
useEffect(() => {
  let isLoaded = false;
  let isCanceled = false;
  const audio = new Audio(soundSrc);
  audio
    .play()
    .then(() => {
      if (isCanceled) {
        // キャンセル済みの場合、ここで音停止
        audio.pause();
      } else {
        isLoaded = true;
      }
    })
    .catch((e) => alert(e));

  return () => {
    // ロード済みであれば音停止、そうでなければキャンセルフラグをセット
    if (isLoaded) {
      audio.pause();
    } else {
      isCanceled = true;
    }
  };
}, [soundSrc]);

isLoaded のフラグのみで制御しようとした場合、音が重複するという挙動になってしまうので注意が必要です(CodeSandboxの Example2 )。

useEffectは同期関数のみしかセットできない

ちなみに、全体をasync関数にすれば以下のようにシンプルに書けそうですが、useEffectには同期関数しかセットできないため、この書き方はできません。

// NG
useEffect(async () => {
  const audio = new Audio(soundSrc);
  try {
    await audio.play();
  } catch (e) {
    return alert(e);
  }

  // audio.play()をawaitしてからクリーンアップ関数を返す
  return () => audio.pause();
}, [soundSrc]);

まとめ

useEffectでクリーンアップが必要な非同期処理を行う場合、非同期処理が完了する前にクリーンアップが実行される場合を考慮する必要があるという例を示しました。

今回の場合、StrictModeを使うことで問題が発覚したため、できればStrictModeを使って、useEffectがべき等になっているかチェックするのが良さそうです。