Reactで実装したフォームのパフォーマンスが問題になるのはなぜか

2024/01/15に公開
3

RelayHub合同会社久保田光則です。

Reactでフォームを効率よく実装するためのライブラリとして、React Hook FormReact Final FormTanStack Formなどがあります。これらのライブラリは、フォームを効率よく実装できる枠組みを提供してくれるだけではなく、高速なフォームを実装するための方法も提供してくれます。

で、この記事ではReact Hook Formとかそういうライブラリの使い方というよりかは、そもそもなぜReactで実装したフォームのパフォーマンスが問題になりやすいのか、その辺りの事情について解説します。

Reactのレンダリングの仕組み

Reactでは皆さんご存知のとおり、仮想DOMと呼ばれるDOMに似た構造のオブジェクトを生成してレンダリングを行います。Reactのコンポーネントはレンダリングするたびにそのコンポーネントに対応する仮想DOMを生成して、React内部では仮想DOMと実際のDOMを比較し差分を検出し、その差分を実際のDOMに適用するというやり方を採ります。

宣言的UIを構築をするのにこのレンダリングの方法は必要不可欠なもので、かつウェブアプリケーションの持つ様々なインタラクションを実装する上でも基本的にうまく機能します。ただ、フォームに関してはいくつか留意するべき事柄があります。

いわゆる素直な実装方法

まず上述したようなライブラリを使わずに、Reactだけを使ってフォームを実装する場合どういった風に実装していくのかを確認していきます。Reactでフォーム、というかテキスト入力欄を実装する方法としては、Controlled InputとUncontrolled Inputの2種類あります[1]

Controlled Inputとは、input要素などのフォーム入力欄の入力の状態を全て管理するやり方です。次のコードでは、input要素の状態をvaluepropsで常に指定しています。素直にReactっぽく書こうと思うとControlled Inputで実装したくなると思います。

MyForm.js
import React from 'react';
import { useState } from 'react';

const MyForm = () => {
  const [state, setState] = useState('');
  
  return (
    <form>
      <input
        type="text" name="名前" 
	value={state}
	onChange={e => setState(e.target.value)}
      /> 
    </form>
  );
};

このControlled Inputには、挙動を細かく柔軟に設定できるというメリットがある反面、キー入力時にReactコンポーネントの再レンダリング処理を含むJavaScriptでの実行が終わらないとテキスト欄への入力がブロックされるというデメリットもあります。つまりここのパフォーマンスが悪くなると、キーボードのキーを押下してから実際に入力したテキストが表示されるまでのユーザーの体験がダイレクトに悪くなるというわけです。

上記のようなシンプルなフォームであれば問題にはなりませんが、この実装のまま複数のフィールドを持ったフォームを実装すると、だいたい次のような形になると思います。

MyForm.js
import React from 'react';
import { useState } from 'react';

const MyForm = () => {
  const [state, setState] = useState({});
  const onChange = (e) => {
    const value = e.currentTarget.value;
    const name = e.currentTarget.name;
    setState(prev => ({...prev, [name]: value}));
  };
  
  return (
    <form>
      {/* フィールド1: 名前 */}
      <input type="text" name="name" value={state.name ?? ''} onChange={onChange} /> 
      {/* フィールド2: メールアドレス */}
      <input type="text" name="email" value={state.email ?? ''} onChange={onChange} /> 
      {/* フィールド3: 住所1 都道府県 */}
      <input type="text" name="address0" value={state.address0 ?? ''} onChange={onChange} /> 
      {/* フィールド4: 住所2 市区町村と番地 */}
      <input type="text" name="address1" value={state.address1 ?? ''} onChange={onChange} /> 
      {/* フィールド5: 住所3 建物名・部屋番号 */}
      <input type="text" name="address2" value={state.address2 ?? ''} onChange={onChange} /> 
      {/* ...その他の入力フィールド */}
      <AdditionalInputs {...state} />
      {/* フォームの状態に依存したその他のコンポーネント */}
      <AdditionalContent {...state} />
    </form>
  );
};

このフォームの実装の潜在的な問題は何かというと、テキスト入力欄でキー入力をするたびにフォーム全体が再レンダリングされるのと、フォーム内のフィールドやこのコンポーネントがレンダリングする要素が増えれば増えるほどキー入力時のパフォーマンスが悪くなっていく点です。

