React 19 系(19.2 / 19.3)と React Compiler を前提にした、実務で役立つベストプラクティスのまとめです。
目次
- プロジェクトの始め方
- コンポーネント設計
- 状態管理
- useEffect の正しい使い方
- データ取得とサーバー連携
- フォームと Actions
- パフォーマンス
- TypeScript
- スタイリング
- アクセシビリティ
- テスト
- ディレクトリ構成
- Lint とツール
- アンチパターン早見表
1. プロジェクトの始め方
- Create React App は使わない(非推奨)。
- フルスタック / SSR が必要なら フレームワーク を使う
- Next.js(App Router)、React Router(フレームワークモード)、TanStack Start など
- SPA だけでよいなら Vite(
npm create vite@latest) - 最初から TypeScript と React Compiler を有効にしておく
2. コンポーネント設計
純粋関数として書く
コンポーネントは「同じ props / state なら同じ JSX を返す」純粋関数にします。レンダー中に外部の変数を書き換えたり、副作用を起こしたりしないこと。
// ❌ レンダー中に外部の値を書き換える
let count = 0;
function Bad() {
count++;
return <p>{count}</p>;
}
// ✅ 入力(props/state)だけから出力を決める
function Good({ count }: { count: number }) {
return <p>{count}</p>;
}小さく、単一責任に
- 1 コンポーネント = 1 つの関心事
- JSX が長くなったら、意味のある単位で分割する
- ただし「再利用されないのに細かく分けすぎる」のも可読性を下げるので注意
合成(Composition)を活用する
props のバケツリレー(prop drilling)や巨大な設定 props を避けるには、children やスロットを使った合成が有効です。
// ❌ 設定 props が増え続ける
<Card title="..." showFooter footerText="..." footerButtonLabel="..." />
// ✅ 合成で柔軟に
<Card>
<Card.Header>タイトル</Card.Header>
<Card.Body>本文</Card.Body>
<Card.Footer>
<Button>OK</Button>
</Card.Footer>
</Card>コンポーネントの中でコンポーネントを定義しない
親が再レンダーされるたびに別の型として扱われ、子の state がリセットされ、パフォーマンスも落ちます。
// ❌
function Parent() {
function Child() { return <div />; }
return <Child />;
}
// ✅ トップレベルで定義する
function Child() { return <div />; }
function Parent() { return <Child />; }key を正しく使う
- リストには 安定した一意の ID を
keyに使う(配列の index は並び替え・削除があると不具合の原因) keyを変えると state をリセットできる。リセット目的の useEffect より簡潔
<ProfileForm key={userId} userId={userId} />React 19 の書き方に合わせる
forwardRefは不要。refは普通の props として受け取れる- Context は
<MyContext value={...}>と直接書ける(.Provider不要) <title>/<meta>/<link>はコンポーネント内に書けば<head>に配置される
function Input({ ref, ...props }: React.ComponentProps<"input">) {
return <input ref={ref} {...props} />;
}3. 状態管理
state は最小限に
計算で求められる値は state にしない。 レンダー中に計算すれば十分です。
// ❌ 派生値を state で持ち、useEffect で同期
const [fullName, setFullName] = useState("");
useEffect(() => setFullName(`${first} ${last}`), [first, last]);
// ✅ レンダー中に計算
const fullName = `${first} ${last}`;state の置き場所
- 使う場所のできるだけ近く に置く(コロケーション)
- 複数のコンポーネントで共有するなら、最も近い共通の親に持ち上げる
- 状態の種類ごとに適切な道具を選ぶ
| 状態の種類 | 推奨 |
|---|---|
| ローカル UI 状態(開閉・入力値など) | useState / useReducer |
| サーバーから取得したデータ | TanStack Query、SWR、Server Components、フレームワークの loader |
| URL で表現できる状態(検索条件・タブ・ページ番号) | URL の search params |
| フォーム | <form> + Actions、React Hook Form など |
| アプリ全体で共有するクライアント状態 | Context(低頻度更新)、Zustand / Jotai など |
サーバーデータをグローバルストア(Redux 等)に手動でキャッシュするのは避け、専用ライブラリに任せるのが今の主流です。
state の更新はイミュータブルに
// ❌ 直接変更
items.push(newItem);
setItems(items);
// ✅ 新しい配列・オブジェクトを作る
setItems([...items, newItem]);
setUser({ ...user, name: "Taro" });前の値に依存する更新は関数形式で
setCount((c) => c + 1);複雑な状態遷移は useReducer
関連する複数の state が一緒に変わる場合や、更新ロジックが複雑な場合は useReducer にまとめると見通しが良くなります。
矛盾した状態を作らない
// ❌ isLoading と isError が同時に true になり得る
const [isLoading, setIsLoading] = useState(false);
const [isError, setIsError] = useState(false);
// ✅ 1 つのステータスで表現
type Status = "idle" | "loading" | "success" | "error";
const [status, setStatus] = useState<Status>("idle");4. useEffect の正しい使い方
useEffect は「React の外部システムと同期する」ためのもの です。多くのケースで不要です。
useEffect が不要なケース
| やりたいこと | 代わりの方法 |
|---|---|
| props/state から値を計算する | レンダー中に計算する |
| 重い計算をキャッシュする | React Compiler に任せる / useMemo |
| props が変わったら state をリセット | key を変える |
| ユーザー操作に反応する | イベントハンドラに書く |
| 親に変更を通知する | イベントハンドラ内で onChange を呼ぶ |
| データ取得 | データ取得ライブラリ / Server Components |
useEffect を使うべきケース
- WebSocket、ブラウザ API(
IntersectionObserver等)、サードパーティのウィジェットとの接続 - DOM の手動操作
クリーンアップを必ず書く
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => connection.disconnect();
}, [roomId]);依存配列を偽らない
- 依存配列から値を省略しない(Lint の警告を無視・抑制しない)
- 「この値の変化では再実行したくない」ロジックは
useEffectEventに切り出す(React 19.2〜)
const onConnected = useEffectEvent(() => {
showNotification("接続しました", theme); // theme の変化では再接続しない
});
useEffect(() => {
const conn = createConnection(roomId);
conn.on("connected", onConnected);
conn.connect();
return () => conn.disconnect();
}, [roomId]);外部ストアの購読は useSyncExternalStore
ブラウザの状態や外部ストアを購読するときは、useEffect + useState ではなく useSyncExternalStore を使います。
5. データ取得とサーバー連携
useEffect で fetch しない
自前で useEffect + fetch を書くと、競合状態(race condition)、キャッシュ、重複リクエスト、ローディング・エラー処理などを全部自分で面倒を見ることになります。
推奨される方法:
- フレームワークの仕組み(Server Components、loader)
- データ取得ライブラリ(TanStack Query、SWR)
- どうしても自前で書く場合は
AbortControllerなどで競合状態を必ず処理
// TanStack Query の例
const { data, isPending, error } = useQuery({
queryKey: ["user", userId],
queryFn: () => fetchUser(userId),
});Server Components(RSC)を使う場合
- デフォルトは Server Component、インタラクティブな部分だけ
"use client"にする "use client"の境界はできるだけ 末端(葉) に置き、クライアントに送る JS を減らす- 秘密情報や DB アクセスはサーバー側に閉じ込める(
server-onlyパッケージで誤インポートを防止) - Server Component から Client Component に渡す props はシリアライズ可能な値に限る
Suspense でローディングを宣言的に
<Suspense fallback={<Spinner />}>
<UserProfile id={id} />
</Suspense>use(promise)で Promise を読み取り、Suspense と組み合わせられる- Promise はレンダーのたびに新しく作らず、Server Component やキャッシュ済みのものを渡す
ウォーターフォールを避ける
親で取得 → 子で取得 → 孫で取得… と直列になると遅くなります。独立したデータは並列に取得(Promise.all、ルートレベルでの prefetch など)しましょう。
Error Boundary を置く
想定外のエラーでアプリ全体が真っ白にならないよう、画面やセクション単位で Error Boundary を設置します(react-error-boundary などが便利)。
6. フォームと Actions
React 19 では、フォームの送信処理を Actions で扱えます。
function NameForm() {
const [error, submitAction, isPending] = useActionState(
async (_prev: string | null, formData: FormData) => {
const result = await updateName(formData.get("name") as string);
return result.error ?? null;
},
null,
);
return (
<form action={submitAction}>
<input name="name" />
<SubmitButton />
{error && <p role="alert">{error}</p>}
</form>
);
}
function SubmitButton() {
const { pending } = useFormStatus();
return <button disabled={pending}>{pending ? "送信中…" : "送信"}</button>;
}useActionState:送信結果やエラーを state として扱うuseFormStatus:親フォームの送信状態を子コンポーネントから参照useOptimistic:サーバー応答を待たずに UI を楽観的に更新- バリデーションは必ずサーバー側でも行う(Zod などでスキーマを共有すると便利)
- 大規模で複雑なフォームは React Hook Form 等のライブラリも選択肢
7. パフォーマンス
基本方針:まず計測、それから最適化
- React DevTools の Profiler、ブラウザの Performance パネル(React のトラックが表示される)で計測してから手を入れる
- 推測で
memoを散りばめない
React Compiler を使う
React Compiler(1.0 安定版)が自動でメモ化を行うため、手動の useMemo / useCallback / React.memo はほとんど不要 になります。
- 新規プロジェクトでは有効化を推奨
- コンパイラが正しく動くには「Rules of React」(純粋性、Hooks のルール)を守ることが前提
- 既存コードの手動メモ化は、無理に削除しなくてよい(挙動が変わるリスクがあるため、テストした上で段階的に)
コンパイラなしの場合のメモ化
- 重い計算 →
useMemo memo化した子コンポーネントに渡す関数 →useCallback- 何でもかんでもメモ化しない(コストと可読性の悪化がある)
その他のテクニック
- コード分割:
lazy()+Suspenseでルート単位・重いコンポーネント単位に分割 - 長いリストの仮想化:TanStack Virtual、react-window など
- 重い更新を後回しに:
useTransition、useDeferredValue - 非表示の画面の状態を保持:
<Activity mode="hidden">(React 19.2〜)でタブ切り替え時などの状態を維持しつつ優先度を下げる - state を下に移す:頻繁に変わる state を、それを使う小さなコンポーネントに閉じ込めると再レンダー範囲が狭まる
- 画像の最適化:適切なサイズ・形式、
loading="lazy"
8. TypeScript
type ButtonProps = {
variant?: "primary" | "secondary";
} & React.ComponentProps<"button">;
export function Button({ variant = "primary", ...props }: ButtonProps) {
return <button data-variant={variant} {...props} />;
}- props は
typeまたはinterfaceで明示的に型付けする React.FCは必須ではない。普通の関数 + props の型注釈で十分- HTML 要素をラップする場合は
React.ComponentProps<"button">などを活用 anyを避け、unknown+ 型ガードを使う- 状態のバリエーションは 判別可能なユニオン型 で表現すると、ありえない状態を型で防げる
type State =
| { status: "loading" }
| { status: "success"; data: User }
| { status: "error"; error: Error };- API レスポンスなど外部データは Zod などで実行時にも検証する
tsconfigはstrict: trueを有効に
9. スタイリング
主な選択肢:
| 手法 | 特徴 |
|---|---|
| Tailwind CSS | ユーティリティファースト。現在最も広く使われる |
| CSS Modules | スコープ付き CSS。追加依存なし |
| ゼロランタイム CSS-in-JS(vanilla-extract、Panda CSS など) | 型安全、RSC と相性が良い |
| ランタイム CSS-in-JS(styled-components、Emotion) | RSC との相性に注意 |
- プロジェクト内で手法を 統一 する
- UI の土台には shadcn/ui、Radix UI、React Aria などの アクセシブルなヘッドレスコンポーネント を活用すると、品質とスピードを両立しやすい
10. アクセシビリティ
- セマンティックな HTML を使う(
<button>、<nav>、<main>、見出しの階層) <div onClick>でボタンを作らない- 画像には
alt、フォーム入力には<label>を関連付ける - キーボードだけで操作できるようにする(フォーカス順、フォーカスの可視化)
- モーダルではフォーカストラップと Esc キーでのクローズ
idの生成にはuseIdを使う(SSR でも一致する)- 色だけで情報を伝えない
eslint-plugin-jsx-a11yで機械的なチェックを入れる
11. テスト
方針
- 実装の詳細ではなく、ユーザーから見た振る舞い をテストする
- 「テストがユーザーの使い方に近いほど、信頼性が高い」
ツール
| 種類 | ツール |
|---|---|
| 単体・コンポーネントテスト | Vitest + React Testing Library |
| API モック | MSW(Mock Service Worker) |
| E2E テスト | Playwright |
| UI カタログ・ビジュアルテスト | Storybook |
test("送信ボタンを押すと完了メッセージが表示される", async () => {
const user = userEvent.setup();
render(<ContactForm />);
await user.type(screen.getByLabelText("名前"), "山田太郎");
await user.click(screen.getByRole("button", { name: "送信" }));
expect(await screen.findByText("送信しました")).toBeInTheDocument();
});- クエリは
getByRole/getByLabelTextを優先(アクセシビリティの確認にもなる) getByTestIdは最後の手段fireEventよりuserEventを使う
12. ディレクトリ構成
機能(feature)単位 で分けるのが、規模が大きくなっても破綻しにくい構成です。
src/
├── app/ # ルーティング、エントリーポイント、プロバイダー
├── features/
│ ├── auth/
│ │ ├── components/
│ │ ├── hooks/
│ │ ├── api/
│ │ └── index.ts # 外部に公開するものだけ export
│ └── todos/
├── components/ # 機能横断で使う汎用 UI(Button, Modal など)
├── hooks/ # 汎用カスタムフック
├── lib/ # ライブラリの設定・ラッパー
└── utils/ # 純粋なユーティリティ関数
- 関連するファイル(コンポーネント、テスト、スタイル)は近くに置く
- feature 同士の直接依存は避け、共通化が必要なら上位層に移す
- ロジックの再利用には カスタムフック を使う(名前は
useで始める)
13. Lint とツール
- ESLint
eslint-plugin-react-hooks(Hooks のルール、React Compiler 関連のルールも含む)— 必須eslint-plugin-jsx-a11y@typescript-eslint
- フォーマッター:Prettier または Biome
- Strict Mode:開発時は
<StrictMode>を有効に。副作用やクリーンアップ漏れを早期に発見できる - React DevTools:コンポーネントツリー、props/state、Profiler の確認
- CI で Lint・型チェック・テストを自動実行する
14. アンチパターン早見表
| アンチパターン | 改善策 |
|---|---|
| 派生値を state + useEffect で同期 | レンダー中に計算 |
| useEffect で fetch | データ取得ライブラリ / RSC / loader |
state を直接変更(push、プロパティ代入) | 新しいオブジェクト・配列を作る |
配列の index を key にする | 安定した ID を使う |
| コンポーネント内でコンポーネントを定義 | トップレベルで定義 |
| 依存配列の Lint 警告を抑制 | ロジックを見直す / useEffectEvent |
何でも useMemo / useCallback | React Compiler に任せる、計測してから最適化 |
| 巨大なグローバル Context に何でも入れる | 分割する / 状態をコロケーション |
| サーバーデータをグローバルストアに手動キャッシュ | TanStack Query など専用ツール |
<div onClick> でボタンを作る | <button> を使う |
| prop drilling が深い | 合成(children)、Context、構成の見直し |
"use client" をルート近くに付ける | 境界を末端に寄せる |