React v18 Automatic batching でハマったこと、リファクタのTips

Product.div でStrapを開発している、フロントエンドエンジニアのあげです。 Strapは今年、React v17から18へアップデートを行いました。当初の想定ではもう少しスムーズに行く予定でしたが、想定よりハマってしまったので、大変だったポイントとリファクタのTips共有します。

React v18 の主な変更点

React v18 の主な変更点は主に3つです。

  • Automatic Batching をreactのeventHandler外にも適用
  • Transition系 hook の追加
  • Suspense正式版の追加

大きなテーマとしては、レンダリングの並行化です。他の変更点・詳細な情報については、公式または他ですでに多く取り上げられているのでそちらをご参照ください。 今回大きくハマったのはAutomatic Batching です。

Automatic Batchingでハマったところ

🤔 Automatic Batching とは?

複数のstate更新を1つにまとめ、再描画を抑制し、パフォーマンス向上をはかる仕組みです。React v17から実装されていた機能ではありますが、React v18から、setTimeout や Promise でも適用されるようになりました。 以下は公式から引用したコードに、日本語訳のコメントを足したものです。

// React v18以前の挙動
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // state更新ごとにレンダリングされ、2回実行されます
}, 1000);

// React v18 から
setTimeout(() => {
  setCount(c => c + 1);
  setFlag(f => !f);
  // state更新はバッチされ、1度だけレンダリングされます
}, 1000);

これだけ聞くと、開発者としてもエンドユーザーとしても、パフォーマンスが改善するのでいいことしかありません。当初、私たちもそう思ってましたが、実際にアップデートしてみると厄介な問題が発生しました。

🤯 手続き的なstate更新をしていると意図した挙動にならない

以下は実際に対処した問題を抽象・簡略化したものです。

import "./App.css";
import { useEffect, useState } from "react";

const patchToServer = async () =>
  await new Promise((resolve) => setTimeout(resolve, 100));

export const App = () => {
  const [appleCount, setAppleCount] = useState(0);
  const [estimatedPrice, setEstimatedPrice] = useState(0);
  const [isPending, setIsPending] = useState(false);

  const addApple = () => {
    setAppleCount((prev) => prev + 1);
  };

  useEffect(() => {
    const asyncFunc = async () => {
      setIsPending(true);
      await patchToServer();
      setEstimatedPrice(appleCount * 100);
      setIsPending(false);
    };
    asyncFunc();
  }, [appleCount]);

  return (
    <div>
      <button type="button" onClick={addApple} disabled={isPending}>
        🍎 x {appleCount}
      </button>
      <br />
      <Receipt price={estimatedPrice} isPending={isPending} />
    </div>
  );
};

const Receipt = ({
  price,
  isPending,
}: {
  price: number;
  isPending: boolean;
}) => {
  const [purchasedPrice, setPurchasedPrice] = useState(0);
  useEffect(() => {
    if (price > 0 && isPending) {
      setPurchasedPrice(price);
    }
  }, [price, isPending]);
  return (
    <p>
      Purchased Price: ¥{purchasedPrice}
      {price > 0 && !isPending && (
        <>
          <br />
          <strong>Thank you!</strong>
        </>
      )}
      <br />
      <small>(🍎 = ¥100)</small>
    </p>
  );
};

CodeSandbox も用意しておきました🙏

このコードが期待する動作は以下の通りです。

  1. ユーザーがリンゴを増やす
  2. リンゴが増えたらサーバーに送り、通信中は pending 状態にする
  3. 通信が完了したら、見積もり金額を計算し、 pending を終了する
  4. 見積もり金額を Receipt コンポーネントに渡し、表示する
  5. 購入が完了したら「Thank you!」を表示する

リンゴの数を増やすと気づくと思いますが、個数と金額が合わなくなります。

React v17 での Receipt コンポーネントのレンダリング回数

React v17まではsetStateのたびに再レンダリングされていました。React v17環境でリンゴを追加した際の Receipt コンポーネントのレンダリングは以下のようになります。

  1. appleCount の更新
  2. setIsPending(true) の更新
  3. setEstimatedPrice の更新
  4. setIsPending(false) の更新

