第06章:コンポーネント分割のルールを決める 📦✨
この章のゴールはシンプルです👇 **「コピペ地獄を防ぐための“置き場所ルール”を決めて、共通UI(Button / Input / Card)を作る」**です😆🧱
6-0 今日の完成イメージ 🏁
pages/(ページ)とcomponents/(部品)とservices/(外部と話す)を分ける- Tailwindのクラスが長くなりがちな ボタン・入力・カード を “共通部品化” してスッキリさせる✨🎽
- 後の章で Firestore / Storage / AI(Firebase AI Logic) を足していくときに、迷子にならない構造にする🧭

6-1 まず決める「4つの箱」ルール 🧺🧠
迷ったらこの4つに分類します👇
① pages(ページ)📄
- ルーティング(URL)と直結する “画面単位”
- ページ固有の組み立てをする場所(レイアウト+部品を並べる)
- 例:
UsersPage.tsx/SettingsPage.tsx
② components(部品)🧩
- 何度も使うUIを置く場所
- “見た目中心”で、できるだけ外部通信はしない(FirebaseやAI呼び出しはここに直書きしない)
- 例:
Button/Input/UserTable
③ hooks(状態ロジック)🪝
useXxxの “状態・手順” をまとめる場所- 例:
useUserForm()/useAuthState()(Reactの「propsで受け渡す」「状態を親に持ち上げる」感覚が大事になります🙂)(React)
④ services(外部と話す)🔌☁️
- Firebase / AI / 外部API など “外の世界” とやり取りする場所
- Reactを使わない(
useStateとかを書かない) - ここにまとめると、UI側がスッキリして事故が減ります💥

FirebaseのWeb SDKも「バージョン混在で壊れる」みたいな落とし穴があるので、外部系をまとめるのはかなり効きます🧯(Firebase)
6-2 おすすめフォルダ構成(まずはこれでOK)📁✨
(あとで増やしていいので、最初はこのくらいの粒度で👍)
src/
pages/
DashboardPage.tsx
UsersPage.tsx
SettingsPage.tsx
layouts/
AppShell.tsx
Sidebar.tsx
TopBar.tsx
components/
ui/
Button.tsx
Input.tsx
Card.tsx
index.ts
services/
ai/
aiClient.ts // ここにAI呼び出しを寄せる(後の章で育てる)
firebase/
index.ts // 後の章でSDK初期化を集約する予定
lib/
cn.ts // className結合の小道具(任意)
types/
index.ts
6-3 迷った時の「置き場所フローチャート」🧭🤔
Q1. URL(ルート)に対応する?
→ Yes ✅:pages/
→ No ❌:次へ
Q2. 見た目(UI)を再利用したい?
→ Yes ✅:components/(とくに components/ui)
→ No ❌:次へ
Q3. useState / useEffect を含む “手順や状態” を再利用したい?
→ Yes ✅:hooks/
→ No ❌:次へ
Q4. Firebase / AI / HTTP など “外部通信” が中心?
→ Yes ✅:services/
→ No ❌:lib/(小道具) or types/(型)
6-4 手を動かす:共通UI(Button / Input / Card)を作る 🛠️🎨
Step 1:フォルダを作る📁
src/components/uisrc/layoutssrc/services/aisrc/services/firebasesrc/libsrc/types
Step 2:className結合ヘルパー(任意だけど便利)🧪
src/lib/cn.ts
export function cn(...values: Array<string | undefined | false | null>) {
return values.filter(Boolean).join(" ");
}
Step 3:Button(共通部品)🔘✨
src/components/ui/Button.tsx
import type { ButtonHTMLAttributes } from "react";
import { cn } from "../../lib/cn";
type Variant = "primary" | "secondary" | "danger";
type Props = ButtonHTMLAttributes<HTMLButtonElement> & {
variant?: Variant;
isLoading?: boolean;
};
const base =
"inline-flex items-center justify-center rounded-md px-3 py-2 text-sm font-medium " +
"transition focus:outline-none focus:ring-2 focus:ring-offset-2 disabled:opacity-60 disabled:cursor-not-allowed";
const variants: Record<Variant, string> = {
primary: "bg-blue-600 text-white hover:bg-blue-700 focus:ring-blue-500",
secondary: "bg-slate-200 text-slate-900 hover:bg-slate-300 focus:ring-slate-400",
danger: "bg-red-600 text-white hover:bg-red-700 focus:ring-red-500",
};
export function Button({
variant = "primary",
className,
isLoading,
children,
disabled,
...rest
}: Props) {
return (
<button
className={cn(base, variants[variant], className)}
disabled={disabled || isLoading}
{...rest}
>
{isLoading ? "処理中…" : children}
</button>
);
}
ポイント👇
childrenとpropsの受け渡しが基本動作です(Reactの基本)🙂(React)

