Reactパフォーマンス は何をしますか?
React 18/19およびNext.js向けのパフォーマンス最適化パターン集。Vercel Labs react-best-practices (MIT, v1.0.0) を基に作成されています。このスキルは優先順位に従ってルールを整理し、アクティブなコードレビューとリファクタリングのための決定木ガイダンスを提供します。
アクティベートするタイミング
- React/Next.jsのコードをパフォーマンス面から作成またはレビューする場合
- ページの読み込みが遅い、インタラクションが遅い、クライアントのCPU使用率が高いなどの問題を診断する場合
- バンドルサイズやLighthouseのCore Web Vitalsの低下を監査する場合
- Server Components / APIルートでのウォーターフォールを解消する場合
- クライアントサイドの再レンダリングを削減する場合
- 長いリスト、アニメーション、ハイドレーションを最適化する場合
app/、pages/、components/、データレイヤーに影響するPRでの最適化の選択を監査する場合
優先順位インデックス
| 優先順位 | カテゴリ | プレフィックス | 重要となるタイミング |
|---|---|---|---|
| 1 — 最重要 | ウォーターフォールの解消 | async- | awaitの後に独立したawaitが続く場合 |
| 2 — 最重要 | バンドルサイズ最適化 | bundle- | 初回読み込みJS、ルートレベルのインポート、サードパーティライブラリ |
| 3 — 高 | サーバーサイドパフォーマンス | server- | RSC、Server Actions、APIルート、SSR |
| 4 — 中高 | クライアントサイドデータ取得 | client- | SWR / TanStack Query / フック内の生のfetch |
| 5 — 中 | 再レンダリング最適化 | rerender- | 高頻度の状態更新、親子間のファンアウト |
| 6 — 中 | レンダリングパフォーマンス | rendering- | 長いリスト、アニメーション、ハイドレーション |
| 7 — 低中 | JavaScriptパフォーマンス | js- | ホットループ、頻繁なアロケーション |
| 8 — 低 | 高度なパターン | advanced- | エフェクトイベント統合、安定したref |
1. ウォーターフォールの解消(最重要)
「ウォーターフォールはパフォーマンスキラーの第1位」 — 逐次的な
awaitはすべてネットワークレイテンシを追加します。
awaitの前に安価な条件をチェック
リモートデータを待つ前に、同期的な条件(props、env、ハードコードされたフラグ)をチェックします。
// 誤り
async function Page({ id }: { id: string }) {
const flag = await getFlag("show-page");
if (!flag || !id) return null;
const data = await getData(id);
// ...
}
// 正しい — 安価な同期条件で先にショートサーキット
async function Page({ id }: { id: string }) {
if (!id) return null;
const flag = await getFlag("show-page");
if (!flag) return null;
const data = await getData(id);
}
使用時までawaitを遅延させる
awaitを、その値を使用するブランチに移動します。
// 誤り — データが必要かどうかを判断する前にawaitしている
const user = await getUser(id);
if (mode === "guest") return renderGuest();
return renderUser(user);
// 正しい
if (mode === "guest") return renderGuest();
const user = await getUser(id);
return renderUser(user);
独立した処理にはPromise.all
// 誤り — 逐次処理
const user = await getUser(id);
const posts = await getPosts(id);
const followers = await getFollowers(id);
// 正しい — 並列処理
const [user, posts, followers] = await Promise.all([
getUser(id),
getPosts(id),
getFollowers(id),
]);
部分的な依存関係 — 早期に開始、遅れてawait
// 正しい — すべてのPromiseを開始し、各結果が必要になった時点でのみawait
const userP = getUser(id);
const postsP = getPosts(id);
const profile = await getProfile(id);
if (profile.private) return null;
const [user, posts] = await Promise.all([userP, postsP]);
ストリーミングのためのSuspense
<Suspense>の境界をデータの近くに配置することで、遅いサブツリーがストリーミングされている間も、ページは可能な限りの内容を描画できます。トレードオフ:コンテンツが到着したときのレイアウトシフト — スペースを予約します(スケルトンまたはmin-height)。
Server Components: コンポジションによる並列化
// 誤り — 1つのコンポーネント内で兄弟のawaitが逐次的に実行される
export default async function Page() {
const user = await getUser();
const cart = await getCart();
return <View user={user} cart={cart} />;
}
// 正しい — 子コンポーネントに分割し、Reactが並列実行する
export default async function Page() {
return (
<View>
<UserSection />
<CartSection />
</View>
);
}
2. バンドルサイズ最適化(最重要)
直接インポート、バレルは使わない
バレルindex.tsファイルを使用すると、ツリーシェイキングがほとんどのコードを削除する場合でも、バンドラーはモジュールグラフ全体を辿る必要があります。直接インポートにより、多くの実世界のアプリで最初の読み込みJSが200〜800ms節約されます。
// 誤り
import { Button, Card, Modal } from "@/components";
// 正しい
import { Button } from "@/components/Button";
import { Card } from "@/components/Card";
import { Modal } from "@/components/Modal";
Next.js 13.5以降にはOptimize Package Importsがあり、リストされたパッケージに対してこれを自動化します — これを使用してください。リストされていないライブラリには手動での直接インポートが引き続き必要です。
静的に分析可能なパス
// 誤り — バンドラー/トレース分析を無効化
const mod = await import(`./pages/${name}`);
// 正しい — 分岐ごとに明示的
const mod = name === "home" ? await import("./pages/home") : await import("./pages/about");
重いコンポーネントの動的インポート
import dynamic from "next/dynamic";
const HeavyChart = dynamic(() => import("./HeavyChart"), {
loading: () => <Skeleton />,
ssr: false, // クライアント専用の場合
});
サードパーティスクリプトの遅延読み込み
アナリティクス、ログ、サポートウィジェットはハイドレーション後に読み込みます。strategy="afterInteractive"(デフォルト)または"lazyOnload"を指定してnext/scriptを使用します。
条件付きモジュール読み込み
if (user.role === "admin") {
const { AdminPanel } = await import("./admin/AdminPanel");
// ...
}
ホバー/フォーカス時のプリロード
ホバー時に<link rel="preload">またはimport()をトリガーして、ユーザーがクリックするまでにバンドルがキャッシュに存在するようにします。
3. サーバーサイドパフォーマンス(高)
Server ActionsをAPIルートと同様に認証する
すべての"use server"関数は公開エンドポイントです。アクション内で認証と認可の両方を行ってください — 呼び出し元のClient Componentのゲーティングに依存しないでください。
"use server";
export async function deleteUser(formData: FormData) {
const session = await getSession();
if (!session?.user) throw new Error("Unauthorized");
const targetId = String(formData.get("id"));
if (session.user.role !== "admin" && session.user.id !== targetId) {
throw new Error("Forbidden");
}
await db.user.delete({ where: { id: targetId } });
}
React.cache()によるリクエスト単位の重複排除
import { cache } from "react";
export const getUser = cache(async (id: string) => {
return db.user.findUnique({ where: { id } });
});
React.cacheは単一のリクエスト内で重複排除を行います。同じレンダリング内の3つのServer ComponentsからgetUser("1")を呼び出す = 1回のDBクエリ。
複数リクエスト間のデータにはLRUキャッシュ
リクエストごとに変化しないデータ(設定、ルックアップテーブル)には、React外部でLRUキャッシュまたはunstable_cacheを使用してキャッシュします。
RSCプロップでの重複シリアライゼーションを回避
Server Componentが複数のClient Componentに同じデータをレンダリングする場合、データはコンシューマごとに一度ずつシリアライズされます。Client Componentを引き上げて、子を渡すようにします。
静的なI/Oをモジュールスコープに移動
// 正しい — モジュール読み込み時に1回だけ実行
const fontData = readFileSync(fontPath);
export async function Page() {
return <Banner font={fontData} />;
}
RSC/SSRでミュータブルなモジュールレベルの状態を持たない
サーバー上のモジュール状態はすべてのリクエスト間で共有されます — ユーザー間で競合状態が発生します。代わりにリクエストスコープのストレージ(headers()、cookies()、非同期コンテキスト)を使用します。
Client Componentに渡すデータを最小化
クライアントが必要とするものだけをシリアライズします。フィールドを削除し、ページネーションし、DBレイヤーで列をプロジェクションします。
アイテムごとにPromise.allでネストされたfetchを並列化
const users = await getUsers();
const enriched = await Promise.all(
users.map(async (u) => ({ ...u, posts: await getPostsFor(u.id) })),
);
非ブロッキング処理にafter()を使用
Next.js 15のafter()は、レスポンスが送信された後に処理を実行します — ログ記録、キャッシュウォーミング、アナリティクスなど。
import { after } from "next/server";
export async function GET() {
const data = await getData();
after(() => logAnalytics(data));
return Response.json(data);
}
4. クライアントサイドデータ取得(中高)
SWR / TanStack Queryを使用した重複排除
複数のコンポーネントがuseUser(id)を呼び出す場合、1つのネットワークリクエストと1つのキャッシュエントリを共有する必要があります。SWRまたはTanStack Queryを使用してください — 共有データに独自のuseEffect + fetchを決して実装しないでください。
グローバルイベントリスナーの重複排除
// 誤り — すべてのコンポーネントが独自のリスナーを追加
useEffect(() => {
window.addEventListener("scroll", handler);
return () => window.removeEventListener("scroll", handler);
}, []);
// 正しい — フックとグローバルサブジェクトによる単一の共有リスナー
const useScroll = createScrollHook(); // 内部的にはシングルトンサブジェクト
スクロール用のパッシブリスナー
window.addEventListener("scroll", handler, { passive: true });
スクロールの滑らかさが向上します。リスナーはpreventDefault()を呼び出せなくなります。
localStorage: バージョン管理 + 最小化
- 常に
versionフィールドを保存し、スキーマ変更時にバージョンを上げ、古いデータを移行または破棄する - ペイロードは小さく保つ —
localStorageは同期的であり、メインスレッドをブロックする
5. 再レンダリング最適化(中)
コールバック内でのみ使用される状態にサブスクライブしない
// 誤り — countが変更されるたびに再レンダリング
const count = useStore((s) => s.count);
const handler = () => doSomething(count);
// 正しい — 呼び出し時に1回だけ読み取る
const handler = () => {
const count = useStore.getState().count;
doSomething(count);
};
高コストな処理をメモ化されたコンポーネントに抽出
// 正しい — `items`が変更されたときのみ子が再レンダリング
const Heavy = memo(function Heavy({ items }: { items: Item[] }) {
return <Chart data={transform(items)} />;
});
デフォルトの非プリミティブプロップをhoist
// 誤り — レンダリングごとに新しい配列が作成され、memoが機能しない
<List items={items ?? []} />
// 正しい
const EMPTY: Item[] = [];
<List items={items ?? EMPTY} />
エフェクトにはプリミティブな依存配列
// 誤り — レンダリングごとに新しいオブジェクト同一性
useEffect(() => {}, [{ id, name }]);
// 正しい — プリミティブ
useEffect(() => {}, [id, name]);
生の値ではなく、導出されたブール値にサブスクライブ
// 誤り — カートの変更すべてで再レンダリング
const cart = useStore((s) => s.cart);
const hasItems = cart.length > 0;
// 正しい — 空でなくなる/空になる切り替え時のみ再レンダリング
const hasItems = useStore((s) => s.cart.length > 0);
レンダリング中に導出、useEffectは使わない
// 誤り
const [full, setFull] = useState("");
useEffect(() => setFull(`${first} ${last}`), [first, last]);
// 正しい
const full = `${first} ${last}`;
安定したコールバックには関数型setState
// 正しい
const increment = useCallback(() => setCount((c) => c + 1), []);
高コストな値のための遅延状態初期化子
const [tree] = useState(() => parseTree(largeInput));
単純なプリミティブに対するmemoを避ける
useMemo(() => x + 1, [x])はオーバーヘッドです。memoはオブジェクト同一性と高コストな計算にのみ価値があります。
依存配列が独立しているフックは分割
// 誤り — どちらかのソースが変更されると両方のセレクタが再実行
const { a, b } = useSomething(source1, source2);
// 正しい
const a = useA(source1);
const b = useB(source2);
インタラクションロジックをイベントハンドラーに移動
イベントハンドラーはユーザーアクション時にのみ実行されます — useEffectは依存配列が変更されるたびに再実行されます。
緊急でない更新にはstartTransition
const [pending, startTransition] = useTransition();
startTransition(() => setFilters(newFilters));
高コストなレンダリングにはuseDeferredValue
const deferredQuery = useDeferredValue(query);
const results = useMemo(() => expensiveSearch(deferredQuery), [deferredQuery]);
過渡的な頻繁に変更される値にはuseRef
頻繁に変更されるが、再レンダリングをトリガーすべきでない値(タイムスタンプ、最後のキー、アキュムレータ)。
コンポーネントの内部でコンポーネントを定義しない
// 誤り — InnerはOuterがレンダリングされるたびに新しいコンポーネント
function Outer() {
const Inner = () => <span />;
return <Inner />;
}
レンダリングのたびに新しいInner型が作成され、Reconciliationが機能せず、子がアンマウントされます。
6. レンダリングパフォーマンス(中)
SVGではなくラッパーをアニメーション化
SVGの周りの<div>ラッパーをtransformすることはGPUアクセラレーションされます。SVG自体をtransformするとペイントがトリガーされます。
長いリストにはcontent-visibility: auto
.row { content-visibility: auto; contain-intrinsic-size: auto 80px; }
ブラウザは画面外のレンダリングをスキップします — 数百行のリストで大きな効果があります。
静的なJSXをhoist
const STATIC_HEADER = <h1>Title</h1>;
function Page() {
return <>{STATIC_HEADER}<Body /></>;
}
SVG: 座標精度を下げる
d="M10.123456,20.654321" → d="M10.12,20.65"。1桁につきバイト数を消費します。視覚的な違いはサブピクセルです。
インラインスクリプトによるハイドレーション時のちらつき防止
ハイドレーション前に必要な値(テーマ、ロケール)は、Reactがマウントする前にdocument.documentElement.dataset.*を設定する<script>をインライン化します。
予想されるハイドレーションの不一致を狭い範囲で抑制
<time suppressHydrationWarning>{new Date().toLocaleString()}</time>
既知の不一致があるリーフノードにのみ使用 — 他の子を含むツリーには決して使用しないでください。
マウント/アンマウントの代わりに<Activity>で表示/非表示
React 19の<Activity mode="visible|hidden">は、ツリーの状態とエフェクトをマウントしたままにして非表示にします — タブやアコーディオンでのアンマウント/再マウントより安価です。
条件付きレンダリングには&&の代わりに三項演算子
// 誤り — `0`がテキストノードとしてレンダリングされる
{count && <Badge>{count}</Badge>}
// 正しい
{count > 0 ? <Badge>{count}</Badge> : null}
ローディング状態にはuseTransition
アクションと一緒にstartTransitionを使用します。Reactは、次の状態が計算されている間、isPendingとして前のUIを表示します。
React DOMリソースヒント
import { preload, preconnect } from "react-dom";
preload("/api/critical", { as: "fetch" });
preconnect("https://api.example.com");
…
[翻訳の都合上、省略]