frontend-architecture について
frontend-architectureは、モジュール、ページ、状態、インポート境界を整えるためのフレームワーク横断型スキルです。機能単位の構成、サーバー状態とUI状態の分離、ページやコンポーネントとスタイルの共置を示します。
主な機能
- 01機能ごとの自己完結型モジュール構成
- 02ページと専用コードのディレクトリ管理
- 03サーバー状態とUI状態の分離
- 04公開バレルを介したモジュール間インポート
- 05ページやコンポーネントとスタイルの共置
活用シーン
- 01新規フロントエンドプロジェクトのディレクトリ設計
- 02モジュール境界とインポート規則の統一
- 03サーバーデータとクライアント状態管理の標準化
- 04ページスタイルと専用コンポーネントの整理
インストール
コマンドをコピーしてターミナルで実行します。
npx skills add https:/ / github.com/ stareezy-1/ frontend-architecture-skill --skill frontend-architectureインストール後、Claude Code で/frontend-architectureを入力して読み込まれたか確認します。
詳細
フロントエンドアーキテクチャ(ポータブル、モジュールベース)
ポータブルなスキルです。Claude Code、OpenCode、Codex、Cursor、Windsurf などで読み取れます。 このスキルでは、コンポーネントライブラリ、状態管理ライブラリ、ビジュアルスタイルではなく、構造と一連のルールについて説明します。 意図的に汎用的な設計です。同じモジュール/ページ/状態モデルを Next.js(App Router)、React + Vite(SPA)、Remix、Expo / React Nativeに適用でき、 あらゆる状態管理スタックやスタイリングスタックで動作します。
目標は、すべてのコントリビューターが誰にも聞かずに、次の3つの問いにすぐ答えられるコードベースです — 「このコードはどこに置くのか?」、「何が何をインポートできるのか?」、「これはサーバー状態か、UI 状態か?」 — この構造により、答えが一目でわかります。
0. 5つの基本理念
- 機能モジュールがそれぞれの領域を所有します。 各機能は、ページ、コンポーネント、フック、状態、型、単一の公開バレルを備えた自己完結型の
modules/{feature}/フォルダーです。 - ページ/画面はファイルではなくディレクトリです。 ルートは、コンポーネント、スタイル、そのページ専用のコンポーネント/フックをまとめて置くフォルダーです。
- 状態は発生元ごとに分けます。 サーバーデータはクエリ/キャッシュレイヤーに置きます。UI/クライアント状態はストアに置きます。どのライブラリを選んでも、両者が重なることはありません。
- 境界を越えるインポートはバレル経由に限ります。 別のモジュールの内部に直接アクセスすることは禁止です。
@/modules/{feature}からインポートし、それより深い階層からはインポートしません。 - コードは配置しておくのではなく、必要に応じて昇格させます。 可能な限りローカルな場所から始め、2つ目の利用箇所が現れたときにだけ外側へ移します。
以下はすべて、この5つの理念を機械的に適用したものです。特定のライブラリには依存しません。使用するスタックはセクション4と6で選んでください。
1. ディレクトリ構成
フレームワークが異なっても構成は同じです。異なるのは、その上に載るルーティングレイヤーのみです(セクション7を参照)。
src/
├── app/ or routes/ or navigation/ ← framework routing layer (thin — see §7)
├── modules/ ← feature modules (the heart of the app)
│ └── {feature}/
│ ├── index.ts ← PUBLIC BARREL — the only cross-module entry point
│ ├── README.md ← what this module owns, its routes, its data deps
│ ├── components/ ← components reused by 2+ pages IN THIS MODULE
│ ├── pages/ ← page/screen directories (one per route)
│ │ └── {page}/
│ │ ├── {page}.tsx ← the page/screen component
│ │ ├── {page}.styles.ts ← ALL styling for this page
│ │ ├── index.ts ← re-exports the page component
│ │ ├── components/ ← components used ONLY by this page
│ │ ├── hooks/ ← hooks used ONLY by this page
│ │ ├── constants/
│ │ └── README.md ← route, params, permissions, data deps
│ ├── hooks/ ← data hooks (query/mutation) + module hooks
│ ├── stores/ ← UI/client state store(s) — never server data
│ ├── services/ ← data-access (API calls) for this feature
│ ├── utils/ ← pure module utilities (co-located *.test.ts)
│ ├── constants/
│ └── types/ ← module request/response + view-model types
└── shared/ ← cross-module building blocks
├── components/ ← components used by 2+ MODULES
├── hooks/ ← cross-cutting hooks
├── api-client/ ← one typed client; the only place that talks to the network
├── store/ ← root store wiring (if your state lib needs one — see §4)
├── utils/ ← formatters, cn()/clsx, helpers
├── constants/
└── types/
スキャフォールド時に空になる可能性のあるフォルダーには、構成を最初から確認できるよう .gitkeep を置きます。
2. 機能モジュール
モジュールは、プロダクトを垂直に切り分けた単位です(例:auth、billing、dashboard、settings)。その機能に必要なものをすべて含み、公開する範囲は意図的に小さくします。
2.1 バレル(index.ts)が契約です
modules/{feature}/index.ts は、他のモジュールとルーティングレイヤーがインポートできる唯一のものです。次の項目を再エクスポートします。
- ルーターがマウントするページ/画面コンポーネント。
- 他の機能が正当に必要とするデータフック。
- ストアのフック/スライスと、その公開型。
- 他の機能が依存する共有定数/型。
// CORRECT — consume the public surface
import { InvoiceListPage, useInvoiceList } from "@/modules/invoice";
// WRONG — reaching into internals couples you to private structure
import { InvoiceListPage } from "@/modules/invoice/pages/invoice-list/invoice-list";
バレルに含めるものは厳選してください。エクスポートされていないものは、意図的に非公開にされています。エクスポートは短いコメント(ページ、フック、ストア、型)でグループ化してください。将来の読者は、バレルをモジュールの API ドキュメントとして利用します。
2.2 1つのモジュール = 1つの境界づけられたコンテキスト
utils モジュールや components モジュールは作成しないでください。モジュールは技術レイヤーではなく、プロダクトの機能に対応させます。技術的な構成要素は shared/ に置きます。
2.3 モジュールの README
各モジュールの README.md には、そのモジュールが所有するもの、ページを表示するルート、データ依存関係(エンドポイント/フック)、モジュール間のルールを記載します。新しいコントリビューターが最初に読むものです。
3. ディレクトリとしてのページ/画面
ページとは、ルーターがマウントするルート(React Native では「画面」)です。単一のコンポーネントから始める場合でも、必ずフォルダーにします。単独のファイルにはしません。これにより、ページにサブコンポーネントやフックが必要になったときも、その置き場所があらかじめ用意されています。
pages/{page}/
├── {page}.tsx ← the page/screen component
├── {page}.styles.ts ← every style for this page (no inline styles — see §5)
├── index.ts ← export { PageComponent } from "./{page}"
├── components/ ← used ONLY by this page
├── hooks/ ← used ONLY by this page
├── constants/
└── README.md ← route, params, permissions, data deps
ページの README は簡潔で重要な情報に絞ります。ルートパス、想定されるパラメーター、必要な権限/認証、依存するフックを記載します。これはページとアプリの他の部分との契約です。
最初からフォルダーにする理由: 1つのファイルで始めたページには、いずれサブ行コンポーネント、派生合計値のフック、スタイルファイルが追加されます。ページがファイルだと、それらは場当たり的な場所に置かれます。ページがフォルダーなら、置き場所が明確になり、差分も読みやすいままです。
4. 状態:データの出所に応じて分割します(必須、ライブラリ非依存)
状態には2つの種類があり、それぞれに適した場所があります。この2つを混在させることは、このスキルが防ごうとしている最もよくあるアーキテクチャ上の失敗です。分割は必須ですが、使用するライブラリは自由に選べます。
| 状態の種類 | 例 | 格納先 |
|---|---|---|
| サーバー状態 | 取得したエンティティ、リスト、集計値など、API が所有するすべてのデータ | クエリ/キャッシュレイヤー(例:TanStack Query、RTK Query、SWR、Apollo) |
| UI/クライアント状態 | 開いているダイアログ、テーブルのフィルター/並べ替え、ウィザードのステップ、入力中の下書き、プレビューの切り替え | クライアントストア(例:Zustand、Redux Toolkit、MobX、Jotai、Valtio、React Context) |
4.1 厳守事項(ライブラリに依存しません)
- サーバーからのレスポンスをクライアントストアに複製してはいけません。 取得したエンティティを Zustand/Redux/MobX にコピーしないでください。サーバーデータの唯一の信頼できる情報源は、クエリ/キャッシュレイヤーです。
- コンポーネント内でデータを取得してはいけません。 コンポーネントはデータフックからサーバーデータを読み取り、ストアセレクターから UI 状態を読み取ります。ネットワーククライアントを直接呼び出すことはありません。
- 連続的に変化する値を再レンダリングされる状態経由で扱ってはいけません。 スクロールの進行状況、ポインター位置、ドラッグのオフセットには、レンダリング状態ではなく refs/アニメーション値を使います(レンダリング状態を使うと、毎フレームツリーが再レンダリングされます)。
- モジュールごとにストアの境界を1つ設けます。 どのライブラリを使う場合でも、各モジュールにまとまりのあるストア単位を1つ用意します(Zustand フック、Redux スライス、MobX クラス、Jotai アトムのグループなど)。それにはモジュールのバレル経由でアクセスします。不要な再レンダリングを避けるため、コンポーネントは必要最小限のスライスだけを購読します。
4.2 クライアント状態管理ライブラリの選択 — 同じ構成、異なる構文
プロジェクトごとに1つ選び、一貫して使ってください。どれも「モジュールごとにストア単位を1つ」という構成に自然に対応します。I を付けるインターフェースの命名規則に注意してください。状態インターフェースの名前には I を接頭辞として付けます(例:IFeatureUiState)。
Zustand — modules/{feature}/stores/{feature}.store.ts
import { create } from "zustand";
export interface IFeatureUiState {
isPreviewOpen: boolean;
filter: string;
togglePreview: () => void;
setFilter: (filter: string) => void;
reset: () => void;
}
const INITIAL_STATE = { isPreviewOpen: false, filter: "" } as const;
export const useFeatureUiStore = create<IFeatureUiState>()((set) => ({
...INITIAL_STATE,
togglePreview: () => set((s) => ({ isPreviewOpen: !s.isPreviewOpen })),
setFilter: (filter) => set({ filter }),
reset: () => set({ ...INITIAL_STATE }),
}));
Redux Toolkit — modules/{feature}/stores/{feature}.slice.ts(shared/store/ に登録)
import { createSlice, type PayloadAction } from "@reduxjs/toolkit";
export interface IFeatureUiState {
isPreviewOpen: boolean;
filter: string;
}
const initialState: IFeatureUiState = { isPreviewOpen: false, filter: "" };
export const featureUiSlice = createSlice({
name: "featureUi",
initialState,
reducers: {
togglePreview: (s) => {
s.isPreviewOpen = !s.isPreviewOpen;
},
setFilter: (s, action: PayloadAction<string>) => {
s.filter = action.payload;
},
reset: () => initialState,
},
});
MobX — modules/{feature}/stores/{feature}.store.ts
import { makeAutoObservable } from "mobx";
export interface IFeatureUiState {
isPreviewOpen: boolean;
filter: string;
}
export class FeatureUiStore implements IFeatureUiState {
isPreviewOpen = false;
filter = "";
constructor() {
makeAutoObservable(this);
}
togglePreview = () => {
this.isPreviewOpen = !this.isPreviewOpen;
};
setFilter = (filter: string) => {
this.filter = filter;
};
reset = () => {
this.isPreviewOpen = false;
this.filter = "";
};
}
Jotai — modules/{feature}/stores/{feature}.atoms.ts
import { atom } from "jotai";
export const isPreviewOpenAtom = atom(false);
export const filterAtom = atom("");
どれを選んでも、§4.1 のルールは変えないでください。サーバー状態と UI 状態が分離され、各モジュールがストア単位を1つ所有していることが重要です。どのライブラリを使うかは問いません。
4.3 データレイヤー(サーバー状態)
すべてのネットワークアクセスは、shared/api-client/ にある型付きクライアント1つを経由します。各モジュールではこれをクエリ/ミューテーションフックとキーのファクトリーでラップし、キャッシュや無効化の一貫性を保ちます。
// modules/invoice/hooks/invoiceKeys.ts — hierarchical key factory (TanStack Query style)
export const invoiceKeys = {
all: ["invoices"] as const,
lists: () => [...invoiceKeys.all, "list"] as const,
list: (params: IListParams) => [...invoiceKeys.lists(), params] as const,
details: () => [...invoiceKeys.all, "detail"] as const,
detail: (id: string) => [...invoiceKeys.details(), id] as const,
} as const;
lists() を無効化すると、フィルター適用済みのすべてのページが更新されます。detail(id) は1つのエンティティを対象にします。(RTK Query/SWR/Apollo では、タグ/キーを使って同じ考え方を表現します。)コンポーネントで生の fetch() を呼び出してはいけません。useInvoiceList()/useCreateInvoice() を呼び出します。
5. スタイリング: コロケーション、インラインスタイルなし(スタイリングライブラリ非依存)
スタイリングは JSX の外、コンポーネント本体の外に保ちます。各ページまたはコンポーネントはコロケーションされたスタイルファイルを持ちます。ルールは一定で、構文は使用するスタイリングスタックに従います。
- Tailwind(Web):
{name}.styles.tsがcn()(clsx + tailwind-merge)で合成された名前付きクラス文字列をエクスポートします。バリアントはcvaを使用します。JSX はstyles.headerを参照します。 - CSS Modules / vanilla-extract: コロケーションされた
{name}.module.css/{name}.css.ts。JSX はstyles.headerを参照します。 - styled-components / Emotion: コロケーションされた
{name}.styles.tsが styled コンポーネントをエクスポートします。 - Tamagui(Web + ネイティブ): コロケーションされた
{name}.styles.tsがstyled(...)コンポーネント、またはcreateStyledContext/useStyleトークンセットをエクスポートします。Tamagui トークン($background、$space.4)を参照し、インラインでハードコードされた値は決して使用しません。Tamagui は、1 つのコードからWeb と React Native の両方をターゲットにする場合に推奨される選択肢です。 - React Native StyleSheet / Nativewind: コロケーションされた
{name}.styles.tsがStyleSheet.create({...})(または Nativewind クラス名)をエクスポートします。JSX はstyles.headerを参照します。
// invoice-list.styles.ts (Tailwind example)
export const invoiceListStyles = {
page: "flex flex-col gap-8",
header: "flex flex-col gap-1.5",
title: "text-3xl font-semibold tracking-tight",
} as const;
// invoice-list.styles.ts (Tamagui example — works on web AND native)
import { styled, YStack, Text } from "tamagui";
export const InvoiceListPage = styled(YStack, { flex: 1, gap: "$8" });
export const InvoiceListHeader = styled(YStack, { gap: "$1.5" });
export const InvoiceListTitle = styled(Text, {
fontSize: "$8",
fontWeight: "600",
});
コンポーネント本体にインラインの style={{...}} リテラルを書かないこと。どのスタックでも同様です。理由: スタイリングがインラインに存在すると、ドリフトや重複が発生します。コロケーションされたスタイルファイルは、サーフェスごとのスペーシングリズム、テーマの正確性、レスポンシブ動作を確認するための 1 つの場所を提供します。自明でない選択(アクセントの固定、ブレークポイント)は、そこにコメントとして記述してください。
このスキルは視覚的なデザインを規定するものではありません。そのためにはデザイン/コンポーネントのスキルと組み合わせてください。このスキルが規定するのは、スタイリングをどこに置くかだけです。
6. 命名規則
一貫した命名により、構造が自己記述的になります。
- インターフェースには
Iを付けます —IFeatureUiState、IInvoiceListParams、IUserProfile。型エイリアス(ユニオン、マップ型、プリミティブ)には付けません(type SortDirection = "asc" | "desc")。 - コンポーネント:
PascalCaseのファイルとエクスポート —InvoiceListPage.tsx、LineItemRow.tsx。 - ページ/画面:
kebab-caseのディレクトリ、コンポーネントファイルも一致 —pages/invoice-list/invoice-list.tsx。 - フック:
useCamelCase—useInvoiceList、useFeatureUiStore。 - ストア:
{feature}.store.ts(Zustand/MobX)、{feature}.slice.ts(Redux)、{feature}.atoms.ts(Jotai)。フックはuse{Feature}{Purpose}Store。 - スタイル:
{name}.styles.tsをその所有者とコロケーションします。 - 定数:
SCREAMING_SNAKE_CASEの値、kebab-caseまたはcamelCaseのファイル。 - バレル: 常に
index.ts。
7. フレームワークアダプター
モジュール/ページ/状態のモデルは一定です。変わるのは、その上に載る薄いルーティング層だけです。ページは常に modules/ に配置され、ルーティング層はそれらをマウントするだけです。
7.1 Next.js(App Router)
src/app/はルートセグメントとルートグループ((marketing)、(app)、(public))をレイアウト/認証の境界として保持します。ルートファイルは薄く、モジュールのバレルからページコンポーネントをインポートしてレンダリングするだけです。- デフォルトはサーバーコンポーネントとし、インタラクティブな末端は
"use client"にします。プロバイダー(クエリクライアント、ストア、テーマ)は"use client"の境界に配置します。
// app/(app)/invoices/page.tsx — thin route file
import { InvoiceListPage } from "@/modules/invoice";
export default function Page() {
return <InvoiceListPage />;
}
7.2 React + Vite(SPA)
src/routes/(または単一のrouter.tsx)がルートテーブル(React Router / TanStack Router)を宣言し、パスをモジュールのページコンポーネントにマッピングします。すべてクライアントサイドです。アプリのルートで、ツリーをクエリクライアントとストア/テーマのプロバイダーで一度ラップします。
7.3 Remix
app/routes/のルートモジュールは薄く保ち、モジュールのページコンポーネントを再エクスポート/マウントします。ローダー/アクションはモジュールのservices/に委譲します。モジュールの境界は変わりません。
7.4 Expo / React Native
- ルーティングは Expo Router(ファイルベース、
app/内)または React Navigation(navigation/)です。ルート/画面ファイルは薄く保ち、モジュールのバレルから画面コンポーネントをインポートします。 - 「ページ」は「スクリーン」です — 同じディレクトリパターン:
pages/{screen}/{screen}.tsx+{screen}.styles.ts。 - クエリ層 + クライアントストアはそのまま動作します(TanStack Query、Zustand、Redux、MobX、Jotai はすべて RN で動作します)。型付きの
api-clientは共有ロジックであり、そのまま動作します。 - スタイリングには Tamagui(web + native の共有に推奨)、
StyleSheet、または Nativewind を使用します。モジュールのロジックは DOM に依存させないでください。
// app/invoices/index.tsx (Expo Router) — thin screen file
import { InvoiceListScreen } from "@/modules/invoice";
export default InvoiceListScreen;
7.5 web + native 間での共有
web と Expo の両方をターゲットにする場合、フレームワークに依存しないコード(型、バリデータ、フォーマッタ、API クライアントのコントラクト)を、両方のアプリが利用する共有パッケージに集約し、両方でレンダリングする必要があるコンポーネントには Tamagui を優先してください。モジュールの境界は両側で同じに保ちます。
8. 規約チェックリスト(レビューで徹底する)
- 新機能 →
shared/にファイルを散らかすのではなく、index.ts+README.mdを備えた新しいmodules/{feature}/を作成する。 - 新ルート → 緩いファイルではなく、ページ/スクリーンディレクトリ(
{page}.tsx+{page}.styles.ts+index.ts+README.md)にする。 - モジュール間のインポートはバレル(
@/modules/{feature})を経由する — 内部の深いパスは使わない。 - サーバーデータはクエリ/キャッシュ層に、UI 状態はモジュールストアに置く。どちらも他方に漏れない(どのライブラリを選んでも)。
- コンポーネント内で
fetch()を呼ばない — 共有クライアント上に構築された型付きデータフックのみを使う。 - インラインスタイルは使わない — 同じ場所に
{name}.styles.tsを置く(Tailwind/CSS Modules/Tamagui/StyleSheet/styled-components)。 - コンポーネント/フック/ユーティリティは最も狭いスコープに置く。2 つ目の利用者が現れたときにのみ昇格させる。
- モジュールごとに 1 つのストアユニットとし、バレル経由でアクセスし、セレクタと
resetを備える。 - インターフェースは
Iプレフィックスを使用する。コンポーネント/フック/ファイルは §6 に従う。 - クエリキー/タグはモジュールごとのファクトリから取得する。無効化は階層的に行う。
- ルーティングファイルは薄く保つ — モジュールのページをマウントし、レイアウト/認証の境界のみを担う。
- ルート、パラメータ、データ依存関係が変わったら、モジュール/ページの README を更新する。
9. コンポーネントの昇格(ローカルから始めて外へ移動する)
コンポーネントは、それを利用する最も狭いスコープで生まれ、2 つ目の利用者が現れたときにのみ昇格されます。「再利用されるかもしれないから」という理由でコンポーネントをあらかじめ配置してはいけません。
| コンポーネントの利用者… | 配置場所 | インポート方法 |
|---|---|---|
| 1 つのページのみ | pages/{page}/components/ | ページ内の相対パス |
| 1 つのモジュール内の 2+ ページ | modules/{feature}/components/ | @/modules/{feature}(バレル経由) |
| 2+ モジュール | shared/components/ | @/shared/... |
| 2+ アプリ / リポジトリ | 公開されたデザインシステムパッケージ | パッケージ名 |
同じ段階はフック、ユーティリティ、定数にも適用されます: ローカル → モジュール → 共有 → パッケージ。昇格は(インポート箇所を更新する)意図的な移動であり、事前に決める推測ではありません。
10. このスキルの適用方法
新しいアプリの足場を作る: src/modules/、src/shared/、およびフレームワークのルーティングレイヤー(§7)を作成します。共有の api-client、クエリレイヤー、選択したクライアントストアプロバイダーを追加します。最初のモジュールにストアのテンプレートを配置します。
機能を追加する: modules/{feature}/ を作成し、サブフォルダー一式(pages/ components/ hooks/ stores/ services/ utils/ constants/ types/)、厳選した index.ts、および README.md を用意します。最初の画面はページディレクトリとして作成します。
コードの配置先を決める: 「これは誰が使うのか?」と問い、最も狭いスコープを選びます(§9)。「このデータはどこから来たのか?」と問い、サーバー由来ならクエリレイヤー、UI 由来ならストアに配置します(§4)。
構造をレビューする: §8 のチェックリストを実行します。特に重要なのは、状態の出どころの漏れ(クライアントストア内のサーバーデータ)と、モジュール間の深いインポート(バレルを迂回するインポート)です。どちらもアーキテクチャを急速に損ないます。
このスキルの公開/インストール
このスキルは Anthropic の SKILL.md 形式に準拠しており、さまざまなエージェントで利用できます。インストール可能にし、見つけやすくするには(例:skills.sh/npx skills)、次の手順に従います。
- このフォルダーを
skills/ディレクトリの下にある公開 GitHub リポジトリに置きます(パスの例:skills/frontend-architecture/SKILL.md)。 - フロントマターの
nameと、要点を明確に示すdescription(上記)を維持します。この説明が検索インデックスの照合に使われます。 - 任意のプロジェクトから次のコマンドでインストールします:
npx skills add <org>/<repo> --skill "frontend-architecture"。 SKILL.md以外のエージェントからは、AGENTS.md/CLAUDE.mdを使ってこのスキルを参照できます。Kiro では、これをステアリングファイルとして複製できます。