Reactの再レンダリング抑制でINPを6倍早くした話

概要

サロン向けの予約管理システム『サロンボード』のスマートフォン版において、特定の条件下で、ユーザ操作に対する応答性を評価する指標であるINPが2,570msを示していました。不要な再レンダリングを抑制することにより、INPを430msまで下げ約6倍高速化することができました。

 

前提

サロンボードについて

サロンボード』は『ホットペッパービューティー』に掲載しているサロン向けの予約・顧客管理システムです。PC版とスマートフォン版のシステムがあり、今回はスマートフォン版サロンボードを対象としています。技術スタックとしては、ReactおよびNext.js (Pages Router)を利用しており、より詳細な話は以前の記事に記載しておりますので、こちらも合わせてご確認ください。

 

INP (Interaction to Next Paint) とは 

INP (Interaction To Next Paint)とは、ユーザによるページ訪問までに発生したすべてのクリック、タップ、キーボード操作のレイテンシをモニタリングすることで、ユーザ操作に対するページの全体的な応答性を評価する指標です。この数値は小さい方が良く、ページがユーザ操作に対して素早く反応していることを示しています。

 

課題と改善目標

スマートフォン版向けサロンボードには1日の予約や予定を管理するためのタイムスケジュール画面という画面があります。

 

kano_schedule

 


この画面は当日以外でも日付を指定することで過去や未来の日付の予約や予定を確認することができます。日付選択ボタンを押すことで日付選択用のモーダルを開き、特定の日付を選択することでその日付の予約や予定の表示に切り替えることができます。私は動作確認中に、この日付選択ボタンを押してから日付選択用のモーダルが開くまでに時間がかかっており、少し表示が遅くなっていることに気づきました。これは予約や予定が多く入っている場合に特に重くなるようでした。

 

kano_dateButton

 

 

クリックしてから表示されるまでの時間はINPで計測できます。最も簡単に計測する方法は、LightHouseのTimeSpanを用いる方法です。計測対象は日付選択ボタンを押してモーダルが開くところまでのみを対象とします。結果は2,570ms でした。なお、実施環境はM1 Mac, CPU 20x slowdownで行いました。INPの目標値についてですが、SEO文脈では200ms以下が推奨、500msを超えると応答性が悪いとされているようです。サロンボードはサロンが使うスタッフ向けのシステムで検索エンジンにインデックスさせることはなくSEOを気にする必要はありませんが、今回はこの値を参考に、目標値を 500 ms 以下としました。

 

原因の特定

再レンダリングの有無の調査

体感だけではなく実測値でも重いことがわかりました。そこでなぜこんなにも時間がかかっているのかを調査しました。再レンダリングが余分に行われることで遅くなりやすいことが知られています。そのため、まずは余分な再レンダリングが行われていないかを調査します。React Dev Toolsをインストールし、Profilerの以下の項目にチェックを入れます。これにより、再レンダリングされたコンポーネントを目視で確認することができます。

  • Record why each component rendered while profiling.
  • Highlight updates when components render.

遅い表示になってしまう操作を改めて行ってみると、本来再レンダリングされる必要のない日付選択用のモーダル以外の部分に対して再レンダリングが行われていました。さらに調査を進めていくと、日付選択用のモーダルに隠れている予約・予定を表示しているコンポーネントが全て再レンダリングされてしまっていることがわかりました。

 

kano_highlight_rerender

 

再レンダリング要因の特定

次に、日付選択ボタンを押して日付選択用のモーダルを表示する際に、なぜ一見関係がないはずの予約や予定を表示しているコンポーネントが再レンダリングされてしまっているのかを調査しました。モーダルを開いているだけなので、予約や予定を表示している箇所は変更する必要がなく、再レンダリングの必要もありません。

 

タイムスケジュール画面のページコンポーネントの構造を抜粋して説明しておきます。

-- Page
   ├─ 日付選択ボタン(DateButton)
   ├─ 予定と予約を表示するタイムテーブル(SalonTimeTable)
   |  └─ 同時刻帯の予定と予約を管理するためのGroup(PanelGroupView)
   |     ├─ 予約パネル(ReservationPanel)
   |     └─ 予定パネル(EventPanel)
   └─ 日付選択モーダル(Modal)

 

React Dev ToolsのProfilerを使って、再レンダリングの要因を特定します。中身を 1 個ずつ見ていき、Why did this render?の項目に注目します。すると、このページ配下の各コンポーネントは大きく3つの要因により再レンダリングされていることがわかりました。

  1. ページ全体: Hook 21 changed
  2. SalonTimeTable: Props changed: (targetDate)
  3. PanelGroupView: Props changes: (renderEventPanel, renderReservationPanel)

 

kano_1_hook21 kano_2_targetDate kano_3_renderFunc

 

次章ではこれらについて詳しく解説するとともに、改善した方法について述べます。

 

改善方法

Hook 21 changed

Hook 21という記載だけではなんのことかわからないので、ComponentsタブからHook21が何者なのかを確認します。その結果、DisplayControl (モーダルの表示状態を管理するための自作カスタムフック)の21番目のstateであることがわかりました。

 

kano_profiler_hooks21

 

 

