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 も用意しておきました🙏
このコードが期待する動作は以下の通りです。
- ユーザーがリンゴを増やす
- リンゴが増えたらサーバーに送り、通信中は
pending
状態にする - 通信が完了したら、見積もり金額を計算し、
pending
を終了する - 見積もり金額を
Receipt
コンポーネントに渡し、表示する - 購入が完了したら「Thank you!」を表示する
リンゴの数を増やすと気づくと思いますが、個数と金額が合わなくなります。
React v17 での Receipt
コンポーネントのレンダリング回数
React v17まではsetStateのたびに再レンダリングされていました。React v17環境でリンゴを追加した際の Receipt
コンポーネントのレンダリングは以下のようになります。
appleCount
の更新setIsPending(true)
の更新setEstimatedPrice
の更新setIsPending(false)
の更新
合計4回です。この3の時に、Receipt
コンポーネント内の useEffect
で一時的に price > 0 && isPending
が成立します。そのため、setPurchasedPrice
が実行され、表示に問題が生じません。
React v18 での Receipt
コンポーネントのレンダリング回数
react v18 ではPromise内のsetStateも1つにバッチ処理されます。具体的には、 App
コンポーネントの patchToServer
以降の setEstimatedPrice
と setIsPending
はバッチ処理されます。つまりリンゴを追加した際の Receipt
コンポーネントのレンダリングは以下のようになります。
appleCount
の更新setIsPending(true)
の更新setEstimatedPrice
&setIsPending(false)
の更新
合計3回です。price > 0 && isPending
が成立しない場合が発生し、setPurchasedPrice
が実行されないので、表示に問題が生じます。2回目にリンゴを追加した際、1の appleCount
更新時に初めて見積もり金額が増えます。
💊 どうリファクタしたらいいか
今回の場合に限っては、サクッと対応するなら、Receipt
コンポーネントの useEffect
で、if条件を price > 0 && isPending
から price > 0
に変えるだけで治ります。たったこれだけですが、プロダクトコードとなると前任者の意図を知る必要があります。今回は単なるミスか、 setPurchasedPrice
の実行回数を抑制してパフォーマンスを改善したかったのかもしれません。仮にパフォーマンス改善が目的だったとして、その効果は限定的なので、 isPending
は取り除いて price > 0 && !isPending
で setPurchasedPrice
が実行されても良さそうです。ただし、コードによっては 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
は一切不要になります。また price
は patchToServer
が完了後に更新されるので、 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
まずは useEffect
や state
がそもそも必要なのか検討してみてください。根っこから削った結果、React v18 の Automatic batching で発生するバグは解消する可能性が高いかもしれません。またリファクタについては、多くのケースは公式のyou-might-not-need-an-effect に似たような内容が掲載されていると思います。一読されると良いかもしれません。ちなみに上記の例は、 Suspense の利用でさらにStateを減らし、patchToServer
も addApple
にまとめてさらにリファクタできる可能性はありますが、これ以上は本旨からずれてしまうので、この辺にしておきます。
まとめ
React v18では、Automatic batching の適用範囲が広がったことで再レンダリングが抑制され、パフォーマンスが向上しました。ただし、以前の実装だと動いたものが動かなくなるケースがあります。また、React v18からは strictMode & 開発モードで、useEffect
が2回実行されるケースがあります。かつての componentDidMount
のような使い方をしている場合は、unmount時の挙動の追加など、見直しが必要かもしれません。いずれの場合も時として、複雑で修正が難しいことがありますが、stateやuseEffect
自体がそもそも必要ない場合があります。根本からゴッソリ削ってみることを検討してみてください。
また、弊社グッドパッチ Product.divでは、フロントエンド、バックエンド どちらも絶賛募集中です!PixiJS と React と GCP が触れて、エンジニアでも機能の企画・デザインから入れる職場なので、ご興味ある方はぜひ応募してみてください。