React Patterns
堅牢でアクセシブル、かつパフォーマンスの高いコンポーネントツリーを構築するための、慣用的な React 18/19 パターン。
有効化するタイミング
- React の関数コンポーネント、カスタムフック、またはコンポーネントツリーを作成または修正する場合
- JSX/TSX ファイルをレビューする場合
- 状態の形状やコンポーネント構成を設計する場合
- クラスコンポーネントや古い
forwardRef/useEffect主体のコードを移行する場合 - ローカル状態、リフトされた状態、コンテキスト、外部ストアの選択肢を比較検討する場合
- Server Components / Client Components(Next.js App Router、RSC)を扱う場合
- React 19 のアクションや制御された入力を使ってフォームを実装する場合
- TanStack Query / SWR / RSC を使用したデータフェッチを配線する場合
基本原則
1. レンダーは Props と State の純粋関数
// 良い: レンダー中に導出する
function Cart({ items }: { items: CartItem[] }) {
const total = items.reduce((sum, i) => sum + i.price * i.qty, 0);
return <span>{formatMoney(total)}</span>;
}
// 悪い: 導出状態を別途保存する
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)
ほとんどのページはコンテキストやグローバルストアを必要としません。重複したリフトが苦痛になるまで、抽象化は控えてください。
Server / Client Components(RSC)
// Server Component - デフォルト、非同期、自身の JS を送信しない
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";
export function AddToCartButton({ productId }: { productId: string }) {
const [pending, startTransition] = useTransition();
return (
<button
disabled={pending}
onClick={() => startTransition(() => addToCart(productId))}
>
{pending ? "Adding..." : "Add to cart"}
</button>
);
}
境界:
- Server -> Client: シリアライズ可能な props または
childrenを渡す - Client -> Server:
<form action={...}>を介して Server Actions を呼び出すか、イベントハンドラから命令的に呼び出す - Client Component ファイルから Server Component を
importしないでください。代わりにchildrenを介して構成してください
Suspense + Error Boundaries
<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: "Invalid input" };
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}>Save</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、WebSocket、またはライブラリのサブスクリプション API |
| 1 回限りのファイアアンドフォーゲット | イベントハンドラ内の 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">Profile</Tabs.Trigger>
<Tabs.Trigger value="settings">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 はすべてのレンダーで等価性チェックを追加します。ほとんどのレンダーでプロップが異なる場合、そのチェックは純粋なオーバーヘッドになります。
レンダリングカスケードの回避
- 可能な限り、状態を上げるのではなく下げる
- コンテキストを分割する: 関心事ごとに 1 つのコンテキスト。これにより、
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、Parallel Routes — 別の関心事。Next.js ドキュメントを参照
- React Native: プラットフォーム固有のパターンは、別の
react-native-patternsスキルを正当化するほど異なります(まだ存在しません) - Remix: Loader/action の規則は RSC と重複しますが、Remix のドキュメントに従います
関連
- ルール: rules/react/ — コーディングスタイル、フック、パターン、セキュリティ、テスト
- スキル: react-performance(Vercel 由来のパフォーマンスルールセット)、frontend-patterns(フレームワーク横断的な UI 関心事)、アクセシビリティ、angular-developer(フレームワーク比較用)
- エージェント:
react-reviewer(コードレビュー用)、react-build-resolver(ビルド/バンドラーエラー用) - コマンド:
/react-review、/react-build、/react-test
例
デバウンス検索のためのカスタムフック
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: "me" });
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">Send</button>
</form>
</>
);
}
レンダリングカスケードを避けるためのコンテキスト分割
// 2 つのコンテキスト: 1 つはほとんど変更されず、もう 1 つは頻繁に変更される
const ThemeContext = createContext<Theme>("light");
const NotificationsContext = createContext<Notification[]>([]);
// ThemeContext のみを消費するコンポーネントは、通知が変更されても再レンダリングされない