Step 4:Input(共通部品)⌨️✨
src/components/ui/Input.tsx
import type { InputHTMLAttributes } from "react";
import { cn } from "../../lib/cn";
type Props = InputHTMLAttributes<HTMLInputElement> & {
label?: string;
error?: string;
};
export function Input({ label, error, className, ...rest }: Props) {
return (
<label className="block">
{label && <div className="mb-1 text-sm font-medium text-slate-700">{label}</div>}
<input
className={cn(
"w-full rounded-md border px-3 py-2 text-sm outline-none",
"focus:ring-2 focus:ring-blue-500",
error ? "border-red-500" : "border-slate-300",
className
)}
{...rest}
/>
{error && <div className="mt-1 text-xs text-red-600">{error}</div>}
</label>
);
}

Step 5:Card(共通部品)🪪✨
src/components/ui/Card.tsx
import type { ReactNode } from "react";
import { cn } from "../../lib/cn";
export function Card({ className, children }: { className?: string; children: ReactNode }) {
return (
<div className={cn("rounded-lg border border-slate-200 bg-white p-4 shadow-sm", className)}>
{children}
</div>
);
}
export function CardTitle({ children }: { children: ReactNode }) {
return <h2 className="mb-2 text-base font-semibold text-slate-900">{children}</h2>;
}
export function CardBody({ children }: { children: ReactNode }) {
return <div className="text-sm text-slate-700">{children}</div>;
}

