😸

開発中でもmount・unmountイベントを一度だけ実行するhooksを作ってみた

2024/08/24に公開

始めに

Reactでコンポーネントがmount・unmountされた時の判定をする場合、hooksでは直接的なAPIが存在しないため useEffect で代用すると思います。

const MyComponent: FC = () => {
  useEffect(() => {
    console.log("mountしました");

    return () => {
      console.log("unmountしました");
    };
  }, []);

  return <div>コンポーネント</div>;
};

しかしながらReactのバージョン18からはStrictModeで開発中はエフェクトの実行が追加で1回行われる仕様になりました。

https://ja.react.dev/reference/react/StrictMode

これはクリーンアップを正しく実行されているかを確認するために大事な挙動ではありますが、mount時にGoogle Analyticsへのイベント送信やトースト表示など、クリーンアップできないものについては2回実行されることでバグのように見えてしまいます。しかし公式の見解では「そのままで良い」という回答になっています。

https://ja.react.dev/learn/synchronizing-with-effects#sending-analytics

確かにイベント送信は開発中はコードを修正するたびに実行されることになるためそもそも送信回数を気にする必要がないかもしれません。しかしそれでも2回実行されると困る時はあるんじゃないかなと思っています。
例えば入場・退場をトーストで通知する場合、useEffectを使うと2回実行されるという挙動から以下のようなトーストが一気に出てしまいます。開発中の仕様といえばそれまでなのですが、何も知らない人からするとバグのように見えますし、どうしても奇妙に感じてしまいます。

汎用的なhooksを提供しているreact-useではuseMountuseUnmountというhooksがありますが、こちらを使っても残念ながら開発中は2回実行されてしまいます。コードの中身をみたところ、useEffectをラップしたuseEffectOnceが使用されておりまずが、特に開発中による2回実行のケアはされておらず、onceという割には開発中は2回実行されるという矛盾がありました。

https://github.com/streamich/react-use/blob/v17.5.1/src/useMount.ts#L1-L9

https://github.com/streamich/react-use/blob/v17.5.1/src/useUnmount.ts#L1-L13

https://github.com/streamich/react-use/blob/v17.5.1/src/useEffectOnce.ts#L1-L7

useMountuseUnmountというhooks名とインターフェースは良いと思っており、後は言葉のイメージ通りmount・unmount時に一度だけコールバックを実行できたら良いなと思いました。そこで今回は実装の中身を改良し、開発中であっても一度しかコールバックを呼ばないように調整したhooksを作ってみました。

検証コード

今回作ったコードは以下のStackBlitzに置いてあります。動作や詳細のコードが気になる方はこちらをご参照ください。

開発中でも一度しかコールバックを呼ばないhooksの実装

useMountOnceの実装

mountのコールバックの実装は単純にフラグで持って一度も実行していない時だけコールバックを呼ぶようにしました。具体的には以下のコードになりました。

import { useEffect, useRef } from "react";

/**
 * マウント時に一度だけ実行する
 * @param onMount - マウント時
 */
export const useMountOnce = (onMount: () => void) => {
  /** 実行したかをフラグで持つ */
  const isExecutedRef = useRef(false);

  /** depsに含めることを回避するためにrefで最新のコールバックを持つ */
  const onMountRef = useRef(onMount);
  onMountRef.current = onMount;

  useEffect(() => {
    if (isExecutedRef.current) {
      return;
    }

    onMountRef.current();
    isExecutedRef.current = true;
  }, []);
};

useUnmountOnceの実装

unmountの方は一番最後に実行するクリーンアップのみを実行したいのですが、それを判断することができないため、2回連続でeffectを行う時のクリーンアップの実行をキャンセルするやり方を考えました。
具体的にはクリーンアップ時にsetTimeoutで実行を予約だけして、effectの時にその予約をキャンセルするコードを書きます。これをすると連続で実行した時はキャンセルができ、それ以外の時は予約したコールバックを実行することができます。コードに落とすと以下のようになりました。

import { useEffect, useRef } from "react";

/**
 * アンマウント時に一度だけ実行するhooks
 * @param onUnmount - アンマウント時
 */
export const useUnmountOnce = (onUnmount: () => void) => {
  /** unmountを非同期で実行するためのtimerId */
  const timerIdRef = useRef<number | undefined>(undefined);

  /** depsに含めることを回避するためにrefで最新のコールバックを持つ */
  const onUnmountRef = useRef(onUnmount);
  onUnmountRef.current = onUnmount;

  useEffect(() => {
    clearTimeout(timerIdRef.current);

    return () => {
      // 2回effectが実行された際にキャンセルできるようにsetTimeoutで実行の予約だけする
      timerIdRef.current = setTimeout(() => {
        onUnmountRef.current();
      }, 0);
    };
  }, []);
};

実行結果

上のhooksを使って以下のようなコードを書いて動かしたところ、無事一度だけトーストが出るようになりました🎉

import { FC } from "react";
import { enqueueSnackbar } from "notistack";
import { Box, Button, Typography } from "@mui/material";
import { Link } from "react-router-dom";

import { useMountOnce } from "../hooks/useMountOnce";
import { useUnmountOnce } from "../hooks/useUnmountOnce";

export const EffectOncePage: FC = () => {
  useMountOnce(() => {
    enqueueSnackbar({
      message: "入場しました",
      variant: "success",
    });
  });

  useUnmountOnce(() => {
    enqueueSnackbar({
      message: "退場しました",
    });
  });

  return (
    <Box>
      <Typography variant="h5">
        mount・unmountイベントを一度だけ実行されるように調整して入場・退場を通知する
      </Typography>
      <Button component={Link} variant="outlined" to="/">
        トップページに戻る
      </Button>
    </Box>
  );
};

終わりに

以上が開発中でもmount・unmountイベントを一度だけ実行するhooksの実装でした。基本的にはuseEffectを使ってキチンとクリーンアップを書くべきですが、中にはそれがなくて逆に開発中に2回実行されることで煩わしさが出ることがあると思います。そういった時の参考になれば幸いです。

GitHubで編集を提案

Discussion