Controlled Inputを利用している場合にイベントハンドラでの処理が終わらない限り実際にフォームへの文字入力がブロックされるという背景を考えると、キー入力イベントの発火のたびにフォーム全体をレンダリングし直すという構造だとユーザーへの応答が遅れやすくなります。

Reactでのフォーム実装のパフォーマンスが問題になりやすい理由としては、宣言型UIを記述できるReactっぽく素直に実装するとキー入力のたびにフォームが再レンダリングされユーザーの体験が悪くなりやすい形の実装に導かれやすいという点が挙げられます。

ただReactでフォームのパフォーマンスが問題になるのはそれだけの理由ではなく、フォームに求められるパフォーマンスが通常のインタラクションよりも厳しいという事情もあります。

インタラクションのパフォーマンス

例えばボタンを押したらなんらかの処理を行ったりするような、ウェブアプリケーションが持つインタラクションを実装する上でどの程度の速度のパフォーマンスが求められるか考えたことはありますか?

Googleが提案したRAILパフォーマンスモデルの場合、インタラクションは100ms以内で応答すべきとされています。これはユーザーが何らかのアクション(画面のタップ、マウスのクリックなど)をしてから100ms以内に画面に何らかの視覚的レスポンスを表示すべきという意味です。さらにその100msのうち、JavaScriptでDOMを変更した場合のブラウザの再レンダリング処理やその他の処理(Idle Taskなど)も含まれるため、JavaScriptの実行時間は50ms以内に抑えるのがよいとされています。

Reactで実装したインタラクションは、この要件を簡単にクリアすることができます。素直に実装すれば問題になることは基本的に少ないでしょう。なぜならReactの再レンダリング処理やイベントハンドリングに50msもかかったりすることは少ないからです。もし何か重たい処理をするとしても、React18で追加されたuseTransition()useDeferredValue()などの新しいAPIを使って最適化を施すことも出来るでしょう。

フォームに求められる速度

フォームのインタラクションの場合は、前述した100ms以内に応答すればよい、というのは実は当てはまりません。フォームの場合、キーリピートや素早くキー入力する場合があり、100ms以内に何度も入力イベントが発火することがあるからです。

キーボードのキーを押しっぱなしにすると、その押したキーが連続で入力される状態になります。この状態をキーリピートといいます。最速で30ms程度の間隔で入力イベントが発生します[2]。また、キーリピードに限らず、すばやくキーボードを押下していると100ms以内に複数の入力イベントが発生することはよくあります。

こういった発火頻度の高いイベントはスクロールやマウスのドラッグや移動のイベントなどいくつかあります。これらのイベントに関しては、RAILモデルでも100ms以内の応答ではなく発火頻度に合わせて応答すべきとされています。なぜなら、発火頻度よりも応答が長くなってしまうと後続するユーザーのアクションがブロックされて体験が悪くなるからです。フォームの場合だと入力したはずの文字が画面に表示されず一瞬フリーズするような挙動をすることになります。

さて、フォームでキー入力した時のインタラクションを実装するときに、求められる応答の速さはどの程度でしょうか?キーボードのイベントの発火頻度が最速で30ms程度になることを考えると、応答の猶予は30ms以内です。ブラウザの再レンダリング処理の時間も考慮すると、Reactコンポーネントの再レンダリング処理を含むJavaScriptでの処理には約15-25ms程度しか残されていないことになります。

Reactでのフォームのパフォーマンスが問題になりやすいのは、素直に宣言的UIで記述すると、キー入力のたびにフォームを再レンダリングしてキー入力をブロックする形の実装になりやすい、というのとフォームに文字を入力するインタラクションのレスポンスに求められる猶予が通常のインタラクションよりも非常に小さいという複数の理由があります。

解決方法

この記事ではフォームのパフォーマンスを高速化する方法について詳しくは解説しませんが、Reactで高速なフォームが実装するための方法は基本的には次のようになります。

  • Controlled Inputを使う場合、状態やコンポーネントを分ける等して個別に最適化を施してパフォーマンスを良くする(キー入力時の再レンダリングを抑制する、もしくは再レンダリングのオーバーヘッドを抑制する)
  • 単純にUncontrolled Inputに切り替える。Uncontrolled Inputであればキー入力時にコンポーネントを再レンダリングする必要はない
  • React Hook FormReact Final FormTanStack Formなどのある程度自動的にパフォーマンス最適化してくれるフォームライブラリを導入する