合計4回です。この3の時に、Receipt コンポーネント内の useEffect で一時的に price > 0 && isPending が成立します。そのため、setPurchasedPrice が実行され、表示に問題が生じません。

React v18 での Receipt コンポーネントのレンダリング回数

react v18 ではPromise内のsetStateも1つにバッチ処理されます。具体的には、 App コンポーネントの patchToServer 以降の setEstimatedPricesetIsPending はバッチ処理されます。つまりリンゴを追加した際の Receipt コンポーネントのレンダリングは以下のようになります。

  1. appleCount の更新
  2. setIsPending(true) の更新
  3. setEstimatedPrice & setIsPending(false) の更新

合計3回です。price > 0 && isPending が成立しない場合が発生し、setPurchasedPrice が実行されないので、表示に問題が生じます。2回目にリンゴを追加した際、1の appleCount 更新時に初めて見積もり金額が増えます。

💊 どうリファクタしたらいいか

今回の場合に限っては、サクッと対応するなら、Receipt コンポーネントの useEffect で、if条件を price > 0 && isPending から price > 0 に変えるだけで治ります。たったこれだけですが、プロダクトコードとなると前任者の意図を知る必要があります。今回は単なるミスか、 setPurchasedPrice の実行回数を抑制してパフォーマンスを改善したかったのかもしれません。仮にパフォーマンス改善が目的だったとして、その効果は限定的なので、 isPending は取り除いて price > 0 && !isPendingsetPurchasedPrice が実行されても良さそうです。ただし、コードによっては useEffect のコードが複雑に絡み合い、今回のようにすぐ判断できないものも多いかと思います。そういったケースを踏まえ、1歩踏み込んだリファクタを考えたいと思います。

そのuseEffect 必要?

「useEffect内のコード、読むのすら辛い…」というときにおすすめの考え方です。そもそも、useEffect ごと消してしまえば読まなくて済みます。経験上、多くの場合で useEffect は不要なケースが多いです。力こそパワーです。 以下は Receipt コンポーネントをリファクタしたものです。

const Receipt = ({ price }: { price: number }) => {
  return (
    <p>
      Purchased Price: ¥{price}
      {price > 0 && (
        <>
          <br />
          <strong>Thank you!</strong>
        </>
      )}
      <br />
      <small>(🍎 = ¥100)</small>
    </p>
  );
};

差分は以下のようになります