実際に該当箇所のコードを確認すると、以下のようになっていました。

  1. 日付選択ボタンクリック時にisOpenの状態がtrueになる。
  2. isOpenの状態更新によってDailySchedulePageが再レンダリングされる。
  3. 親であるDailySchedulePageが再レンダリングされたのでSalonTimeTableが再レンダリングされる。
// Before
function DailySchedulePage() {
  const [isOpen, setIsOpen] = useDisplayControl();
  return (
    <div>
      <SalonTimeTable /> 
      <DateButton onClick={() => setIsOpen(true)} />
      {isOpen && <Dialog />}
    </div>
  );
}

 

上記のように、親の再レンダリングに引っ張られて本来必要のないSalonTimeTableまで再レンダリングされてしまっているのが原因でした。このようなケースでは、

  • コンポーネントに切り出してStateを親で持たないようにする
  • React.memoを使用する

などの対策がありますが、実際のコンポーネントはもう少し複雑で、コンポーネントに切り出すことが難しかったため、今回はReact.memoを使用することでレンダリング回数を抑えるようにしました。以下にサンプルコードを示します。

 

// After
const MemoizedSalonTimeTable = memo(SalonTimeTable); // メモ化するコードを追加
function DailySchedulePage() {
  const [isOpen, setIsOpen] = useState(false);
  return (
    <div>
      <MemoizedSalonTimeTable /> /* メモ化したコンポーネントを利用するように変更 */
      <DateButton onClick={() => setIsOpen(true)} />
      {isOpen && <Dialog />}
    </div>
  );
}

 

これにより、親のコンポーネントが持つステートであるisOpenが更新されても子のSalonTimeTableコンポーネントの再レンダリングは行われないようになりました。参考までに、子コンポーネントに切り出す場合のコードも置いておきます。

 

// 参考: 子コンポーネントに切り出す場合のサンプルコード
function DialogWithDateButton() {
  // After: 子がstateを持つようにする
  const [isOpen, setIsOpen] = useState(false);
  return <>
    <DateButton onClick={() => setIsOpen(true)} />
    {isOpen && <Dialog />}
  </>
}
function DailySchedulePage() {
  // After: 親からstateへの依存をはがす
  return (
    <div>
      <TimeTable />
      <DialogWithDateButton>
    </div>
  );
}

 

Props changed: (targetDate)

以下に抜粋したコードのtargetDateDate型です。Reactは一度レンダリングしたコンポーネントに対してプリミティブな同じ値がpropsとして子に渡される場合、描画結果が同じになることが明白なので再レンダリングされないようになっています。しかし、Date型のようなオブジェクトはたとえ同じ日付だとしても別のオブジェクトとして扱われてしまい再レンダリングが行われています。つまり、以下のようなケースでも1つ目のtargetDateによってpropsが更新され、再レンダリングが発生します。

 

// Before
function DailySchedulePage() {
  const router = useRouter()
  const date = router.query.date
  // レンダリングのたびにDateオブジェクトが生成される
  const targetDate = parse(date, "yyyyMMdd") // date-fnsのparse関数でDateに変換
  return <div>
    <SalonTimeTable targetDate={targetDate}>
  </div>
}

 

ここで、stringに変更することで、同じ日付であっても再レンダリングしないようにします。

 

// After
function DailySchedulePage() {
  const router = useRouter()
  const dateString = router.query.date
  return <div>
    <SalonTimeTable dateString={dateString}> // Date → stringに変更することで同一性を担保する
  </div>
}

 

この修正により、文字列による比較が行えるため、同じ日付が選択された場合でも同じ値として利用することができるようになります。
Date型は変えずにuseMemoを使用する方法もありますが、SalonTimeTableが必要とするのは日付だけであり、不必要な時間情報を渡す必要はないと判断したためstringにしました。

 

Props changes: (functions)

コンポーネントに対して関数を渡すようなケースがあります。今回はこの関数が毎回生成されることによって再レンダリングされていました。

 

// Before
function PanelGroup () {
  const func = () => { 省略 }
  return <PanelGroupView func={func}>
}

 

このような場合にはuseCallbackを使用することで、無駄な再レンダリングを防ぐことができます。

 

// After
function PanelGroup () {
  const func = useCallback(() => {/* 省略 */}, [/* 省略 */]) // 関数に対してuseCallbackを適用する
  return <PanelGroupView func={func}>
}

 

改善結果

これら3つの改善を行うことで、余分な再レンダリングが行われなくなり、INPを2,570 msから430msまで落とし約6倍高速化することができました。

 

まとめ

今回は以下のような手順を踏むことでパフォーマンス改善を行いました。

 

  1. 体感で遅いと感じた部分をINPで計測することにより、体感だけではなく定量的にも遅いことを確認しました。
  2. 遅くなっている原因を再レンダリングと仮定し、Profilerを利用して再レンダリングが原因であることを確定させました。
  3. 再レンダリングが行われないように修正しました。
    1. 親の再レンダリングによる子の再レンダリングを抑制するために、useMemoを使用しました。
    2. 同じ日付でも別の値になるDate型をやめstring型にすることで同値になるようにしました。
    3. 関数がレンダリングのたびに再生成されないようにuseCallbackを利用するようにしました。