どの方法を採用するにしても、キー入力のたびにフォーム全体を再レンダリングするという枠組みから抜け出る必要があるでしょう。

終わりに

Reactで実装するフォームを高速化するための記事などは見かけますが、そもそもなぜReactでのフォームのパフォーマンスが問題になるのかについてはあまり見かけないのでこの記事ではその辺りを解説しました。

最後に宣伝になりますが、お仕事ではウェブパフォーマンス改善支援サービスをやっていますので、事業で運営しているウェブサイトやウェブアプリケーションのパフォーマンス改善について、どうやって改善していけばいいかわからない、アドバイスを受けたい、という方はぜひご相談ください。

おまけ

レンダリングが遅くなったフォームでテキスト入力するとどうなるかをシミュレーションしたSlow Form Simulatorを作成しました。フォームのレンダリングするのに同じ遅延を持つControlled InputとUncontrolled Inputがどのように振る舞うのか違いを見てみてください。

脚注
  1. 制御されたコンポーネントと非制御コンポーネント ↩︎

  2. macOSの場合、コマンドラインで設定するとさらに速い20ms程度のキーリピートを設定できるらしいですが、とりあえずここでは考えないことにします ↩︎

RelayHub

Discussion

smikitkysmikitky

普通にcontrolled componentでやってみてパフォーマンスの問題が出ることって多いのでしょうか。今ぱっと実験した限り、その「いわゆる愚直な方式」で毎キーストローク全部を再レンダーした場合、仰るようなパフォーマンス問題が本当に「顕在化」するのはinputを数千個以上並べたときです。別に input や textarea に限った話ではなく、何らの最適化をしない場合は毎秒DOM要素が数万個といったレベルの処理にReactの限界が存在するのは確かです。でも私はinputを100個並べたフォームすらまず作りませんので、「フォームのパフォーマンスが問題になりやすい」などと現実に感じたことは一度もありません。

この MyForm のような、せいぜい一度に数十要素といったレベルでの更新であればReactは十分に速く、10msどころか1msだろうと楽々クリアしますし、むしろそれがReactの本分そのものとも言えるので、いったいみんなは何を心配しているのだろう…と感じてしまっています。

記事中では一部で「潜在的問題」と断っている部分もありつつ、現実にパフォーマンスの問題が出やすいかのような書き方がベースになっています。世の開発者はそんなにしょっちゅう1コンポーネントで数千ものinputを同時に扱っているのでしょうか。controlledであることで具体的にパフォーマンスに問題が出る一般的な例があるなら知りたいです。(仮にあったとしてもそんな複雑なフォームはcontrolledのままで改善する方針を考えた方がバグが少なくなりそうですが…)

anatooanatoo

smikitkyさん、おっしゃるとおりで、例で示した程度のコードでは現実的には全く問題にならないです。ただ、Controlled Inputを使っていてキー入力が詰まってしまうようなコードというのは、僕が遭遇したものだと、画面にたくさんのデータを表示するデータシートがフォームの状態に依存している場合や、コンポーネントの中で単純に重たい処理をしている場合などがそうでした。input要素の数が問題なのではなく単純にキー入力のたびに行われる再レンダリングのオーバーヘッドが大きくなると問題になるということですね。Controlledであっても個別に最適化すればそれは問題ないと思います。

smikitkysmikitky

画面にたくさんのデータを表示するデータシートがフォームの状態に依存している

A欄の入力内容に応じて重たい集計結果やフィルタリングの結果をリアルタイムに反映しないといけない上に、無関係のB欄やC欄に1文字タイプするだけでもその重い処理がなぜか毎回走る…みたいな状況でしょうか。本来は useMemouseDeferredValue の出番のような気がしますし、何なら単なるデバウンスも効きます。そこで「B欄やC欄をuncontrolledにして切り離せばいい」という発想は自分はしたことがなかったのですが、確かにそれも解決法ではあるのかもしれませんね。

コンポーネントの中で単純に重たい処理をしている

フォームの項目中にカスタムのデートピッカー等が大量にあるような場合でしょうか。複雑で多機能なフォームほどcontrolledにして情報源 (source of truth) をJavaScript内に集中させるメリットを享受できるので memo とか使ってちゃんと最適化すべし…と思っていたのですが、最近のフォーム系ライブラリがその辺もうまいことやってくれるならそれもアリなのかもという気がしてきました。ありがとうございました。