Step 6:まとめてexport(使う側が楽になる)📦
src/components/ui/index.ts
export * from "./Button";
export * from "./Input";
export * from "./Card";
6-5 レイアウトを “layouts/” に移す(第5章の続き)🧱➡️📦
たとえば、今まで App.tsx にベタ書きしてた「サイドバー+ヘッダー+メイン枠」を
src/layouts/AppShell.tsx に移します🙂
import type { ReactNode } from "react";
export function AppShell({ sidebar, topbar, children }: {
sidebar: ReactNode;
topbar: ReactNode;
children: ReactNode;
}) {
return (
<div className="min-h-dvh bg-slate-50">
<div className="flex">
<aside className="w-64 border-r bg-white">{sidebar}</aside>
<div className="flex-1">
<header className="border-b bg-white">{topbar}</header>
<main className="p-4">{children}</main>
</div>
</div>
</div>
);
}
こういう “組み立て部品” は children を受け取ると強いです💪
(ただし Children API を濫用すると壊れやすいので、まずは普通に children を受け取る感じでOKです🙂)(React)
6-6 services に「AIの入口」を用意しておく 🤖🚪
ここがこの章のちょい未来ポイントです✨ 後の章で Firebase AI Logic を使って “文章整形ボタン” とかをやる予定なので、呼び出し口を services 側に寄せるクセを今から付けます👍
src/services/ai/aiClient.ts(今はダミーでOK)
export type NormalizeTextResult = {
text: string;
};
export async function normalizeText(input: string): Promise<NormalizeTextResult> {
// 第18章あたりで Firebase AI Logic(Gemini/Imagen)接続に差し替える予定✨
// いまは“それっぽい戻り”にしてUIを先に作れるようにする
return { text: input.trim() };
}
Firebase AI Logic は、Web/モバイルから Gemini や Imagen を呼ぶためのクライアントSDKや、App Checkなどの保護と組み合わせる前提で用意されています🛡️(Firebase) あと地味に重要なんですが、モデル名や提供状況は更新が入るので、公式の注意書き(例:一部モデルの提供終了日)を章を進めるたびに見るクセを付けると強いです🧠(Firebase)
6-7 仕上げ:import を楽にする(パス別名)🧭✨
相対パス ../../components/ui/Button って増えると地味にストレスです😇
Vite は resolve.alias が使えます(ファイルシステムへのaliasは絶対パス推奨)(vitejs)
vite.config.ts(例:@ を src に)
import { defineConfig } from "vite";
import react from "@vitejs/plugin-react";
import path from "node:path";
export default defineConfig({
plugins: [react()],
resolve: {
alias: {
"@": path.resolve(__dirname, "src"),
},
},
});
これで @/components/ui みたいに書けます🎉
※ tsconfig の paths を Vite が解釈する機能もありますが、パフォーマンスコストの注意があるので、最初は alias 方式が無難です🙂(vitejs)
6-8 AI(Antigravity / Gemini CLI)でリファクタを加速する 🛸💻
Antigravity の使いどころ(この章で超相性いい)🤝
- 「今の
src/を見て、上の構成に移す作業」をまるごと任せやすいです - Antigravity は “ミッション管理+ブラウザ+エージェント” 前提の開発プラットフォームとして紹介されています(Google Codelabs)
おすすめの投げ方(コピペ用)👇
- 「このプロジェクトを
pages/layouts/components/hooks/servicesに整理して」 - 「Button/Input/Card を作って、既存画面のコピペ箇所を置換して」
- 「変更点を “ファイル単位の差分” で出して」
- 「動作確認チェックリストも作って」
Gemini CLI の使いどころ(差分生成が便利)🧰✨
Gemini CLI はターミナルで使えるAIエージェントで、ReAct ループや MCP サーバー対応などが説明されています(Google Cloud Documentation) おすすめは “いきなり全部変更” じゃなくて、まず計画→差分→適用 の順です🙂
投げ方テンプレ👇
- 「現状の
src/構造を読み取って、移動計画を出して」 - 「次に、Button/Input/Card を追加する差分を生成して」
- 「最後に、既存のコピペUIを置換する差分を生成して」
- 「壊れやすい点(import / export / 循環参照)をチェックして」
6-9 ミニ課題 🎯✨
✅ 「同じ見た目のボタン」を3箇所見つけて、全部 Button に置き換える
✅ 置換したら、variant="secondary" と variant="danger" も最低1回使う
✅ できたら Input を1個導入して、error を表示してみる(わざとエラー文字を入れてOK)😆
6-10 チェック✅(ここまで出来たら勝ち!)🏆
-
pages/とcomponents/が分かれてる -
Button / Input / Cardがcomponents/uiにある -
components/ui/index.tsからまとめてimportできる - 同じUIのコピペが減った(少なくともボタンは共通化できた)
-
services/aiの “入口ファイル” ができてる(中身はダミーでOK)(Firebase)
6-11 よくあるつまずき 😵💫🧯
-
export/import が噛み合わない →
export function Buttonなのにimport Buttonしてる、みたいなやつ。 → 迷ったらcomponents/ui/index.tsに寄せて、そこから import すると安定👍 -
分割しすぎて逆に迷子 → “同じUIを2回以上” になったら共通化、くらいの温度感でOK🔥 → 早すぎる抽象化は罠です🪤😇
-
services に React を持ち込む →
services/は “純粋な関数” っぽくしておくと後で強いです💪
次の第7章は、UIに「loading / error / data」の三兄弟を入れて、**“待ち時間でも気持ちいい管理画面”**にしていきます🔁✨ 第6章がキレイにできるほど、第7章以降がめちゃ楽になりますよ〜😆🚀