【React】正解がなくて迷う0→1開発へ。画面駆動×Feature駆動×スキーマ駆動のミックスアーキテクチャ
はじめに
Reactのディレクトリ構成やフロントエンドのアーキテクチャには「絶対的な正解」が存在しません。
プロジェクトを始めるたびに「Atomic Designの分類で迷う」「クリーンアーキテクチャを適用しすぎてディレクトリが深くなる」といった問題で議論が空転しがちです。
特に0→1(新規立ち上げ)開発においては、重厚すぎる設計論に囚われると開発スピードが著しく低下し、逆に適当に作り始めると一瞬でコードがスパゲティ化します。
本記事では、0→1開発において「思考コストゼロで爆速開発でき、後から破綻しない」を目指して辿り着いた、画面駆動 × Feature駆動 × スキーマ駆動のミックス構成について解説します。
3つの「駆動」を組み合わせる設計思想
このアーキテクチャの核は、役割と関心を3つの軸でパキッと切り分けることです。
- 画面(Pages)駆動: ルーティングと1対1対応する「薄いシェル」。
- Feature(機能)駆動: 画面構造と完全ミラーリングされた「機能の閉じ込め領域」。
- スキーマ駆動: バックエンドとの通信・型・フックを全自動生成する「型安全パイプライン」。
ディレクトリ構造ツリー
src/
├── pages/ # 【1. 画面駆動】ルーティング対応の薄いシェル
│ ├── index.tsx # -> features/home を呼ぶだけ
│ └── users/
│ ├── index.tsx # -> features/users/list を呼ぶだけ
│ └── [id].tsx # -> features/users/detail を呼ぶだけ
│
├── features/ # 【2. Feature駆動】pagesと完全ミラーリングされた名前空間
│ ├── home/
│ ├── users/
│ │ ├── list/ # users/index.tsx に対応するFeature
│ │ │ ├── components/ # この画面専用のドメイン知識を持つUI部品
│ │ │ ├── hooks/ # 画面状態・ローカルロジック
│ │ │ └── index.tsx # エントリポイント(Featureコンポーネント)
│ │ └── detail/
│ │ ├── components/
│ │ └── index.tsx
│ └── common/ # 複数Featureで使うドメイン共通処理
│
├── components/ui/ # 【UI層】ドメイン知識ゼロの純粋UI部品(Button, Modal等)
├── lib/api/
│ └── client.ts # 共通のAxiosインスタンス(インターセプター設定)
└── generated/ # 【3. スキーマ駆動】orvalが自動生成する型・フック群
各層の役割と開発体験(DX)を上げる工夫
1. pages/ と features/ の完全ミラーリング(ナビゲーション迷子ゼロ)
通常のFeature駆動で起きがちな「画面とFeatureの対応関係が分からなくなる」問題を解決するため、URLの階層構造と features/ のディレクトリ構造を1対1で完全に一致させます。
-
pages/users/[id].tsxを見たら、修正対象のコードはfeatures/users/detail/にあると思考ゼロで確信できます。 -
pages/配下はロジックを持たず、以下のように数行の「薄いシェル」に徹します。
// src/pages/users/[id].tsx
import { UserDetailFeature } from '@/features/users/detail';
export default function UserDetailPage() {
return <UserDetailFeature/>;
}
2. components/ui/ vs features/xxx/components/ の明確な境界線
「このコンポーネントはどこに置くべきか?」で迷わないよう、明確な割り切りルールを設定します。
-
components/ui/(純粋UI層): -
ドメイン知識(APIレスポンスの型やビジネスロジック)を一切知らない部品。
-
children,onClick,classNameなどのプリミティブな props のみ受け取る(shadcn/ui的アプローチ)。 -
features/xxx/components/(ドメインUI層): -
特定の機能やAPIの型に直接依存しており、ドメイン知識を持つ部品(例:
UserProfileCardなど)。
0→1開発での抽象化ルール(DRYの割り切り)
「将来使い回すかも」と最初から過度に汎用的なコンポーネントを作ろうとすると、propsが無駄に肥大化して壊れやすくなります。
最初はすべてfeatures/内に閉じ込めておき、実際に2カ所以上で使われて汎用化の形が見えたタイミングで初めてcomponents/ui/やfeatures/common/へ引き上げる運用がベストです。
3. スキーマ駆動:orval × TanStack Query × Axios
0→1開発で手書きコードを最も削減できるのが、API通信層の完全自動化です。
OpenAPI(Swagger)などのスキーマ定義から、orval を使って TypeScript の型定義・Axios クライアント・TanStack Query(React Query)のカスタムフックを src/generated/ 配下へ全自動生成します。
共通Axios設定(mutator)を挟み込む
単にフックを自動生成するだけでなく、カスタムの Axios インスタンス(mutator)を噛ませます。
// src/lib/api/client.ts(共通Axiosインスタンス)
import axios from 'axios';
export const customAxiosInstance = axios.create({
baseURL: process.env.NEXT_PUBLIC_API_URL,
});
// 共通インターセプター(認証ヘッダー付与やエラーの共通ハンドリング)
customAxiosInstance.interceptors.request.use((config) => {
const token = localStorage.getItem('token');
if (token) config.headers.Authorization = `Bearer ${token}`;
return config;
});
これを orval に読み込ませることで、自動生成されたフックを実行するだけで、認証ヘッダー付与や共通エラーハンドリングが全通信に自動適用されます。
Feature内でのデータフェッチは、以下のようにたった数行で完結します。
// src/features/users/detail/index.tsx
import { useGetUserById } from '@/generated/users'; // orvalが自動生成したフック
import { useRouter } from 'next/router';
export const UserDetailFeature = () => {
const router = useRouter();
const id = router.query.id as string;
// 型安全&共通Axios設定適用済みのフックを呼ぶだけ
const { data: user, isLoading, error } = useGetUserById(id);
if (isLoading) return <div>Loading...</div>;
if (error) return <div>エラーが発生しました</div>;
return (
<div>
<h1>{user.name}</h1>
</div>
);
};
拡張性:CSR前提で作りつつ、特定画面だけSSR化できる
このアーキテクチャの最大の強みは、「初期は開発の早いCSRで爆速で立ち上げ、後からSEO等が必要になった画面(詳細ページなど)だけSSR化する」というアプローチが極めて容易な点です。
TanStack Query の dehydrate 機構を使えば、pages/ 側でデータを事前取得(Prefetch)してキャッシュを流し込むだけで完結します。
features/ 側で使っているフックやUIコンポーネントは1行も変更する必要がありません。
// src/pages/users/[id].tsx(特定の画面だけSSR化する場合)
import { UserDetailFeature } from '@/features/users/detail';
import { getUserById } from '@/generated/users'; // 自動生成された純粋なAPI関数
import { customAxiosInstance } from '@/lib/api/client';
import { QueryClient, dehydrate, HydrationBoundary } from '@tanstack/react-query';
import { GetServerSideProps } from 'next';
export const getServerSideProps: GetServerSideProps = async (context) => {
const queryClient = new QueryClient();
const id = context.params?.id as string;
// 1. サーバー側でデータをプリフェッチしてキャッシュに詰める
await queryClient.prefetchQuery({
queryKey: ['getUserById', id],
queryFn: () => getUserById(id, customAxiosInstance),
});
return {
props: {
dehydratedState: dehydrate(queryClient), // 2. キャッシュをクライアントへ渡す
},
};
};
export default function UserDetailPage({ dehydratedState }: any) {
return (
<HydrationBoundary state="{dehydratedState}">
{/* 3. FeatureコンポーネントはCSRの時と一切変えずにそのまま呼び出す! */}
<UserDetailFeature/>
</HydrationBoundary>
);
}
ルーティングとSSRのデータ準備だけを pages/ が引き受け、UIやビジネスロジックを持つ features/ は一切汚さない。関心の分離が美しく保たれます。
まとめ
正解がないReactのディレクトリ構成において、0→1開発で重要なのは「迷う時間を極限まで減らし、後からの変更に強くすること」です。
- 画面とFeatureを1対1で揃えることで、コードの迷子をなくす。
- スキーマ駆動(orval + TanStack + Axios)で、型とAPI通信の記述を完全自動化する。
-
コンポーネントの共通化は後回しにし、まずは
features/に閉じ込める。 - pagesを薄いシェルに徹することで、後からのSSR化もシームレスに対応できる。
この構成を採用してから、0→1開発における実装スピードと保守性が劇的に向上しました。新規プロダクトの立ち上げやフロントエンドの構成に悩んでいる方の参考になれば幸いです。