const Receipt = ({
  price,
-  isPending,
}: {
  price: number;
-  isPending: boolean;
}) => {
-  const [purchasedPrice, setPurchasedPrice] = useState(0);
-  useEffect(() => {
-    if (price > 0 && isPending) {
-      setPurchasedPrice(price);
-    }
-  }, [price, isPending]);
  return (
    <p>
      Purchased Price: ¥{purchasedPrice}
-      {price > 0 && !isPending && (
+      {price > 0 && (
        <>
          <br />
          <strong>Thank you!</strong>
        </>
      )}
      <br />
      <small>(🍎 = ¥100)</small>
    </p>
  );
};

そもそもこの Receipt コンポーネントの仕事は price を表示して、購入されたら「Thank you!」と言うだけです。もらった price を表示するだけでなので、useEffect は一切不要になります。また pricepatchToServer が完了後に更新されるので、 price は0円の商品を購入しない限りは price > 0 です。そのため isPending の状態によらずに購入時は price の値によって「Thank you!」を表示できます。

その state 必要?

useEffectは、ログを取ったりサーバーに保存するなど一部の場合を除いては基本的には不要なことが多いです。つまり useEffect 内のstateの更新は不要な場合が多い(もちろん必要な場合もあります)です。useEffect が必要かどうかを判断することすら面倒なことがあるかもしれません。その場合は 中の setState がそもそも不要なのでは?と疑い、思い切ってstateごと消すことを検討してみましょう。物理で殴れば解決します。 以下は App をリファクタしたものです。

export const App = () => {
  const [appleCount, setAppleCount] = useState(0);
  const [isPending, setIsPending] = useState(false);

  const addApple = () => {
    setAppleCount((prev) => prev + 1);
  };

  useEffect(() => {
    const asyncFunc = async () => {
      setIsPending(true);
      await patchToServer();
      setIsPending(false);
    };
    asyncFunc();
  }, [appleCount]);

  return (
    <div>
      <button type="button" onClick={addApple} disabled={isPending}>
        🍎 x {appleCount}
      </button>
      <br />
      <Receipt price={appleCount * 100} isPending={isPending} />
    </div>
  );
};

差分は以下のようになります

export const App = () => {
  const [appleCount, setAppleCount] = useState(0);
-  const [estimatedPrice, setEstimatedPrice] = useState(0);
  const [isPending, setIsPending] = useState(false);

  const addApple = () => {
    setAppleCount((prev) => prev + 1);
  };

  useEffect(() => {
    const asyncFunc = async () => {
      setIsPending(true);
      await patchToServer();
-      setEstimatedPrice(appleCount * 100);
      setIsPending(false);
    };
    asyncFunc();
  }, [appleCount]);

  return (
    <div>
      <button type="button" onClick={addApple} disabled={isPending}>
        🍎 x {appleCount}
      </button>
      <br />
-      <Receipt price={estimatedPrice} isPending={isPending} />
+      <Receipt price={appleCount * 100} isPending={isPending} />
    </div>
  );
};

今回は料金設計が単純なので、複雑な条件は必要なく、単にリンゴの個数に定数をかけるだけで見積もり金額が出せます。stateから計算できるものを、わざわざ新しいstateとして作り出す必要はありません。そのため今回は消しました。副作用( useEffect )もスッキリしました。仮に見積もり金額が複雑な場合や処理が重い場合は、 useMemo などの利用を検討してください。

♻️ リファクタについてのまとめ

こちらが最終的な修正コードになります。

import "./App.css";
import { useEffect, useState } from "react";

const patchToServer = async () =>
  await new Promise((resolve) => setTimeout(resolve, 100));

export const App = () => {
  const [appleCount, setAppleCount] = useState(0);
  const [isPending, setIsPending] = useState(false);

  const addApple = () => {
    setAppleCount((prev) => prev + 1);
  };

  useEffect(() => {
    const asyncFunc = async () => {
      setIsPending(true);
      await patchToServer();
      setIsPending(false);
    };
    asyncFunc();
  }, [appleCount]);

  return (
    <div>
      <button type="button" onClick={addApple} disabled={isPending}>
        🍎 x {appleCount}
      </button>
      <br />
      <Receipt price={appleCount * 100} />
    </div>
  );
};

const Receipt = ({ price }: { price: number }) => {
  return (
    <p>
      Purchased Price: ¥{price}
      {price > 0 && (
        <>
          <br />
          <strong>Thank you!</strong>
        </>
      )}
      <br />
      <small>(🍎 = ¥100)</small>
    </p>
  );
};

codeSandbox

まずは useEffectstate がそもそも必要なのか検討してみてください。根っこから削った結果、React v18 の Automatic batching で発生するバグは解消する可能性が高いかもしれません。またリファクタについては、多くのケースは公式のyou-might-not-need-an-effect に似たような内容が掲載されていると思います。一読されると良いかもしれません。ちなみに上記の例は、 Suspense の利用でさらにStateを減らし、patchToServeraddApple にまとめてさらにリファクタできる可能性はありますが、これ以上は本旨からずれてしまうので、この辺にしておきます。

まとめ

React v18では、Automatic batching の適用範囲が広がったことで再レンダリングが抑制され、パフォーマンスが向上しました。ただし、以前の実装だと動いたものが動かなくなるケースがあります。また、React v18からは strictMode & 開発モードで、useEffect が2回実行されるケースがあります。かつての componentDidMount のような使い方をしている場合は、unmount時の挙動の追加など、見直しが必要かもしれません。いずれの場合も時として、複雑で修正が難しいことがありますが、stateやuseEffect 自体がそもそも必要ない場合があります。根本からゴッソリ削ってみることを検討してみてください。

また、弊社グッドパッチ Product.divでは、フロントエンドバックエンド どちらも絶賛募集中です!PixiJS と React と GCP が触れて、エンジニアでも機能の企画・デザインから入れる職場なので、ご興味ある方はぜひ応募してみてください。