Reactアプリを高速化する4つの実践的テクニック
はじめに
Webアプリケーションが重いと、ユーザーは待たされて離脱してしまいます。特に最初の画面が表示されるまでの時間や、操作に対する反応の速さは重要な指標です。
このブログでは、実際のReactアプリで実施した4つのパフォーマンス改善手法を紹介します。前半の1〜2章では初期表示を速くする工夫、後半の3〜4章では操作時の体感速度を上げる工夫を扱います。
大事なのは、4つすべてを機械的に入れることではなく、まず計測して「どこが遅いのか」を見つけることです。この記事では、1章は画面単位の読み込み最適化、2章は部品単位の読み込み最適化、3章は一覧の一部だけを更新する方法、4章は入力中の高コスト処理を遅らせる方法として整理します。
サンプルコードでは、React Router、React Query、use-debounce を使います。React 本体だけではないライブラリも登場するので、その点も意識しながら読むと全体像をつかみやすくなります。
1. 画面ごとに必要なコードだけ読み込む(コード分割)
問題
Reactアプリでは、最初に全画面のコードを一括読み込みすることが珍しくありません。全画面分のJavaScriptを一度にダウンロードするため、初期表示に時間がかかっていました。
解決策
React.lazy と Suspense を使うと、ルーティング単位でコードを分割し、必要なタイミングまで読み込みを遅らせられます。Suspense は、読み込み中に代わりの UI を表示する仕組みです。React.lazy で読み込むモジュールは、基本的に default export(そのファイルの代表として 1 つだけ書き出す形式)された React コンポーネントである必要があります。
import { lazy, Suspense } from "react";
const HomePage = lazy(() => import("./pages/HomePage"));
const SettingsPage = lazy(() => import("./pages/SettingsPage"));
function App() {
return (
<Suspense fallback={<div>読み込み中...</div>}>
<Routes>
<Route path="/" element={<HomePage />} />
<Route path="/settings" element={<SettingsPage />} />
</Routes>
</Suspense>
);
}
この例では Routes 全体を Suspense で囲っています。実運用では、共通レイアウトまで fallback(読み込み中に表示する代替 UI)に置き換わらないように、必要な範囲だけを囲むことがよくあります。
効果
ユーザーがアクセスした画面に関係するコードの読み込みを後ろ倒ししやすくなるため、初回のダウンロード量が減り、表示が速くなることが多くなります。なお、共通の依存ライブラリや共有チャンク(複数画面で共通利用するコードのまとまり)は別途読み込まれる場合があり、分割を細かくしすぎると逆に遅くなることもあります。
2. 重い機能は必要な時だけ読み込む(遅延読み込み)
問題
Markdownエディタやグラフライブラリなど、機能が豊富なライブラリはサイズが大きくなりがちです。編集画面でしか使わないライブラリが、編集しない画面でも読み込まれているのは無駄です。
解決策
1章が画面単位のコード分割なら、こちらは重い部品単位の遅延読み込みです。import() を使った動的 import と React.lazy を組み合わせることで、重い UI コンポーネントを必要な画面だけで読み込めます。通常のユーティリティ関数や解析ライブラリのような非 UI ライブラリは、イベントハンドラや useEffect の中で plain な import() を使って読み込むのが基本です。
import { lazy, Suspense } from "react";
const HeavyEditor = lazy(() => import("./components/HeavyEditor"));
function EditScreen() {
return (
<Suspense fallback={<div>エディタを読み込み中...</div>}>
<HeavyEditor />
</Suspense>
);
}
効果
実際の改善例では、初期バンドルサイズ(最初に読み込まれる JavaScript の量)が1.5MBから442KBに削減されました。編集しない画面ではエディタ用ライブラリをダウンロードしなくなったことで、表示が速くなっています。
3. 一覧の一部だけ更新する(局所更新)
ここからは、初期表示ではなく操作時の体感速度を改善する話です。
問題
投稿を削除した後、一覧を再取得するためにサーバーに再度リクエストを送っていませんか?小さな更新のたびに全件取得すると、通信量が増え、体感速度も遅くなります。
解決策
局所更新とは、一覧全体を取り直さず、必要な部分だけを書き換えることです。React Query(サーバーデータの取得結果をキャッシュして扱うライブラリ)の queryClient.setQueryData を使うと、キャッシュ済みの一覧をローカルで更新できます。次の例は、削除 API が成功した後にキャッシュを更新して一覧へ反映するケースです。
const removeItem = (itemId: string) => {
queryClient.setQueryData(["items"], (oldItems: { id: string }[] = []) => {
return oldItems.filter((item) => item.id !== itemId);
});
};
注意点
この方法が向いているのは、削除後の一覧結果をクライアント側で安全に予測できる場合です。サーバー側で並び順、件数、関連データ、権限による表示内容まで変わるなら、invalidateQueries などで再取得した方が整合性を保ちやすくなります。
効果
一覧を丸ごと再取得する待ち時間がなくなったことで、成功後の反映が速くなりました。サーバーに余計なリクエストを送らなくなったため、通信量の削減効果も出ています。
4. 入力時の処理を最適化する(デバウンス)
問題
フォームの入力時に、検索、API 呼び出し、重いバリデーションのような高コストな処理が毎文字入力ごとに走ると、重いアプリでは入力遅延を感じることがあります。
解決策
デバウンスは、「入力が止まってから一定時間後に処理を1回だけ実行する」方法です。入力欄の状態は即時更新しつつ、重い副作用(検索や API 呼び出しのように、入力以外に何かを起こす処理)だけを遅らせます。
import { useState } from "react";
import { useDebouncedCallback } from "use-debounce";
function SearchForm() {
const [keyword, setKeyword] = useState("");
const debouncedSearch = useDebouncedCallback((value: string) => {
console.log("検索実行:", value);
}, 300);
const handleChange = (e: React.ChangeEvent<HTMLInputElement>) => {
const value = e.target.value;
setKeyword(value);
debouncedSearch(value);
};
return <input value={keyword} onChange={handleChange} />;
}
検索 API を呼ぶ場合は、AbortController で前のリクエストを中止したり、古いレスポンスを無視したりして、後から返ってきた古い結果で画面が上書きされないようにします。
効果
入力値は即時反映しつつ、検索処理は「入力が止まってから300ms後」に実行されるため、入力中の引っかかりを減らしやすくなります。
まとめ
4つのテクニックを整理します。
| テクニック | 解決したい問題 | 主な手法 |
|---|---|---|
| コード分割 | 初期読み込みの重さ | React.lazy + Suspense |
| 遅延読み込み | 重いライブラリの読み込み | 動的 import |
| 一覧の一部更新 | 不要なデータ再取得 | React Queryのローカル更新 |
| デバウンス | 入力遅延 | use-debounce |
重要なのは、漫然とではなく計測して問題を見つけ、改善効果を確認することです。ブラウザの開発者ツールでバンドルサイズやネットワークリクエストを確認し、どこが重いかを特定するところから始めましょう。
着手順に迷ったら、初期表示が遅いなら 1 → 2、操作後の反映が遅いなら 3、入力中にもたつくなら 4 の順で検討すると進めやすくなります。
小さな改善の積み重ねが、心地よい操作体験を生み出します。