React Patterns
React 18/19の慣用的なパターン集。堅牢でアクセシブル、パフォーマンスの高いコンポーネントツリーを構築するためのものです。
アクティベーションのタイミング
- React関数コンポーネント、カスタムフック、コンポーネントツリーを作成・修正するとき
- JSX/TSXファイルをレビューするとき
- 状態の構造やコンポーネント構成を設計するとき
- クラスコンポーネントや
forwardRef/useEffectが多い古いコードを移行するとき - ローカル状態、リフトアップ状態、コンテクスト、外部ストアの選択を検討するとき
- サーバーコンポーネント / クライアントコンポーネント(Next.js App Router、RSC)を扱うとき
- React 19のアクションまたは制御された入力でフォームを実装するとき
- TanStack Query / SWR / RSC でデータフェッチを組み込むとき
基本原則
1. レンダーはプロパティと状態の純粋関数
// Good: レンダー中に導出
function Cart({ items }: { items: CartItem[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <span>{formatMoney(total)}</span>;
}
// Bad: 導出状態を個別に保持
function Cart({ items }: { items: CartItem[] }) {
const [total, setTotal] = useState(0);
useEffect(() => {
setTotal(items.reduce((sum, i) => sum + i.price * i.qty, 0));
}, [items]);
return <span>{formatMoney(total)}</span>;
}
useEffect内での導出状態の設定は、レンダーサイクルを増やし、非同期を引き起こす可能性があり、データフローを不明瞭にします。
2. 副作用はレンダー外で行う
副作用、ミューテーション、ネットワーク呼び出し、サブスクリプションはイベントハンドラまたはuseEffectに配置し、レンダー本体には決して記述しません。
3. 継承よりもコンポジション
Reactにはコンポーネントの継承モデルはありません。children、レンダープロップ、コンポーネントプロップを使用してコンポジションします。
フックの規律
完全なルールセットは rules/react/hooks.md を参照。ハイライト:
- トップレベルのみ、条件付きは禁止
- すべてのサブスクリプション、インターバル、リスナーはクリーンアップ
- 新しい状態が古い状態に依存する場合は関数型更新子(
setX(prev => prev + 1))を使用 - デフォルトの立場: メモ化しない — プロファイラまたは依存関係チェーンが重要だと証明した場合のみ
useMemo/useCallbackを追加 - 同じフックシーケンスが2つ以上のコンポーネントに現れた場合のみカスタムフックを抽出
状態配置の決定木
1つのコンポーネントでのみ使われる?
-> そのコンポーネント内で useState
親と少数の子孫で使われる?
-> 最も近い共通祖先にリフトアップ
遠く離れたブランチで使われ、読み取り頻度が低い(テーマ、認証、ロケール)?
-> React Context
高頻度の更新がツリー全体で共有される?
-> 外部ストア(Zustand、Jotai、Redux Toolkit)
サーバーから派生する?
-> サーバー状態ライブラリ(TanStack Query、SWR、RSC fetch)
ほとんどのページでContextやグローバルストアは必要ありません。リフトアップが煩わしくなるまで抽象化は控えてください。
サーバー/クライアントコンポーネント (RSC)
// Server Component - デフォルト、非同期、JSをクライアントに送らない
import { db } from '@/lib/db';
import { notFound } from 'next/navigation';
import { ProductView } from './ProductView';
export default async function ProductPage({ params }: { params: { id: string } }) {
const product = await db.product.findUnique({ where: { id: params.id } });
if (!product) notFound();
return <ProductView product={product} />;
}
// Client Component - "use client" でオプトイン
"use client";
import { useTransition } from 'react';
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToCart(productId))}
>
{pending ? "追加中..." : "カートに追加"}
</button>
);
}
境界:
- Server -> Client: シリアライズ可能なプロップスまたは
childrenを渡す - Client -> Server:
<form action={...}>またはイベントハンドラから Server Action を実行 - Client コンポーネントファイルから Server コンポーネントを
importしてはいけません — 代わりにchildrenを使ってコンポジションします
Suspense + Error Boundaries
import { ErrorBoundary } from 'react-error-boundary';
import { Suspense } from 'react';
<ErrorBoundary fallback={<ErrorView />}>
<Suspense fallback={<UserSkeleton />}>
<UserDetail id={id} />
</Suspense>
</ErrorBoundary>
- Suspense 境界はデータの近くに配置し、ルートレベルでは配置しない — コンテンツを段階的に表示
- Error Boundary はクラスAPIのまま;フックフレンドリーなラッパーには
react-error-boundaryを使用 - 境界は子のレンダー時、ライフサイクル時、コンストラクタ時にスローされたエラーをキャッチします — イベントハンドラや非同期コード内ではキャッチしません
フォーム
React 19 フォームアクション(新規コードに推奨)
"use client";
import { useActionState } from "react";
const initial = { error: null as string | null };
async function updateUserAction(_prev: typeof initial, formData: FormData) {
"use server";
const parsed = UserSchema.safeParse(Object.fromEntries(formData));
if (!parsed.success) return { error: "入力が無効です" };
await db.user.update({ where: { id: parsed.data.id }, data: parsed.data });
return { error: null };
}
export function UserForm() {
const [state, formAction, pending] = useActionState(updateUserAction, initial);
return (
<form action={formAction}>
<input name="name" required />
<button type="submit" disabled={pending}>保存</button>
{state.error && <p role="alert">{state.error}</p>}
</form>
);
}
制御された入力
値が他のUIを駆動する場合、キーストロークごとにフォーマットする場合、リアルタイムバリデーションを実装する場合に制御された入力を使用します。
複雑なフォーム
マルチステップフォーム、動的フィールド配列、クロスフィールドバリデーションには、ライブラリ(React Hook Form、TanStack Form)を使用します。複雑ではないフォームで独自の状態管理を行うとメンテナンスの罠になります。
データフェッチの決定マトリクス
| ニーズ | ツール |
|---|---|
| Next.js App Routerでのリクエストごとのデータ | RSC await fetch() |
| クライアントサイドキャッシュ + ミューテーション + 無効化 | TanStack Query |
| 軽量クライアントキャッシュ + 再検証 | SWR |
| リアルタイムサブスクリプション | Server-Sent Events、WebSockets、またはライブラリのサブスクリプションAPI |
| 一度限りのfire-and-forget | イベントハンドラ内の fetch() |
アプリケーションデータに対して useEffect + fetch を使用するのは避けてください — 競合状態、キャッシュなし、リトライなし、Suspenseとの統合なし。
コンポジションのレシピ
children によるスロット
<Layout>
<Header />
<Main>{content}</Main>
</Layout>
名前付きスロット
<Page header={<Nav />} sidebar={<Filters />}>
<Results />
</Page>
複合コンポーネント(Contextを介した共有状態)
<Tabs defaultValue="profile">
<Tabs.List>
<Tabs.Trigger value="profile">プロフィール</Tabs.Trigger>
<Tabs.Trigger value="settings">設定</Tabs.Trigger>
</Tabs.List>
<Tabs.Panel value="profile"><Profile /></Tabs.Panel>
<Tabs.Panel value="settings"><Settings /></Tabs.Panel>
</Tabs>
レンダープロップ / 関数としての子
親がレンダリング出力にパラメータを渡す必要がある場合に便利です:
<DataLoader id={id}>
{({ data, isLoading }) => isLoading ? <Spinner /> : <UserCard user={data} />}
</DataLoader>
最新の代替手法: 同じ形状を返すフック(useData(id)) — 通常、よりクリーンです。
パフォーマンス
React.memo が実際に役立つとき
次の条件がすべて満たされる場合のみ、コンポーネントを React.memo でラップします:
- 頻繁に再レンダリングされる
- プロップスがレンダー間で通常同じ
- レンダーが測定可能なほど高コスト
React.memo はすべてのレンダーで等価性チェックを追加します。ほとんどのレンダーでプロップスが異なる場合、そのチェックは純粋なオーバーヘッドです。
レンダーカスケードの防止
- 状態は上方ではなく下方にリフトする
- Context を分割する: 関心ごとに1つのContext。
themeContextの変更が認証コンシューマーを再レンダリングしないようにする - 外部状態ライブラリには
useSyncExternalStoreを使用 — 安全な並行レンダリングに必要
リスト
- 安定した
keyプロップ(データベースID、配列インデックスは避ける)を提供する - 表示アイテム数が非自明な行で約50を超えたら、
@tanstack/react-virtualまたはreact-windowで仮想リスト化する
アクセシビリティファーストのコンポジション
role属性に手を伸ばす前に、常にセマンティックなHTML(<button>、<a>、<nav>、<main>)をレンダリングする- すべてのインタラクティブ要素はキーボードでアクセス可能でなければならない
- フォーム入力にはラベルが必要 —
<label htmlFor>または、アイコンで視覚的にラベル付けされている場合はaria-label - ルート変更やモーダルの開閉時にフォーカスを管理する
- コンポーネントテストで
axeを実行する(skills/react-testing を参照) - 相互参照: skills/accessibility/SKILL.md はWCAG基準とパターンライブラリをカバー
ルーティング
このスキルはルーターに依存しません。上記のパターンは React Router、TanStack Router、Next.js App Router、Remix Router で動作します。ルーター固有のパターン(ローダー、アクション、ネストされたレイアウト)は各ルーターのドキュメントに従ってください — これらはReactコアの上に重ねられたフレームワークの関心事です。
スコープ外(参照セクション)
- Next.js固有: App Routerのデータ読み込み、Route Handlers、Middleware、並行ルート — 別の関心事です。Next.jsのドキュメントを参照
- React Native: プラットフォーム固有のパターンは大きく異なるため、別の
react-native-patternsスキルが必要(現時点では存在しません) - Remix: Loader/actionの規則はRSCと重複しますが、Remixのドキュメントに従ってください
関連情報
- ルール: rules/react/ — コーディングスタイル、フック、パターン、セキュリティ、テスト
- スキル: react-performance(Vercel由来のパフォーマンスルールセット)、frontend-patterns(フレームワーク横断的なUIの関心事)、accessibility、angular-developer(フレームワーク比較)
- エージェント:
react-reviewer(コードレビュー用)、react-build-resolver(ビルド/バンドラエラー用) - コマンド:
/react-review、/react-build、/react-test
例
デバウンス検索のカスタムフック
import { useState, useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
function useDebounce<T>(value: T, delay = 300): T {
const [debounced, setDebounced] = useState(value);
useEffect(() => {
const id = setTimeout(() => setDebounced(value), delay);
return () => clearTimeout(id);
}, [value, delay]);
return debounced;
}
function SearchBox() {
const [query, setQuery] = useState("");
const debounced = useDebounce(query, 300);
const { data } = useQuery({
queryKey: ["search", debounced],
queryFn: () => searchApi(debounced),
enabled: debounced.length > 0,
});
return (
<>
<input value={query} onChange={(e) => setQuery(e.target.value)} />
<Results items={data ?? []} />
</>
);
}
React 19 useOptimistic を使った楽観的UI
"use client";
import { useOptimistic } from "react";
export function MessageList({ messages }: { messages: Message[] }) {
const [optimistic, addOptimistic] = useOptimistic(
messages,
(state, newMessage: Message) => [...state, newMessage],
);
async function send(formData: FormData) {
const text = String(formData.get("text"));
addOptimistic({ id: "pending", text, sender: "私" });
await saveMessage(text);
}
return (
<>
<ul>{optimistic.map((m) => <li key={m.id}>{m.text}</li>)}</ul>
<form action={send}>
<input name="text" />
<button type="submit">送信</button>
</form>
</>
);
}
レンダーカスケードを防ぐためのContext分割
import { createContext, useContext } from 'react';
// 2つのContext: 1つは滅多に変更されず、もう1つは頻繁に変更される
const ThemeContext = createContext<string>("light");
const NotificationsContext = createContext<Notification[]>([]);
// ThemeContextのみを消費するコンポーネントは、通知が変更されても再レンダリングされない