@einja/dev-cli 0.1.6
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +179 -0
- package/dist/cli.d.ts +2 -0
- package/dist/cli.d.ts.map +1 -0
- package/dist/cli.js +49 -0
- package/dist/cli.js.map +1 -0
- package/dist/commands/init.d.ts +3 -0
- package/dist/commands/init.d.ts.map +1 -0
- package/dist/commands/init.js +243 -0
- package/dist/commands/init.js.map +1 -0
- package/dist/commands/list.d.ts +2 -0
- package/dist/commands/list.d.ts.map +1 -0
- package/dist/commands/list.js +23 -0
- package/dist/commands/list.js.map +1 -0
- package/dist/commands/sync.d.ts +7 -0
- package/dist/commands/sync.d.ts.map +1 -0
- package/dist/commands/sync.js +294 -0
- package/dist/commands/sync.js.map +1 -0
- package/dist/commands/sync.test.d.ts +2 -0
- package/dist/commands/sync.test.d.ts.map +1 -0
- package/dist/commands/sync.test.js +593 -0
- package/dist/commands/sync.test.js.map +1 -0
- package/dist/commands/task-loop.d.ts +11 -0
- package/dist/commands/task-loop.d.ts.map +1 -0
- package/dist/commands/task-loop.js +81 -0
- package/dist/commands/task-loop.js.map +1 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +3 -0
- package/dist/index.js.map +1 -0
- package/dist/lib/file-system.d.ts +39 -0
- package/dist/lib/file-system.d.ts.map +1 -0
- package/dist/lib/file-system.js +79 -0
- package/dist/lib/file-system.js.map +1 -0
- package/dist/lib/mcp-config.d.ts +43 -0
- package/dist/lib/mcp-config.d.ts.map +1 -0
- package/dist/lib/mcp-config.js +109 -0
- package/dist/lib/mcp-config.js.map +1 -0
- package/dist/lib/mcp-config.test.d.ts +2 -0
- package/dist/lib/mcp-config.test.d.ts.map +1 -0
- package/dist/lib/mcp-config.test.js +285 -0
- package/dist/lib/mcp-config.test.js.map +1 -0
- package/dist/lib/merger.d.ts +41 -0
- package/dist/lib/merger.d.ts.map +1 -0
- package/dist/lib/merger.js +164 -0
- package/dist/lib/merger.js.map +1 -0
- package/dist/lib/preset-update/cli-repo-detector.d.ts +35 -0
- package/dist/lib/preset-update/cli-repo-detector.d.ts.map +1 -0
- package/dist/lib/preset-update/cli-repo-detector.js +83 -0
- package/dist/lib/preset-update/cli-repo-detector.js.map +1 -0
- package/dist/lib/preset-update/cli-repo-detector.test.d.ts +2 -0
- package/dist/lib/preset-update/cli-repo-detector.test.d.ts.map +1 -0
- package/dist/lib/preset-update/cli-repo-detector.test.js +120 -0
- package/dist/lib/preset-update/cli-repo-detector.test.js.map +1 -0
- package/dist/lib/preset-update/file-copier.d.ts +59 -0
- package/dist/lib/preset-update/file-copier.d.ts.map +1 -0
- package/dist/lib/preset-update/file-copier.js +220 -0
- package/dist/lib/preset-update/file-copier.js.map +1 -0
- package/dist/lib/preset-update/file-copier.test.d.ts +2 -0
- package/dist/lib/preset-update/file-copier.test.d.ts.map +1 -0
- package/dist/lib/preset-update/file-copier.test.js +297 -0
- package/dist/lib/preset-update/file-copier.test.js.map +1 -0
- package/dist/lib/preset-update/preset-finder.d.ts +39 -0
- package/dist/lib/preset-update/preset-finder.d.ts.map +1 -0
- package/dist/lib/preset-update/preset-finder.js +92 -0
- package/dist/lib/preset-update/preset-finder.js.map +1 -0
- package/dist/lib/preset-update/preset-finder.test.d.ts +2 -0
- package/dist/lib/preset-update/preset-finder.test.d.ts.map +1 -0
- package/dist/lib/preset-update/preset-finder.test.js +128 -0
- package/dist/lib/preset-update/preset-finder.test.js.map +1 -0
- package/dist/lib/preset.d.ts +14 -0
- package/dist/lib/preset.d.ts.map +1 -0
- package/dist/lib/preset.js +52 -0
- package/dist/lib/preset.js.map +1 -0
- package/dist/lib/sync/backup-manager.d.ts +50 -0
- package/dist/lib/sync/backup-manager.d.ts.map +1 -0
- package/dist/lib/sync/backup-manager.js +117 -0
- package/dist/lib/sync/backup-manager.js.map +1 -0
- package/dist/lib/sync/backup-manager.test.d.ts +2 -0
- package/dist/lib/sync/backup-manager.test.d.ts.map +1 -0
- package/dist/lib/sync/backup-manager.test.js +155 -0
- package/dist/lib/sync/backup-manager.test.js.map +1 -0
- package/dist/lib/sync/batch-processor.d.ts +27 -0
- package/dist/lib/sync/batch-processor.d.ts.map +1 -0
- package/dist/lib/sync/batch-processor.js +46 -0
- package/dist/lib/sync/batch-processor.js.map +1 -0
- package/dist/lib/sync/batch-processor.test.d.ts +2 -0
- package/dist/lib/sync/batch-processor.test.d.ts.map +1 -0
- package/dist/lib/sync/batch-processor.test.js +110 -0
- package/dist/lib/sync/batch-processor.test.js.map +1 -0
- package/dist/lib/sync/category-validator.d.ts +36 -0
- package/dist/lib/sync/category-validator.d.ts.map +1 -0
- package/dist/lib/sync/category-validator.js +46 -0
- package/dist/lib/sync/category-validator.js.map +1 -0
- package/dist/lib/sync/category-validator.test.d.ts +2 -0
- package/dist/lib/sync/category-validator.test.d.ts.map +1 -0
- package/dist/lib/sync/category-validator.test.js +89 -0
- package/dist/lib/sync/category-validator.test.js.map +1 -0
- package/dist/lib/sync/conflict-reporter.d.ts +57 -0
- package/dist/lib/sync/conflict-reporter.d.ts.map +1 -0
- package/dist/lib/sync/conflict-reporter.js +81 -0
- package/dist/lib/sync/conflict-reporter.js.map +1 -0
- package/dist/lib/sync/conflict-reporter.test.d.ts +2 -0
- package/dist/lib/sync/conflict-reporter.test.d.ts.map +1 -0
- package/dist/lib/sync/conflict-reporter.test.js +132 -0
- package/dist/lib/sync/conflict-reporter.test.js.map +1 -0
- package/dist/lib/sync/diff-engine.d.ts +28 -0
- package/dist/lib/sync/diff-engine.d.ts.map +1 -0
- package/dist/lib/sync/diff-engine.js +118 -0
- package/dist/lib/sync/diff-engine.js.map +1 -0
- package/dist/lib/sync/diff-engine.test.d.ts +2 -0
- package/dist/lib/sync/diff-engine.test.d.ts.map +1 -0
- package/dist/lib/sync/diff-engine.test.js +133 -0
- package/dist/lib/sync/diff-engine.test.js.map +1 -0
- package/dist/lib/sync/file-filter.d.ts +40 -0
- package/dist/lib/sync/file-filter.d.ts.map +1 -0
- package/dist/lib/sync/file-filter.js +171 -0
- package/dist/lib/sync/file-filter.js.map +1 -0
- package/dist/lib/sync/file-filter.test.d.ts +2 -0
- package/dist/lib/sync/file-filter.test.d.ts.map +1 -0
- package/dist/lib/sync/file-filter.test.js +179 -0
- package/dist/lib/sync/file-filter.test.js.map +1 -0
- package/dist/lib/sync/hash-cache.d.ts +34 -0
- package/dist/lib/sync/hash-cache.d.ts.map +1 -0
- package/dist/lib/sync/hash-cache.js +51 -0
- package/dist/lib/sync/hash-cache.js.map +1 -0
- package/dist/lib/sync/hash-cache.test.d.ts +2 -0
- package/dist/lib/sync/hash-cache.test.d.ts.map +1 -0
- package/dist/lib/sync/hash-cache.test.js +110 -0
- package/dist/lib/sync/hash-cache.test.js.map +1 -0
- package/dist/lib/sync/integration.test.d.ts +2 -0
- package/dist/lib/sync/integration.test.d.ts.map +1 -0
- package/dist/lib/sync/integration.test.js +317 -0
- package/dist/lib/sync/integration.test.js.map +1 -0
- package/dist/lib/sync/marker-processor.d.ts +54 -0
- package/dist/lib/sync/marker-processor.d.ts.map +1 -0
- package/dist/lib/sync/marker-processor.js +208 -0
- package/dist/lib/sync/marker-processor.js.map +1 -0
- package/dist/lib/sync/marker-processor.test.d.ts +2 -0
- package/dist/lib/sync/marker-processor.test.d.ts.map +1 -0
- package/dist/lib/sync/marker-processor.test.js +245 -0
- package/dist/lib/sync/marker-processor.test.js.map +1 -0
- package/dist/lib/sync/metadata-manager.d.ts +46 -0
- package/dist/lib/sync/metadata-manager.d.ts.map +1 -0
- package/dist/lib/sync/metadata-manager.js +129 -0
- package/dist/lib/sync/metadata-manager.js.map +1 -0
- package/dist/lib/sync/metadata-manager.test.d.ts +2 -0
- package/dist/lib/sync/metadata-manager.test.d.ts.map +1 -0
- package/dist/lib/sync/metadata-manager.test.js +137 -0
- package/dist/lib/sync/metadata-manager.test.js.map +1 -0
- package/dist/lib/sync/performance.test.d.ts +2 -0
- package/dist/lib/sync/performance.test.d.ts.map +1 -0
- package/dist/lib/sync/performance.test.js +126 -0
- package/dist/lib/sync/performance.test.js.map +1 -0
- package/dist/types/index.d.ts +59 -0
- package/dist/types/index.d.ts.map +1 -0
- package/dist/types/index.js +2 -0
- package/dist/types/index.js.map +1 -0
- package/dist/types/preset-update.d.ts +106 -0
- package/dist/types/preset-update.d.ts.map +1 -0
- package/dist/types/preset-update.js +5 -0
- package/dist/types/preset-update.js.map +1 -0
- package/dist/types/sync.d.ts +169 -0
- package/dist/types/sync.d.ts.map +1 -0
- package/dist/types/sync.js +19 -0
- package/dist/types/sync.js.map +1 -0
- package/package.json +72 -0
- package/presets/minimal/.claude/agents/einja/docs/docs-updater.md +161 -0
- package/presets/minimal/.claude/agents/einja/frontend/design-engineer.md +685 -0
- package/presets/minimal/.claude/agents/einja/frontend/frontend-architect.md +747 -0
- package/presets/minimal/.claude/agents/einja/frontend/frontend-coder.md +441 -0
- package/presets/minimal/.claude/agents/einja/git/conflict-resolver.md +148 -0
- package/presets/minimal/.claude/agents/einja/specs/spec-design-generator.md +462 -0
- package/presets/minimal/.claude/agents/einja/specs/spec-qa-generator.md +466 -0
- package/presets/minimal/.claude/agents/einja/specs/spec-requirements-generator.md +416 -0
- package/presets/minimal/.claude/agents/einja/specs/spec-tasks-generator.md +608 -0
- package/presets/minimal/.claude/agents/einja/task/task-committer.md +82 -0
- package/presets/minimal/.claude/agents/einja/task/task-executer.md +352 -0
- package/presets/minimal/.claude/agents/einja/task/task-modification-analyzer.md +369 -0
- package/presets/minimal/.claude/agents/einja/task/task-qa.md +74 -0
- package/presets/minimal/.claude/agents/einja/task/task-reviewer.md +169 -0
- package/presets/minimal/.claude/commands/einja/frontend-implement.md +322 -0
- package/presets/minimal/.claude/commands/einja/spec-create.md +254 -0
- package/presets/minimal/.claude/commands/einja/start-dev.md +98 -0
- package/presets/minimal/.claude/commands/einja/sync-cursor-commands.md +203 -0
- package/presets/minimal/.claude/commands/einja/task-exec.md +390 -0
- package/presets/minimal/.claude/commands/einja/update-docs-by-task-specs.md +448 -0
- package/presets/minimal/.claude/hooks/einja/biome-format.sh +49 -0
- package/presets/minimal/.claude/hooks/einja/design-doc-check.sh +61 -0
- package/presets/minimal/.claude/hooks/einja/detect-secrets.sh +62 -0
- package/presets/minimal/.claude/hooks/einja/large-file-warning.sh +42 -0
- package/presets/minimal/.claude/hooks/einja/playwright-resize.sh +36 -0
- package/presets/minimal/.claude/hooks/einja/typecheck.sh +37 -0
- package/presets/minimal/.claude/hooks/einja/unset-volta-recursion.sh +32 -0
- package/presets/minimal/.claude/hooks/einja/validate-git-commit.sh +239 -0
- package/presets/minimal/.claude/hooks/einja/warn-index-ts.sh +34 -0
- package/presets/minimal/.claude/hooks/einja/warn-relative-import.sh +48 -0
- package/presets/minimal/.claude/settings.json +174 -0
- package/presets/minimal/.claude/skills/einja/api-development/SKILL.md +14 -0
- package/presets/minimal/.claude/skills/einja/backend-architecture/SKILL.md +14 -0
- package/presets/minimal/.claude/skills/einja/coding-standards/SKILL.md +120 -0
- package/presets/minimal/.claude/skills/einja/coding-standards/reference/naming-conventions.md +107 -0
- package/presets/minimal/.claude/skills/einja/coding-standards/reference/prohibited-patterns.md +169 -0
- package/presets/minimal/.claude/skills/einja/coding-standards/reference/typescript-rules.md +247 -0
- package/presets/minimal/.claude/skills/einja/component-design/SKILL.md +109 -0
- package/presets/minimal/.claude/skills/einja/component-design/reference/directory-structure.md +117 -0
- package/presets/minimal/.claude/skills/einja/component-design/reference/props-patterns.md +159 -0
- package/presets/minimal/.claude/skills/einja/component-design/reference/styling-guide.md +200 -0
- package/presets/minimal/.claude/skills/einja/conflict-resolver/SKILL.md +190 -0
- package/presets/minimal/.claude/skills/einja/frontend-development/SKILL.md +14 -0
- package/presets/minimal/.claude/skills/einja/general-context-loader/SKILL.md +254 -0
- package/presets/minimal/.claude/skills/einja/output-format/SKILL.md +137 -0
- package/presets/minimal/.claude/skills/einja/spec-context-loader/SKILL.md +177 -0
- package/presets/minimal/.claude/skills/einja/task-commit/SKILL.md +269 -0
- package/presets/minimal/.claude/skills/einja/task-qa/SKILL.md +306 -0
- package/presets/minimal/.claude/skills/einja/task-qa/reference/failure-patterns.md +69 -0
- package/presets/minimal/.claude/skills/einja/task-qa/reference/troubleshooting.md +65 -0
- package/presets/minimal/.claude/skills/einja/task-qa/reference/usage-patterns.md +52 -0
- package/presets/minimal/.claude/skills/einja/task-qa/templates/qa-test-template.md +128 -0
- package/presets/minimal/preset.yaml +111 -0
- package/presets/minimal/symlinks.json +45 -0
- package/scaffolds/.mcp.json +45 -0
- package/scaffolds/CLAUDE.md.template +318 -0
- package/scaffolds/steering/README.md +170 -0
- package/scaffolds/steering/acceptance-criteria-and-qa-guide.md +415 -0
- package/scaffolds/steering/architecture.md +481 -0
- package/scaffolds/steering/branch-strategy.md +362 -0
- package/scaffolds/steering/commit-rules.md +217 -0
- package/scaffolds/steering/db-schema-design.md +609 -0
- package/scaffolds/steering/development/api-development.md +783 -0
- package/scaffolds/steering/development/backend-architecture.md +731 -0
- package/scaffolds/steering/development/frontend-development.md +1537 -0
- package/scaffolds/steering/development/review-guidelines.md +365 -0
- package/scaffolds/steering/development/testing-strategy.md +819 -0
- package/scaffolds/steering/development-workflow.md +429 -0
- package/scaffolds/steering/infrastructure/deployment.md +277 -0
- package/scaffolds/steering/infrastructure/environment-variables.md +298 -0
- package/scaffolds/steering/product.md +540 -0
- package/scaffolds/steering/task-management.md +367 -0
- package/templates/README.md +159 -0
- package/templates/design-simple.md.template +172 -0
- package/templates/design.md.template +327 -0
- package/templates/qa-test.md.template +125 -0
- package/templates/requirements.md.template +254 -0
|
@@ -0,0 +1,1537 @@
|
|
|
1
|
+
# フロントエンド開発ガイド
|
|
2
|
+
|
|
3
|
+
## 概要
|
|
4
|
+
|
|
5
|
+
このドキュメントでは、Next.js 14 App Routerを使用したフロントエンド開発のベストプラクティスと実装ガイドラインを説明します。
|
|
6
|
+
|
|
7
|
+
Tanstack Query、React Hook Form、Hono Clientを活用した型安全で保守性の高いフロントエンド開発を実現します。
|
|
8
|
+
|
|
9
|
+
### 関連ドキュメント
|
|
10
|
+
|
|
11
|
+
- **[API開発ガイド](api-development.md)** - Hono API実装、Server Actionsとの使い分け
|
|
12
|
+
- **[バックエンドアーキテクチャ](backend-architecture.md)** - 4層アーキテクチャ、Repository/Result型
|
|
13
|
+
|
|
14
|
+
> **📌 Server Actions vs Hono Client + Tanstack Query の使い分け**
|
|
15
|
+
>
|
|
16
|
+
> フロントエンドからAPIを呼び出す方法は2パターンあります。使い分けの基準は **[API開発ガイド セクション7](api-development.md#7-フロントエンド統合パターン)** を参照してください。
|
|
17
|
+
>
|
|
18
|
+
> - **Server Actions**: シンプルなフォーム送信、単発のミューテーション
|
|
19
|
+
> - **Hono Client + Tanstack Query**: 複雑なデータフェッチ、キャッシュ管理、リアルタイム更新(本ドキュメントで解説)
|
|
20
|
+
|
|
21
|
+
---
|
|
22
|
+
|
|
23
|
+
## 目次
|
|
24
|
+
|
|
25
|
+
1. [ディレクトリ構造](#1-ディレクトリ構造)
|
|
26
|
+
2. [技術スタック](#2-技術スタック)
|
|
27
|
+
3. [Hono Client統合(API呼び出し)](#3-hono-client統合api呼び出し)
|
|
28
|
+
4. [Server ComponentとClient Component](#4-server-componentとclient-component)
|
|
29
|
+
5. [Tanstack Query(サーバー状態管理)](#5-tanstack-queryサーバー状態管理)
|
|
30
|
+
6. [React Hook Form(フォーム処理)](#6-react-hook-formフォーム処理)
|
|
31
|
+
7. [コンポーネント設計](#7-コンポーネント設計)
|
|
32
|
+
8. [App Router構成](#8-app-router構成)
|
|
33
|
+
9. [状態管理戦略](#9-状態管理戦略)
|
|
34
|
+
10. [エラーハンドリング](#10-エラーハンドリング)
|
|
35
|
+
11. [実装例](#11-実装例)
|
|
36
|
+
|
|
37
|
+
---
|
|
38
|
+
|
|
39
|
+
## 1. ディレクトリ構造
|
|
40
|
+
|
|
41
|
+
### Webアプリケーション(apps/web)
|
|
42
|
+
|
|
43
|
+
```
|
|
44
|
+
apps/web/
|
|
45
|
+
├── app/ # Next.js App Router
|
|
46
|
+
│ ├── (auth)/ # ルートグループ: 認証関連
|
|
47
|
+
│ │ ├── login/
|
|
48
|
+
│ │ │ └── page.tsx
|
|
49
|
+
│ │ └── register/
|
|
50
|
+
│ │ └── page.tsx
|
|
51
|
+
│ ├── (dashboard)/ # ルートグループ: ダッシュボード
|
|
52
|
+
│ │ ├── posts/
|
|
53
|
+
│ │ │ ├── page.tsx # 投稿一覧
|
|
54
|
+
│ │ │ ├── new/
|
|
55
|
+
│ │ │ │ └── page.tsx # 投稿作成
|
|
56
|
+
│ │ │ └── [id]/
|
|
57
|
+
│ │ │ └── page.tsx # 投稿詳細
|
|
58
|
+
│ │ └── profile/
|
|
59
|
+
│ │ └── page.tsx
|
|
60
|
+
│ ├── api/ # API Routes
|
|
61
|
+
│ │ └── [[...route]]/
|
|
62
|
+
│ │ └── route.ts # Honoエントリーポイント
|
|
63
|
+
│ ├── layout.tsx # ルートレイアウト
|
|
64
|
+
│ └── page.tsx # トップページ
|
|
65
|
+
├── components/ # UIコンポーネント
|
|
66
|
+
│ ├── ui/ # 基本コンポーネント
|
|
67
|
+
│ │ ├── button.tsx
|
|
68
|
+
│ │ ├── card.tsx
|
|
69
|
+
│ │ ├── input.tsx
|
|
70
|
+
│ │ ├── modal.tsx
|
|
71
|
+
│ │ └── ...
|
|
72
|
+
│ └── features/ # 機能別コンポーネント
|
|
73
|
+
│ ├── posts/
|
|
74
|
+
│ │ ├── PostList.tsx
|
|
75
|
+
│ │ ├── PostCard.tsx
|
|
76
|
+
│ │ ├── PostCreateForm.tsx
|
|
77
|
+
│ │ └── PostDetail.tsx
|
|
78
|
+
│ └── auth/
|
|
79
|
+
│ ├── LoginForm.tsx
|
|
80
|
+
│ └── RegisterForm.tsx
|
|
81
|
+
├── lib/ # ユーティリティ
|
|
82
|
+
│ ├── api-client.ts # Hono Client設定
|
|
83
|
+
│ ├── query-client.ts # Tanstack Query設定
|
|
84
|
+
│ └── utils.ts # 共通ユーティリティ
|
|
85
|
+
├── hooks/ # カスタムフック
|
|
86
|
+
│ ├── use-posts.ts # Postデータフック
|
|
87
|
+
│ └── use-auth.ts # 認証フック
|
|
88
|
+
├── public/ # 静的ファイル
|
|
89
|
+
├── styles/ # グローバルスタイル
|
|
90
|
+
│ └── globals.css
|
|
91
|
+
├── next.config.js
|
|
92
|
+
├── package.json
|
|
93
|
+
├── tsconfig.json
|
|
94
|
+
└── tailwind.config.ts
|
|
95
|
+
```
|
|
96
|
+
|
|
97
|
+
### 管理画面(apps/admin)
|
|
98
|
+
|
|
99
|
+
```
|
|
100
|
+
apps/admin/
|
|
101
|
+
├── app/
|
|
102
|
+
│ ├── (protected)/ # ルートグループ: 認証必須
|
|
103
|
+
│ │ ├── admin/
|
|
104
|
+
│ │ │ ├── users/
|
|
105
|
+
│ │ │ │ └── page.tsx
|
|
106
|
+
│ │ │ ├── posts/
|
|
107
|
+
│ │ │ │ └── page.tsx
|
|
108
|
+
│ │ │ └── analytics/
|
|
109
|
+
│ │ │ └── page.tsx
|
|
110
|
+
│ ├── api/
|
|
111
|
+
│ │ └── [[...route]]/
|
|
112
|
+
│ │ └── route.ts
|
|
113
|
+
│ ├── layout.tsx
|
|
114
|
+
│ └── page.tsx
|
|
115
|
+
├── components/
|
|
116
|
+
│ ├── ui/
|
|
117
|
+
│ └── features/
|
|
118
|
+
│ └── admin/
|
|
119
|
+
│ ├── UserTable.tsx
|
|
120
|
+
│ └── PostStatusManager.tsx
|
|
121
|
+
├── lib/
|
|
122
|
+
├── hooks/
|
|
123
|
+
└── ...
|
|
124
|
+
```
|
|
125
|
+
|
|
126
|
+
**設計ポイント**:
|
|
127
|
+
- **ルートグループ**: `(auth)`, `(dashboard)`, `(protected)` でルートを論理的にグループ化
|
|
128
|
+
- **コロケーション**: 機能別にコンポーネント、フック、ユーティリティを配置
|
|
129
|
+
- **共通コンポーネント**: ui/に再利用可能な基本コンポーネント、features/に機能別コンポーネント
|
|
130
|
+
|
|
131
|
+
---
|
|
132
|
+
|
|
133
|
+
## 2. 技術スタック
|
|
134
|
+
|
|
135
|
+
| カテゴリ | ライブラリ | バージョン | 用途 |
|
|
136
|
+
|---------|-----------|----------|------|
|
|
137
|
+
| フレームワーク | Next.js | 14.x | App Router、SSR/SSG |
|
|
138
|
+
| UI | React | 18.x | コンポーネントライブラリ |
|
|
139
|
+
| 状態管理 | Tanstack Query | 5.x | サーバー状態管理 |
|
|
140
|
+
| フォーム | React Hook Form | 7.x | フォーム処理 |
|
|
141
|
+
| バリデーション | Zod | 3.x | スキーマバリデーション |
|
|
142
|
+
| API Client | Hono Client | 4.x | 型安全なAPI呼び出し |
|
|
143
|
+
| スタイリング | Tailwind CSS | 3.x | ユーティリティファースト |
|
|
144
|
+
| 型チェック | TypeScript | 5.x | 静的型付け |
|
|
145
|
+
|
|
146
|
+
---
|
|
147
|
+
|
|
148
|
+
## 3. Hono Client統合(API呼び出し)
|
|
149
|
+
|
|
150
|
+
### セットアップ
|
|
151
|
+
|
|
152
|
+
**Hono Clientの初期化**:
|
|
153
|
+
|
|
154
|
+
```typescript
|
|
155
|
+
// apps/web/lib/api-client.ts
|
|
156
|
+
import { hc } from 'hono/client'
|
|
157
|
+
import type { AppType } from '@/app/api/[[...route]]/route'
|
|
158
|
+
|
|
159
|
+
export const apiClient = hc<AppType>('/api')
|
|
160
|
+
```
|
|
161
|
+
|
|
162
|
+
**型定義のエクスポート**:
|
|
163
|
+
|
|
164
|
+
```typescript
|
|
165
|
+
// apps/web/app/api/[[...route]]/route.ts
|
|
166
|
+
import { Hono } from 'hono'
|
|
167
|
+
import { handle } from 'hono/vercel'
|
|
168
|
+
import postRoutes from '@/server/routes/postRoutes'
|
|
169
|
+
|
|
170
|
+
const app = new Hono().basePath('/api')
|
|
171
|
+
|
|
172
|
+
app.route('/', postRoutes)
|
|
173
|
+
|
|
174
|
+
export const GET = handle(app)
|
|
175
|
+
export const POST = handle(app)
|
|
176
|
+
export const PUT = handle(app)
|
|
177
|
+
export const DELETE = handle(app)
|
|
178
|
+
|
|
179
|
+
// 型のエクスポート(フロントエンドで使用)
|
|
180
|
+
export type AppType = typeof app
|
|
181
|
+
```
|
|
182
|
+
|
|
183
|
+
### API呼び出しパターン
|
|
184
|
+
|
|
185
|
+
**GET リクエスト**:
|
|
186
|
+
|
|
187
|
+
```typescript
|
|
188
|
+
// 投稿一覧取得
|
|
189
|
+
const response = await apiClient.posts.$get({
|
|
190
|
+
query: { page: '1', limit: '10' }
|
|
191
|
+
})
|
|
192
|
+
const data = await response.json() // 型推論: { posts: Post[], total: number }
|
|
193
|
+
```
|
|
194
|
+
|
|
195
|
+
**POST リクエスト**:
|
|
196
|
+
|
|
197
|
+
```typescript
|
|
198
|
+
// 投稿作成
|
|
199
|
+
const response = await apiClient.posts.$post({
|
|
200
|
+
json: { title: 'New Post', content: 'Content' }
|
|
201
|
+
})
|
|
202
|
+
const data = await response.json() // 型推論: { post: Post }
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
**GET リクエスト(パスパラメータ)**:
|
|
206
|
+
|
|
207
|
+
```typescript
|
|
208
|
+
// 投稿詳細取得
|
|
209
|
+
const response = await apiClient.posts[':id'].$get({
|
|
210
|
+
param: { id: '123' }
|
|
211
|
+
})
|
|
212
|
+
const data = await response.json() // 型推論: { post: Post }
|
|
213
|
+
```
|
|
214
|
+
|
|
215
|
+
**PUT リクエスト**:
|
|
216
|
+
|
|
217
|
+
```typescript
|
|
218
|
+
// 投稿更新
|
|
219
|
+
const response = await apiClient.posts[':id'].$put({
|
|
220
|
+
param: { id: '123' },
|
|
221
|
+
json: { title: 'Updated Title' }
|
|
222
|
+
})
|
|
223
|
+
const data = await response.json() // 型推論: { post: Post }
|
|
224
|
+
```
|
|
225
|
+
|
|
226
|
+
**DELETE リクエスト**:
|
|
227
|
+
|
|
228
|
+
```typescript
|
|
229
|
+
// 投稿削除
|
|
230
|
+
const response = await apiClient.posts[':id'].$delete({
|
|
231
|
+
param: { id: '123' }
|
|
232
|
+
})
|
|
233
|
+
const data = await response.json() // 型推論: { success: true }
|
|
234
|
+
```
|
|
235
|
+
|
|
236
|
+
**エンドツーエンド型推論のメリット**:
|
|
237
|
+
- バックエンドのAPI変更が自動的にフロントエンドに反映
|
|
238
|
+
- 型エラーでAPI仕様の不一致を早期発見
|
|
239
|
+
- IDEの補完機能でAPI仕様を確認可能
|
|
240
|
+
|
|
241
|
+
---
|
|
242
|
+
|
|
243
|
+
## 4. Server ComponentとClient Component
|
|
244
|
+
|
|
245
|
+
### 🚨 基本原則(最重要)
|
|
246
|
+
|
|
247
|
+
このプロジェクトでは、以下の原則を**必ず守ってください**:
|
|
248
|
+
|
|
249
|
+
✅ **可能な限りServer Componentを使用する**
|
|
250
|
+
- すべてのコンポーネントはデフォルトでServer Component
|
|
251
|
+
- インタラクティブ性が必要な部分のみClient Component化
|
|
252
|
+
|
|
253
|
+
❌ **page.tsxでの`'use client'`使用は禁止**
|
|
254
|
+
- ページコンポーネント(`app/**/page.tsx`)はServer Componentとして実装
|
|
255
|
+
- データフェッチ、認証チェックはServer Componentで実行
|
|
256
|
+
- インタラクティブな部分は別コンポーネントに分離してClient Component化
|
|
257
|
+
|
|
258
|
+
### Server ComponentとClient Componentの違い
|
|
259
|
+
|
|
260
|
+
#### Server Component(デフォルト)
|
|
261
|
+
|
|
262
|
+
**特徴**:
|
|
263
|
+
- サーバー側でのみレンダリング
|
|
264
|
+
- JavaScriptバンドルに含まれない
|
|
265
|
+
- データベースやAPIに直接アクセス可能
|
|
266
|
+
- 機密情報(APIキー、トークン)を安全に扱える
|
|
267
|
+
|
|
268
|
+
**制限**:
|
|
269
|
+
- `useState`, `useEffect`などのReactフックは使用不可
|
|
270
|
+
- ブラウザAPI(`window`, `document`)は使用不可
|
|
271
|
+
- イベントハンドラー(`onClick`等)は使用不可
|
|
272
|
+
|
|
273
|
+
**例**:
|
|
274
|
+
```typescript
|
|
275
|
+
// app/posts/page.tsx (Server Component - デフォルト)
|
|
276
|
+
import { PostList } from '@/components/features/posts/PostList'
|
|
277
|
+
import { apiClient } from '@/lib/api-client'
|
|
278
|
+
|
|
279
|
+
export default async function PostsPage() {
|
|
280
|
+
// サーバー側でデータフェッチ
|
|
281
|
+
const response = await apiClient.posts.$get({
|
|
282
|
+
query: { page: '1', limit: '10' }
|
|
283
|
+
})
|
|
284
|
+
const data = await response.json()
|
|
285
|
+
|
|
286
|
+
return (
|
|
287
|
+
<div>
|
|
288
|
+
<h1>投稿一覧</h1>
|
|
289
|
+
<PostList initialData={data} /> {/* Client Componentにデータを渡す */}
|
|
290
|
+
</div>
|
|
291
|
+
)
|
|
292
|
+
}
|
|
293
|
+
```
|
|
294
|
+
|
|
295
|
+
#### Client Component
|
|
296
|
+
|
|
297
|
+
**特徴**:
|
|
298
|
+
- `'use client'`ディレクティブで明示的に宣言
|
|
299
|
+
- サーバー側でプリレンダリング後、クライアント側でハイドレーション
|
|
300
|
+
- Reactフック、イベントハンドラー使用可能
|
|
301
|
+
- JavaScriptバンドルに含まれる
|
|
302
|
+
|
|
303
|
+
**用途**:
|
|
304
|
+
- インタラクティブ性(ボタンクリック、入力処理)
|
|
305
|
+
- ステート管理(`useState`, `useReducer`)
|
|
306
|
+
- ライフサイクル(`useEffect`, `useLayoutEffect`)
|
|
307
|
+
- カスタムフック(`useQuery`, `useForm`)
|
|
308
|
+
- ブラウザAPI(`localStorage`, `window`)
|
|
309
|
+
|
|
310
|
+
**例**:
|
|
311
|
+
```typescript
|
|
312
|
+
// components/features/posts/PostList.tsx (Client Component)
|
|
313
|
+
'use client'
|
|
314
|
+
|
|
315
|
+
import { useState } from 'react'
|
|
316
|
+
import { PostCard } from './PostCard'
|
|
317
|
+
import type { Post } from '@repo/server-core/domain/entities/Post'
|
|
318
|
+
|
|
319
|
+
interface PostListProps {
|
|
320
|
+
initialData: { posts: Post[], total: number }
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
export function PostList({ initialData }: PostListProps) {
|
|
324
|
+
const [posts, setPosts] = useState(initialData.posts)
|
|
325
|
+
|
|
326
|
+
const handleSort = (field: string) => {
|
|
327
|
+
// ソート処理
|
|
328
|
+
const sorted = [...posts].sort(/* ... */)
|
|
329
|
+
setPosts(sorted)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
return (
|
|
333
|
+
<div>
|
|
334
|
+
<button onClick={() => handleSort('title')}>タイトルでソート</button>
|
|
335
|
+
{posts.map(post => <PostCard key={post.id} post={post} />)}
|
|
336
|
+
</div>
|
|
337
|
+
)
|
|
338
|
+
}
|
|
339
|
+
```
|
|
340
|
+
|
|
341
|
+
### 判断フローチャート
|
|
342
|
+
|
|
343
|
+
コンポーネント作成時の判断基準:
|
|
344
|
+
|
|
345
|
+
```
|
|
346
|
+
新しいコンポーネントを作成
|
|
347
|
+
↓
|
|
348
|
+
質問1: page.tsxか?
|
|
349
|
+
YES → Server Component(絶対)
|
|
350
|
+
NO → 質問2へ
|
|
351
|
+
↓
|
|
352
|
+
質問2: イベントハンドラー(onClick等)が必要か?
|
|
353
|
+
YES → Client Component
|
|
354
|
+
NO → 質問3へ
|
|
355
|
+
↓
|
|
356
|
+
質問3: Reactフック(useState, useEffect等)が必要か?
|
|
357
|
+
YES → Client Component
|
|
358
|
+
NO → 質問4へ
|
|
359
|
+
↓
|
|
360
|
+
質問4: ブラウザAPI(window, localStorage等)が必要か?
|
|
361
|
+
YES → Client Component
|
|
362
|
+
NO → 質問5へ
|
|
363
|
+
↓
|
|
364
|
+
質問5: カスタムフック(useQuery, useForm等)が必要か?
|
|
365
|
+
YES → Client Component
|
|
366
|
+
NO → Server Component(デフォルト)
|
|
367
|
+
```
|
|
368
|
+
|
|
369
|
+
### `'use client'`境界の最適化
|
|
370
|
+
|
|
371
|
+
#### ❌ 非推奨パターン
|
|
372
|
+
|
|
373
|
+
```typescript
|
|
374
|
+
// ❌ page.tsx全体をClient Component化(禁止)
|
|
375
|
+
'use client'
|
|
376
|
+
|
|
377
|
+
import { useState } from 'react'
|
|
378
|
+
|
|
379
|
+
export default function PostsPage() {
|
|
380
|
+
const [filter, setFilter] = useState('')
|
|
381
|
+
|
|
382
|
+
return (
|
|
383
|
+
<div>
|
|
384
|
+
<Header /> {/* 静的 */}
|
|
385
|
+
<Sidebar /> {/* 静的 */}
|
|
386
|
+
<FilterInput value={filter} onChange={setFilter} /> {/* インタラクティブ */}
|
|
387
|
+
<PostList filter={filter} />
|
|
388
|
+
</div>
|
|
389
|
+
)
|
|
390
|
+
}
|
|
391
|
+
```
|
|
392
|
+
|
|
393
|
+
**問題点**:
|
|
394
|
+
- ページ全体がJavaScriptバンドルに含まれる
|
|
395
|
+
- バンドルサイズが肥大化
|
|
396
|
+
- First Contentful Paintが遅くなる
|
|
397
|
+
- Server Componentのメリットを失う
|
|
398
|
+
|
|
399
|
+
#### ✅ 推奨パターン
|
|
400
|
+
|
|
401
|
+
```typescript
|
|
402
|
+
// ✅ page.tsxはServer Component('use client'なし)
|
|
403
|
+
import { Header } from '@/components/features/Header'
|
|
404
|
+
import { Sidebar } from '@/components/features/Sidebar'
|
|
405
|
+
import { PostListContainer } from '@/components/features/posts/PostListContainer'
|
|
406
|
+
import { apiClient } from '@/lib/api-client'
|
|
407
|
+
|
|
408
|
+
export default async function PostsPage() {
|
|
409
|
+
// サーバー側でデータフェッチ
|
|
410
|
+
const response = await apiClient.posts.$get()
|
|
411
|
+
const data = await response.json()
|
|
412
|
+
|
|
413
|
+
return (
|
|
414
|
+
<div>
|
|
415
|
+
<Header /> {/* Server Component */}
|
|
416
|
+
<Sidebar /> {/* Server Component */}
|
|
417
|
+
<PostListContainer initialData={data} /> {/* Client Component */}
|
|
418
|
+
</div>
|
|
419
|
+
)
|
|
420
|
+
}
|
|
421
|
+
```
|
|
422
|
+
|
|
423
|
+
```typescript
|
|
424
|
+
// components/features/posts/PostListContainer.tsx
|
|
425
|
+
'use client'
|
|
426
|
+
|
|
427
|
+
import { useState } from 'react'
|
|
428
|
+
import { FilterInput } from './FilterInput'
|
|
429
|
+
import { PostList } from './PostList'
|
|
430
|
+
|
|
431
|
+
export function PostListContainer({ initialData }) {
|
|
432
|
+
const [filter, setFilter] = useState('')
|
|
433
|
+
|
|
434
|
+
return (
|
|
435
|
+
<div>
|
|
436
|
+
<FilterInput value={filter} onChange={setFilter} />
|
|
437
|
+
<PostList data={initialData} filter={filter} />
|
|
438
|
+
</div>
|
|
439
|
+
)
|
|
440
|
+
}
|
|
441
|
+
```
|
|
442
|
+
|
|
443
|
+
**メリット**:
|
|
444
|
+
- バンドルサイズの最小化
|
|
445
|
+
- 静的コンテンツは即座に表示
|
|
446
|
+
- インタラクティブ部分のみハイドレーション
|
|
447
|
+
- パフォーマンスの最適化
|
|
448
|
+
|
|
449
|
+
### コンポーネント間のデータ受け渡しパターン
|
|
450
|
+
|
|
451
|
+
#### パターン1: Server Component → Client Component(props)
|
|
452
|
+
|
|
453
|
+
```typescript
|
|
454
|
+
// app/posts/[id]/page.tsx (Server Component)
|
|
455
|
+
import { PostDetail } from '@/components/features/posts/PostDetail'
|
|
456
|
+
import { apiClient } from '@/lib/api-client'
|
|
457
|
+
|
|
458
|
+
export default async function PostDetailPage({ params }: { params: { id: string } }) {
|
|
459
|
+
// サーバー側でデータフェッチ
|
|
460
|
+
const response = await apiClient.posts[':id'].$get({
|
|
461
|
+
param: { id: params.id }
|
|
462
|
+
})
|
|
463
|
+
const { post } = await response.json()
|
|
464
|
+
|
|
465
|
+
// Client Componentにpropsとして渡す
|
|
466
|
+
return <PostDetail post={post} />
|
|
467
|
+
}
|
|
468
|
+
```
|
|
469
|
+
|
|
470
|
+
```typescript
|
|
471
|
+
// components/features/posts/PostDetail.tsx (Client Component)
|
|
472
|
+
'use client'
|
|
473
|
+
|
|
474
|
+
import { useState } from 'react'
|
|
475
|
+
import type { Post } from '@repo/server-core/domain/entities/Post'
|
|
476
|
+
|
|
477
|
+
interface PostDetailProps {
|
|
478
|
+
post: Post // Server Componentから受け取ったデータ
|
|
479
|
+
}
|
|
480
|
+
|
|
481
|
+
export function PostDetail({ post }: PostDetailProps) {
|
|
482
|
+
const [isEditing, setIsEditing] = useState(false)
|
|
483
|
+
|
|
484
|
+
return (
|
|
485
|
+
<div>
|
|
486
|
+
<h1>{post.title}</h1>
|
|
487
|
+
<p>{post.content}</p>
|
|
488
|
+
<button onClick={() => setIsEditing(true)}>編集</button>
|
|
489
|
+
</div>
|
|
490
|
+
)
|
|
491
|
+
}
|
|
492
|
+
```
|
|
493
|
+
|
|
494
|
+
#### パターン2: Server ComponentをClient Componentの子として渡す(children)
|
|
495
|
+
|
|
496
|
+
```typescript
|
|
497
|
+
// app/posts/layout.tsx (Server Component)
|
|
498
|
+
import { PostSidebar } from '@/components/features/posts/PostSidebar'
|
|
499
|
+
import { PostContainer } from '@/components/features/posts/PostContainer'
|
|
500
|
+
|
|
501
|
+
export default function PostLayout({ children }: { children: React.ReactNode }) {
|
|
502
|
+
return (
|
|
503
|
+
<PostContainer>
|
|
504
|
+
<PostSidebar /> {/* Server Component */}
|
|
505
|
+
{children} {/* Server Component */}
|
|
506
|
+
</PostContainer>
|
|
507
|
+
)
|
|
508
|
+
}
|
|
509
|
+
```
|
|
510
|
+
|
|
511
|
+
```typescript
|
|
512
|
+
// components/features/posts/PostContainer.tsx (Client Component)
|
|
513
|
+
'use client'
|
|
514
|
+
|
|
515
|
+
import { useState } from 'react'
|
|
516
|
+
import type { ReactNode } from 'react'
|
|
517
|
+
|
|
518
|
+
interface PostContainerProps {
|
|
519
|
+
children: ReactNode
|
|
520
|
+
}
|
|
521
|
+
|
|
522
|
+
export function PostContainer({ children }: PostContainerProps) {
|
|
523
|
+
const [sidebarOpen, setSidebarOpen] = useState(true)
|
|
524
|
+
|
|
525
|
+
return (
|
|
526
|
+
<div className={sidebarOpen ? 'with-sidebar' : 'no-sidebar'}>
|
|
527
|
+
<button onClick={() => setSidebarOpen(!sidebarOpen)}>
|
|
528
|
+
サイドバー切替
|
|
529
|
+
</button>
|
|
530
|
+
{children} {/* Server Componentが表示される */}
|
|
531
|
+
</div>
|
|
532
|
+
)
|
|
533
|
+
}
|
|
534
|
+
```
|
|
535
|
+
|
|
536
|
+
### パフォーマンス考慮事項
|
|
537
|
+
|
|
538
|
+
#### バンドルサイズへの影響
|
|
539
|
+
|
|
540
|
+
| コンポーネントタイプ | JavaScriptバンドル | 初期表示速度 | SEO |
|
|
541
|
+
|------------------|-----------------|------------|-----|
|
|
542
|
+
| Server Component | 含まれない | ⚡ 高速 | ✅ 優秀 |
|
|
543
|
+
| Client Component | 含まれる | 🐢 遅い | ⚠️ 要対策 |
|
|
544
|
+
|
|
545
|
+
#### コードスプリッティング戦略
|
|
546
|
+
|
|
547
|
+
```typescript
|
|
548
|
+
// ✅ 複数のClient Componentエントリーポイントで分割
|
|
549
|
+
// components/features/posts/PostCreateForm.tsx
|
|
550
|
+
'use client'
|
|
551
|
+
export function PostCreateForm() { /* ... */ }
|
|
552
|
+
|
|
553
|
+
// components/features/posts/PostList.tsx
|
|
554
|
+
'use client'
|
|
555
|
+
export function PostList() { /* ... */ }
|
|
556
|
+
|
|
557
|
+
// components/features/posts/PostDetail.tsx
|
|
558
|
+
'use client'
|
|
559
|
+
export function PostDetail() { /* ... */ }
|
|
560
|
+
```
|
|
561
|
+
|
|
562
|
+
それぞれ独立したバンドルとなり、必要なページでのみロードされます。
|
|
563
|
+
|
|
564
|
+
#### 動的インポートの活用
|
|
565
|
+
|
|
566
|
+
大きなClient Componentは動的インポートで遅延ロード:
|
|
567
|
+
|
|
568
|
+
```typescript
|
|
569
|
+
// app/posts/page.tsx
|
|
570
|
+
import dynamic from 'next/dynamic'
|
|
571
|
+
|
|
572
|
+
const PostEditor = dynamic(
|
|
573
|
+
() => import('@/components/features/posts/PostEditor'),
|
|
574
|
+
{
|
|
575
|
+
loading: () => <div>エディタを読み込み中...</div>,
|
|
576
|
+
ssr: false // クライアント側のみでレンダリング
|
|
577
|
+
}
|
|
578
|
+
)
|
|
579
|
+
|
|
580
|
+
export default function PostsPage() {
|
|
581
|
+
return (
|
|
582
|
+
<div>
|
|
583
|
+
<h1>投稿編集</h1>
|
|
584
|
+
<PostEditor />
|
|
585
|
+
</div>
|
|
586
|
+
)
|
|
587
|
+
}
|
|
588
|
+
```
|
|
589
|
+
|
|
590
|
+
### チェックリスト
|
|
591
|
+
|
|
592
|
+
新しいコンポーネントを作成する際のチェックリスト:
|
|
593
|
+
|
|
594
|
+
- [ ] **page.tsxか?** → YESなら必ずServer Component
|
|
595
|
+
- [ ] **インタラクティブ性が必要か?**(onClick, onChange等)
|
|
596
|
+
- [ ] **Reactフックを使用するか?**(useState, useEffect等)
|
|
597
|
+
- [ ] **ブラウザAPIを使用するか?**(window, localStorage等)
|
|
598
|
+
- [ ] **カスタムフックを使用するか?**(useQuery, useForm等)
|
|
599
|
+
- [ ] **バンドルサイズへの影響を考慮したか?**
|
|
600
|
+
- [ ] **`'use client'`境界を最小限に抑えたか?**
|
|
601
|
+
- [ ] **静的コンテンツをServer Componentとして分離したか?**
|
|
602
|
+
|
|
603
|
+
すべてNOならServer Component、1つでもYESならClient Componentを検討してください。
|
|
604
|
+
|
|
605
|
+
---
|
|
606
|
+
|
|
607
|
+
## 5. Tanstack Query(サーバー状態管理)
|
|
608
|
+
|
|
609
|
+
> **⚠️ 重要**: Tanstack Query(`useQuery`, `useMutation`等)は**Client Componentでのみ使用できます**。詳細は[4. Server ComponentとClient Component](#4-server-componentとclient-component)を参照してください。
|
|
610
|
+
|
|
611
|
+
### QueryClientの設定
|
|
612
|
+
|
|
613
|
+
```typescript
|
|
614
|
+
// apps/web/lib/query-client.ts
|
|
615
|
+
import { QueryClient } from '@tanstack/react-query'
|
|
616
|
+
|
|
617
|
+
export const queryClient = new QueryClient({
|
|
618
|
+
defaultOptions: {
|
|
619
|
+
queries: {
|
|
620
|
+
staleTime: 1000 * 60 * 5, // 5分間キャッシュを新鮮とみなす
|
|
621
|
+
cacheTime: 1000 * 60 * 10, // 10分間キャッシュを保持
|
|
622
|
+
refetchOnWindowFocus: false, // ウィンドウフォーカス時の再取得を無効化
|
|
623
|
+
retry: 1, // 失敗時1回リトライ
|
|
624
|
+
},
|
|
625
|
+
},
|
|
626
|
+
})
|
|
627
|
+
```
|
|
628
|
+
|
|
629
|
+
**Providerの設定**:
|
|
630
|
+
|
|
631
|
+
```typescript
|
|
632
|
+
// apps/web/app/layout.tsx
|
|
633
|
+
'use client'
|
|
634
|
+
|
|
635
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
636
|
+
import { queryClient } from '@/lib/query-client'
|
|
637
|
+
|
|
638
|
+
export default function RootLayout({ children }) {
|
|
639
|
+
return (
|
|
640
|
+
<html lang="ja">
|
|
641
|
+
<body>
|
|
642
|
+
<QueryClientProvider client={queryClient}>
|
|
643
|
+
{children}
|
|
644
|
+
</QueryClientProvider>
|
|
645
|
+
</body>
|
|
646
|
+
</html>
|
|
647
|
+
)
|
|
648
|
+
}
|
|
649
|
+
```
|
|
650
|
+
|
|
651
|
+
### useQuery - データ取得
|
|
652
|
+
|
|
653
|
+
**基本パターン**:
|
|
654
|
+
|
|
655
|
+
```typescript
|
|
656
|
+
import { useQuery } from '@tanstack/react-query'
|
|
657
|
+
import { apiClient } from '@/lib/api-client'
|
|
658
|
+
|
|
659
|
+
export function usePostList(page: number, limit: number) {
|
|
660
|
+
return useQuery({
|
|
661
|
+
queryKey: ['posts', page, limit], // キャッシュキー
|
|
662
|
+
queryFn: async () => {
|
|
663
|
+
const response = await apiClient.posts.$get({
|
|
664
|
+
query: { page: String(page), limit: String(limit) }
|
|
665
|
+
})
|
|
666
|
+
if (!response.ok) {
|
|
667
|
+
throw new Error('Failed to fetch posts')
|
|
668
|
+
}
|
|
669
|
+
return response.json()
|
|
670
|
+
},
|
|
671
|
+
enabled: true, // 自動実行を有効化
|
|
672
|
+
})
|
|
673
|
+
}
|
|
674
|
+
```
|
|
675
|
+
|
|
676
|
+
**コンポーネントでの使用**:
|
|
677
|
+
|
|
678
|
+
```typescript
|
|
679
|
+
export function PostList() {
|
|
680
|
+
const { data, isLoading, error } = usePostList(1, 10)
|
|
681
|
+
|
|
682
|
+
if (isLoading) return <div>読み込み中...</div>
|
|
683
|
+
if (error) return <div>エラー: {error.message}</div>
|
|
684
|
+
|
|
685
|
+
return (
|
|
686
|
+
<div>
|
|
687
|
+
{data.posts.map(post => (
|
|
688
|
+
<PostCard key={post.id} post={post} />
|
|
689
|
+
))}
|
|
690
|
+
</div>
|
|
691
|
+
)
|
|
692
|
+
}
|
|
693
|
+
```
|
|
694
|
+
|
|
695
|
+
### useMutation - データ更新
|
|
696
|
+
|
|
697
|
+
**基本パターン**:
|
|
698
|
+
|
|
699
|
+
```typescript
|
|
700
|
+
import { useMutation, useQueryClient } from '@tanstack/react-query'
|
|
701
|
+
import { apiClient } from '@/lib/api-client'
|
|
702
|
+
import type { CreatePostInput } from '@repo/server-core/domain/validators/post'
|
|
703
|
+
|
|
704
|
+
export function useCreatePost() {
|
|
705
|
+
const queryClient = useQueryClient()
|
|
706
|
+
|
|
707
|
+
return useMutation({
|
|
708
|
+
mutationFn: async (data: CreatePostInput) => {
|
|
709
|
+
const response = await apiClient.posts.$post({ json: data })
|
|
710
|
+
if (!response.ok) {
|
|
711
|
+
throw new Error('Failed to create post')
|
|
712
|
+
}
|
|
713
|
+
return response.json()
|
|
714
|
+
},
|
|
715
|
+
onSuccess: () => {
|
|
716
|
+
// 投稿一覧のキャッシュを無効化(再取得をトリガー)
|
|
717
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] })
|
|
718
|
+
},
|
|
719
|
+
onError: (error) => {
|
|
720
|
+
console.error('投稿作成エラー:', error)
|
|
721
|
+
},
|
|
722
|
+
})
|
|
723
|
+
}
|
|
724
|
+
```
|
|
725
|
+
|
|
726
|
+
**コンポーネントでの使用**:
|
|
727
|
+
|
|
728
|
+
```typescript
|
|
729
|
+
export function PostCreateButton() {
|
|
730
|
+
const createPost = useCreatePost()
|
|
731
|
+
|
|
732
|
+
const handleCreate = () => {
|
|
733
|
+
createPost.mutate({
|
|
734
|
+
title: 'New Post',
|
|
735
|
+
content: 'Content',
|
|
736
|
+
status: 'draft',
|
|
737
|
+
})
|
|
738
|
+
}
|
|
739
|
+
|
|
740
|
+
return (
|
|
741
|
+
<button onClick={handleCreate} disabled={createPost.isPending}>
|
|
742
|
+
{createPost.isPending ? '作成中...' : '投稿を作成'}
|
|
743
|
+
</button>
|
|
744
|
+
)
|
|
745
|
+
}
|
|
746
|
+
```
|
|
747
|
+
|
|
748
|
+
### QueryKeyパターン
|
|
749
|
+
|
|
750
|
+
**推奨されるキー構造**:
|
|
751
|
+
|
|
752
|
+
```typescript
|
|
753
|
+
// 投稿一覧
|
|
754
|
+
['posts'] // すべての投稿
|
|
755
|
+
['posts', { page: 1, limit: 10 }] // ページング付き投稿一覧
|
|
756
|
+
|
|
757
|
+
// 投稿詳細
|
|
758
|
+
['posts', postId] // 特定の投稿
|
|
759
|
+
|
|
760
|
+
// ユーザー関連
|
|
761
|
+
['users'] // ユーザー一覧
|
|
762
|
+
['users', userId] // 特定のユーザー
|
|
763
|
+
['users', userId, 'posts'] // 特定ユーザーの投稿
|
|
764
|
+
```
|
|
765
|
+
|
|
766
|
+
**キャッシュ無効化のパターン**:
|
|
767
|
+
|
|
768
|
+
```typescript
|
|
769
|
+
// 特定のクエリのみ無効化
|
|
770
|
+
queryClient.invalidateQueries({ queryKey: ['posts', postId] })
|
|
771
|
+
|
|
772
|
+
// プレフィックスマッチで無効化
|
|
773
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] }) // ['posts', ...] すべて無効化
|
|
774
|
+
|
|
775
|
+
// 完全一致で無効化
|
|
776
|
+
queryClient.invalidateQueries({ queryKey: ['posts'], exact: true })
|
|
777
|
+
```
|
|
778
|
+
|
|
779
|
+
---
|
|
780
|
+
|
|
781
|
+
## 6. React Hook Form(フォーム処理)
|
|
782
|
+
|
|
783
|
+
> **⚠️ 重要**: React Hook Form(`useForm`, `Controller`等)は**Client Componentでのみ使用できます**。詳細は[4. Server ComponentとClient Component](#4-server-componentとclient-component)を参照してください。
|
|
784
|
+
|
|
785
|
+
### Zodスキーマとの統合
|
|
786
|
+
|
|
787
|
+
**Zodスキーマの定義** (共有スキーマを使用):
|
|
788
|
+
|
|
789
|
+
```typescript
|
|
790
|
+
// @repo/server-core/domain/validators/post.ts
|
|
791
|
+
import { z } from 'zod'
|
|
792
|
+
|
|
793
|
+
export const createPostSchema = z.object({
|
|
794
|
+
title: z.string().min(1, 'タイトルは必須です').max(200, 'タイトルは200文字以内です'),
|
|
795
|
+
content: z.string().min(1, '本文は必須です'),
|
|
796
|
+
status: z.enum(['draft', 'published']).default('draft'),
|
|
797
|
+
})
|
|
798
|
+
|
|
799
|
+
export type CreatePostInput = z.infer<typeof createPostSchema>
|
|
800
|
+
```
|
|
801
|
+
|
|
802
|
+
### フォームの実装
|
|
803
|
+
|
|
804
|
+
```typescript
|
|
805
|
+
import { useForm } from 'react-hook-form'
|
|
806
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
807
|
+
import { createPostSchema, type CreatePostInput } from '@repo/server-core/domain/validators/post'
|
|
808
|
+
import { useCreatePost } from '@/hooks/use-posts'
|
|
809
|
+
|
|
810
|
+
export function PostCreateForm() {
|
|
811
|
+
const {
|
|
812
|
+
register,
|
|
813
|
+
handleSubmit,
|
|
814
|
+
formState: { errors, isSubmitting },
|
|
815
|
+
reset,
|
|
816
|
+
} = useForm<CreatePostInput>({
|
|
817
|
+
resolver: zodResolver(createPostSchema),
|
|
818
|
+
defaultValues: {
|
|
819
|
+
status: 'draft',
|
|
820
|
+
},
|
|
821
|
+
})
|
|
822
|
+
|
|
823
|
+
const createPost = useCreatePost()
|
|
824
|
+
|
|
825
|
+
const onSubmit = async (data: CreatePostInput) => {
|
|
826
|
+
try {
|
|
827
|
+
await createPost.mutateAsync(data)
|
|
828
|
+
alert('投稿を作成しました')
|
|
829
|
+
reset() // フォームをリセット
|
|
830
|
+
} catch (error) {
|
|
831
|
+
console.error('投稿作成エラー:', error)
|
|
832
|
+
}
|
|
833
|
+
}
|
|
834
|
+
|
|
835
|
+
return (
|
|
836
|
+
<form onSubmit={handleSubmit(onSubmit)}>
|
|
837
|
+
<div>
|
|
838
|
+
<label>タイトル</label>
|
|
839
|
+
<input {...register('title')} />
|
|
840
|
+
{errors.title && <p className="error">{errors.title.message}</p>}
|
|
841
|
+
</div>
|
|
842
|
+
|
|
843
|
+
<div>
|
|
844
|
+
<label>本文</label>
|
|
845
|
+
<textarea {...register('content')} />
|
|
846
|
+
{errors.content && <p className="error">{errors.content.message}</p>}
|
|
847
|
+
</div>
|
|
848
|
+
|
|
849
|
+
<div>
|
|
850
|
+
<label>ステータス</label>
|
|
851
|
+
<select {...register('status')}>
|
|
852
|
+
<option value="draft">下書き</option>
|
|
853
|
+
<option value="published">公開</option>
|
|
854
|
+
</select>
|
|
855
|
+
</div>
|
|
856
|
+
|
|
857
|
+
<button type="submit" disabled={isSubmitting || createPost.isPending}>
|
|
858
|
+
{isSubmitting || createPost.isPending ? '作成中...' : '投稿を作成'}
|
|
859
|
+
</button>
|
|
860
|
+
</form>
|
|
861
|
+
)
|
|
862
|
+
}
|
|
863
|
+
```
|
|
864
|
+
|
|
865
|
+
**設計ポイント**:
|
|
866
|
+
- **zodResolver**: Zodスキーマとの統合によりバリデーションロジックを一元化
|
|
867
|
+
- **型安全性**: `CreatePostInput`型でフォームデータの型を保証
|
|
868
|
+
- **エラー表示**: `formState.errors`でリアルタイムエラー表示
|
|
869
|
+
- **defaultValues**: デフォルト値の設定
|
|
870
|
+
- **reset()**: 送信成功後にフォームをクリア
|
|
871
|
+
|
|
872
|
+
---
|
|
873
|
+
|
|
874
|
+
## 7. コンポーネント設計
|
|
875
|
+
|
|
876
|
+
### UIコンポーネント(components/ui/)
|
|
877
|
+
|
|
878
|
+
**基本コンポーネントの役割**:
|
|
879
|
+
- 再利用可能な汎用UIパーツ
|
|
880
|
+
- プロジェクト全体で使用
|
|
881
|
+
- ビジネスロジックを含まない
|
|
882
|
+
|
|
883
|
+
**例**:
|
|
884
|
+
|
|
885
|
+
```typescript
|
|
886
|
+
// components/ui/button.tsx
|
|
887
|
+
import type { ReactNode } from 'react'
|
|
888
|
+
|
|
889
|
+
interface ButtonProps {
|
|
890
|
+
children: ReactNode
|
|
891
|
+
onClick?: () => void
|
|
892
|
+
disabled?: boolean
|
|
893
|
+
variant?: 'primary' | 'secondary' | 'danger'
|
|
894
|
+
}
|
|
895
|
+
|
|
896
|
+
export function Button({ children, onClick, disabled, variant = 'primary' }: ButtonProps) {
|
|
897
|
+
const baseStyles = 'px-4 py-2 rounded font-medium'
|
|
898
|
+
const variantStyles = {
|
|
899
|
+
primary: 'bg-blue-500 text-white hover:bg-blue-600',
|
|
900
|
+
secondary: 'bg-gray-500 text-white hover:bg-gray-600',
|
|
901
|
+
danger: 'bg-red-500 text-white hover:bg-red-600',
|
|
902
|
+
}
|
|
903
|
+
|
|
904
|
+
return (
|
|
905
|
+
<button
|
|
906
|
+
onClick={onClick}
|
|
907
|
+
disabled={disabled}
|
|
908
|
+
className={`${baseStyles} ${variantStyles[variant]} disabled:opacity-50`}
|
|
909
|
+
>
|
|
910
|
+
{children}
|
|
911
|
+
</button>
|
|
912
|
+
)
|
|
913
|
+
}
|
|
914
|
+
```
|
|
915
|
+
|
|
916
|
+
### 機能別コンポーネント(components/features/)
|
|
917
|
+
|
|
918
|
+
**機能別コンポーネントの役割**:
|
|
919
|
+
- 特定の機能に特化したコンポーネント
|
|
920
|
+
- ビジネスロジックを含む
|
|
921
|
+
- UIコンポーネントを組み合わせて構築
|
|
922
|
+
|
|
923
|
+
**例**:
|
|
924
|
+
|
|
925
|
+
```typescript
|
|
926
|
+
// components/features/posts/PostCard.tsx
|
|
927
|
+
import Link from 'next/link'
|
|
928
|
+
import { Card } from '@/components/ui/card'
|
|
929
|
+
import { Button } from '@/components/ui/button'
|
|
930
|
+
import type { Post } from '@repo/server-core/domain/entities/Post'
|
|
931
|
+
|
|
932
|
+
interface PostCardProps {
|
|
933
|
+
post: Post
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
export function PostCard({ post }: PostCardProps) {
|
|
937
|
+
return (
|
|
938
|
+
<Card>
|
|
939
|
+
<h3>{post.title}</h3>
|
|
940
|
+
<p>{post.content.substring(0, 100)}...</p>
|
|
941
|
+
<div className="meta">
|
|
942
|
+
<span>{post.status}</span>
|
|
943
|
+
<span>{new Date(post.createdAt).toLocaleDateString()}</span>
|
|
944
|
+
</div>
|
|
945
|
+
<Link href={`/posts/${post.id}`}>
|
|
946
|
+
<Button variant="primary">詳細を見る</Button>
|
|
947
|
+
</Link>
|
|
948
|
+
</Card>
|
|
949
|
+
)
|
|
950
|
+
}
|
|
951
|
+
```
|
|
952
|
+
|
|
953
|
+
### カスタムフック(hooks/)
|
|
954
|
+
|
|
955
|
+
**カスタムフックの役割**:
|
|
956
|
+
- データ取得ロジックの抽象化
|
|
957
|
+
- 状態管理ロジックの再利用
|
|
958
|
+
- コンポーネントをシンプルに保つ
|
|
959
|
+
|
|
960
|
+
**例**:
|
|
961
|
+
|
|
962
|
+
```typescript
|
|
963
|
+
// hooks/use-posts.ts
|
|
964
|
+
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
|
965
|
+
import { apiClient } from '@/lib/api-client'
|
|
966
|
+
import type { CreatePostInput, UpdatePostInput } from '@repo/server-core/domain/validators/post'
|
|
967
|
+
|
|
968
|
+
// 投稿一覧取得
|
|
969
|
+
export function usePostList(page: number, limit: number) {
|
|
970
|
+
return useQuery({
|
|
971
|
+
queryKey: ['posts', page, limit],
|
|
972
|
+
queryFn: async () => {
|
|
973
|
+
const response = await apiClient.posts.$get({
|
|
974
|
+
query: { page: String(page), limit: String(limit) }
|
|
975
|
+
})
|
|
976
|
+
return response.json()
|
|
977
|
+
},
|
|
978
|
+
})
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
// 投稿詳細取得
|
|
982
|
+
export function usePost(id: string) {
|
|
983
|
+
return useQuery({
|
|
984
|
+
queryKey: ['posts', id],
|
|
985
|
+
queryFn: async () => {
|
|
986
|
+
const response = await apiClient.posts[':id'].$get({ param: { id } })
|
|
987
|
+
return response.json()
|
|
988
|
+
},
|
|
989
|
+
})
|
|
990
|
+
}
|
|
991
|
+
|
|
992
|
+
// 投稿作成
|
|
993
|
+
export function useCreatePost() {
|
|
994
|
+
const queryClient = useQueryClient()
|
|
995
|
+
|
|
996
|
+
return useMutation({
|
|
997
|
+
mutationFn: async (data: CreatePostInput) => {
|
|
998
|
+
const response = await apiClient.posts.$post({ json: data })
|
|
999
|
+
return response.json()
|
|
1000
|
+
},
|
|
1001
|
+
onSuccess: () => {
|
|
1002
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] })
|
|
1003
|
+
},
|
|
1004
|
+
})
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
// 投稿更新
|
|
1008
|
+
export function useUpdatePost(id: string) {
|
|
1009
|
+
const queryClient = useQueryClient()
|
|
1010
|
+
|
|
1011
|
+
return useMutation({
|
|
1012
|
+
mutationFn: async (data: UpdatePostInput) => {
|
|
1013
|
+
const response = await apiClient.posts[':id'].$put({
|
|
1014
|
+
param: { id },
|
|
1015
|
+
json: data
|
|
1016
|
+
})
|
|
1017
|
+
return response.json()
|
|
1018
|
+
},
|
|
1019
|
+
onSuccess: () => {
|
|
1020
|
+
queryClient.invalidateQueries({ queryKey: ['posts', id] })
|
|
1021
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] })
|
|
1022
|
+
},
|
|
1023
|
+
})
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// 投稿削除
|
|
1027
|
+
export function useDeletePost() {
|
|
1028
|
+
const queryClient = useQueryClient()
|
|
1029
|
+
|
|
1030
|
+
return useMutation({
|
|
1031
|
+
mutationFn: async (id: string) => {
|
|
1032
|
+
const response = await apiClient.posts[':id'].$delete({ param: { id } })
|
|
1033
|
+
return response.json()
|
|
1034
|
+
},
|
|
1035
|
+
onSuccess: () => {
|
|
1036
|
+
queryClient.invalidateQueries({ queryKey: ['posts'] })
|
|
1037
|
+
},
|
|
1038
|
+
})
|
|
1039
|
+
}
|
|
1040
|
+
```
|
|
1041
|
+
|
|
1042
|
+
---
|
|
1043
|
+
|
|
1044
|
+
## 8. App Router構成
|
|
1045
|
+
|
|
1046
|
+
### ルートグループ
|
|
1047
|
+
|
|
1048
|
+
**認証グループ** (`(auth)/`):
|
|
1049
|
+
- 認証関連のページをグループ化
|
|
1050
|
+
- URLパスに影響しない(`/login`となり、`/(auth)/login`とはならない)
|
|
1051
|
+
- 共通のレイアウトを適用可能
|
|
1052
|
+
|
|
1053
|
+
**ダッシュボードグループ** (`(dashboard)/`):
|
|
1054
|
+
- ログイン後のページをグループ化
|
|
1055
|
+
- 認証チェックミドルウェアを適用
|
|
1056
|
+
|
|
1057
|
+
### レイアウト
|
|
1058
|
+
|
|
1059
|
+
**ルートレイアウト** (`app/layout.tsx`):
|
|
1060
|
+
|
|
1061
|
+
```typescript
|
|
1062
|
+
import type { Metadata } from 'next'
|
|
1063
|
+
import { QueryClientProvider } from '@tanstack/react-query'
|
|
1064
|
+
import { queryClient } from '@/lib/query-client'
|
|
1065
|
+
import './globals.css'
|
|
1066
|
+
|
|
1067
|
+
export const metadata: Metadata = {
|
|
1068
|
+
title: 'プロジェクト名',
|
|
1069
|
+
description: 'プロジェクトの説明',
|
|
1070
|
+
}
|
|
1071
|
+
|
|
1072
|
+
export default function RootLayout({ children }: { children: React.ReactNode }) {
|
|
1073
|
+
return (
|
|
1074
|
+
<html lang="ja">
|
|
1075
|
+
<body>
|
|
1076
|
+
<QueryClientProvider client={queryClient}>
|
|
1077
|
+
{children}
|
|
1078
|
+
</QueryClientProvider>
|
|
1079
|
+
</body>
|
|
1080
|
+
</html>
|
|
1081
|
+
)
|
|
1082
|
+
}
|
|
1083
|
+
```
|
|
1084
|
+
|
|
1085
|
+
**グループレイアウト** (`app/(dashboard)/layout.tsx`):
|
|
1086
|
+
|
|
1087
|
+
```typescript
|
|
1088
|
+
import { Header } from '@/components/features/Header'
|
|
1089
|
+
import { Sidebar } from '@/components/features/Sidebar'
|
|
1090
|
+
|
|
1091
|
+
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
|
|
1092
|
+
return (
|
|
1093
|
+
<div className="dashboard">
|
|
1094
|
+
<Header />
|
|
1095
|
+
<div className="flex">
|
|
1096
|
+
<Sidebar />
|
|
1097
|
+
<main className="flex-1">{children}</main>
|
|
1098
|
+
</div>
|
|
1099
|
+
</div>
|
|
1100
|
+
)
|
|
1101
|
+
}
|
|
1102
|
+
```
|
|
1103
|
+
|
|
1104
|
+
### ページコンポーネント
|
|
1105
|
+
|
|
1106
|
+
**一覧ページ** (`app/posts/page.tsx`):
|
|
1107
|
+
|
|
1108
|
+
```typescript
|
|
1109
|
+
// ✅ page.tsxはServer Component('use client'なし)
|
|
1110
|
+
import { PostListContainer } from '@/components/features/posts/PostListContainer'
|
|
1111
|
+
import { Button } from '@/components/ui/button'
|
|
1112
|
+
import { apiClient } from '@/lib/api-client'
|
|
1113
|
+
import Link from 'next/link'
|
|
1114
|
+
|
|
1115
|
+
export default async function PostsPage() {
|
|
1116
|
+
// サーバー側でデータフェッチ
|
|
1117
|
+
const response = await apiClient.posts.$get({
|
|
1118
|
+
query: { page: '1', limit: '10' }
|
|
1119
|
+
})
|
|
1120
|
+
const data = await response.json()
|
|
1121
|
+
|
|
1122
|
+
return (
|
|
1123
|
+
<div>
|
|
1124
|
+
<div className="header">
|
|
1125
|
+
<h1>投稿一覧</h1>
|
|
1126
|
+
<Link href="/posts/new">
|
|
1127
|
+
<Button variant="primary">新規作成</Button>
|
|
1128
|
+
</Link>
|
|
1129
|
+
</div>
|
|
1130
|
+
|
|
1131
|
+
{/* Client Componentにデータを渡す */}
|
|
1132
|
+
<PostListContainer initialData={data} />
|
|
1133
|
+
</div>
|
|
1134
|
+
)
|
|
1135
|
+
}
|
|
1136
|
+
```
|
|
1137
|
+
|
|
1138
|
+
```typescript
|
|
1139
|
+
// components/features/posts/PostListContainer.tsx (Client Component)
|
|
1140
|
+
'use client'
|
|
1141
|
+
|
|
1142
|
+
import { PostCard } from '@/components/features/posts/PostCard'
|
|
1143
|
+
import type { Post } from '@repo/server-core/domain/entities/Post'
|
|
1144
|
+
|
|
1145
|
+
interface PostListContainerProps {
|
|
1146
|
+
initialData: { posts: Post[], total: number }
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
export function PostListContainer({ initialData }: PostListContainerProps) {
|
|
1150
|
+
return (
|
|
1151
|
+
<div className="grid">
|
|
1152
|
+
{initialData.posts.map(post => (
|
|
1153
|
+
<PostCard key={post.id} post={post} />
|
|
1154
|
+
))}
|
|
1155
|
+
</div>
|
|
1156
|
+
)
|
|
1157
|
+
}
|
|
1158
|
+
```
|
|
1159
|
+
|
|
1160
|
+
**作成ページ** (`app/posts/new/page.tsx`):
|
|
1161
|
+
|
|
1162
|
+
```typescript
|
|
1163
|
+
// ✅ page.tsxはServer Component('use client'なし)
|
|
1164
|
+
import { PostCreateForm } from '@/components/features/posts/PostCreateForm'
|
|
1165
|
+
|
|
1166
|
+
export default function PostNewPage() {
|
|
1167
|
+
return (
|
|
1168
|
+
<div>
|
|
1169
|
+
<h1>新規投稿作成</h1>
|
|
1170
|
+
{/* PostCreateFormはClient Component */}
|
|
1171
|
+
<PostCreateForm />
|
|
1172
|
+
</div>
|
|
1173
|
+
)
|
|
1174
|
+
}
|
|
1175
|
+
```
|
|
1176
|
+
|
|
1177
|
+
**詳細ページ** (`app/posts/[id]/page.tsx`):
|
|
1178
|
+
|
|
1179
|
+
```typescript
|
|
1180
|
+
// ✅ page.tsxはServer Component('use client'なし)
|
|
1181
|
+
import { PostDetail } from '@/components/features/posts/PostDetail'
|
|
1182
|
+
import { apiClient } from '@/lib/api-client'
|
|
1183
|
+
|
|
1184
|
+
export default async function PostDetailPage({ params }: { params: { id: string } }) {
|
|
1185
|
+
// サーバー側でデータフェッチ
|
|
1186
|
+
const response = await apiClient.posts[':id'].$get({
|
|
1187
|
+
param: { id: params.id }
|
|
1188
|
+
})
|
|
1189
|
+
const { post } = await response.json()
|
|
1190
|
+
|
|
1191
|
+
// Client Componentにデータを渡す
|
|
1192
|
+
return <PostDetail post={post} />
|
|
1193
|
+
}
|
|
1194
|
+
```
|
|
1195
|
+
|
|
1196
|
+
---
|
|
1197
|
+
|
|
1198
|
+
## 9. 状態管理戦略
|
|
1199
|
+
|
|
1200
|
+
### サーバー状態 vs クライアント状態
|
|
1201
|
+
|
|
1202
|
+
**サーバー状態**(Tanstack Queryで管理):
|
|
1203
|
+
- APIから取得したデータ
|
|
1204
|
+
- キャッシュ、再取得、無効化が必要
|
|
1205
|
+
- 例: 投稿一覧、ユーザー情報
|
|
1206
|
+
|
|
1207
|
+
**クライアント状態**(Reactの状態管理で管理):
|
|
1208
|
+
- UIの状態(モーダルの開閉、タブの選択など)
|
|
1209
|
+
- フォームの入力値(未送信)
|
|
1210
|
+
- 例: サイドバーの開閉状態、テーマ設定
|
|
1211
|
+
|
|
1212
|
+
### 状態管理の選択基準
|
|
1213
|
+
|
|
1214
|
+
| データの種類 | 管理方法 | ツール |
|
|
1215
|
+
|-------------|---------|--------|
|
|
1216
|
+
| サーバーから取得したデータ | Tanstack Query | useQuery, useMutation |
|
|
1217
|
+
| フォームの入力値 | React Hook Form | useForm |
|
|
1218
|
+
| UIの状態(ローカル) | useState | useState |
|
|
1219
|
+
| UIの状態(グローバル) | Context API | useContext, createContext |
|
|
1220
|
+
| 認証状態 | Context API + Tanstack Query | 組み合わせ |
|
|
1221
|
+
|
|
1222
|
+
---
|
|
1223
|
+
|
|
1224
|
+
## 10. エラーハンドリング
|
|
1225
|
+
|
|
1226
|
+
### API エラーハンドリング
|
|
1227
|
+
|
|
1228
|
+
```typescript
|
|
1229
|
+
export function usePostList(page: number, limit: number) {
|
|
1230
|
+
return useQuery({
|
|
1231
|
+
queryKey: ['posts', page, limit],
|
|
1232
|
+
queryFn: async () => {
|
|
1233
|
+
const response = await apiClient.posts.$get({
|
|
1234
|
+
query: { page: String(page), limit: String(limit) }
|
|
1235
|
+
})
|
|
1236
|
+
|
|
1237
|
+
if (!response.ok) {
|
|
1238
|
+
const error = await response.json()
|
|
1239
|
+
throw new Error(error.error?.message || 'データの取得に失敗しました')
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
return response.json()
|
|
1243
|
+
},
|
|
1244
|
+
retry: (failureCount, error) => {
|
|
1245
|
+
// 4xx エラーはリトライしない
|
|
1246
|
+
if (error.message.includes('401') || error.message.includes('404')) {
|
|
1247
|
+
return false
|
|
1248
|
+
}
|
|
1249
|
+
return failureCount < 2
|
|
1250
|
+
},
|
|
1251
|
+
})
|
|
1252
|
+
}
|
|
1253
|
+
```
|
|
1254
|
+
|
|
1255
|
+
### エラー境界(Error Boundary)
|
|
1256
|
+
|
|
1257
|
+
```typescript
|
|
1258
|
+
// components/ErrorBoundary.tsx
|
|
1259
|
+
'use client'
|
|
1260
|
+
|
|
1261
|
+
import { Component, type ReactNode } from 'react'
|
|
1262
|
+
|
|
1263
|
+
interface Props {
|
|
1264
|
+
children: ReactNode
|
|
1265
|
+
}
|
|
1266
|
+
|
|
1267
|
+
interface State {
|
|
1268
|
+
hasError: boolean
|
|
1269
|
+
error?: Error
|
|
1270
|
+
}
|
|
1271
|
+
|
|
1272
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
1273
|
+
constructor(props: Props) {
|
|
1274
|
+
super(props)
|
|
1275
|
+
this.state = { hasError: false }
|
|
1276
|
+
}
|
|
1277
|
+
|
|
1278
|
+
static getDerivedStateFromError(error: Error) {
|
|
1279
|
+
return { hasError: true, error }
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
render() {
|
|
1283
|
+
if (this.state.hasError) {
|
|
1284
|
+
return (
|
|
1285
|
+
<div className="error-boundary">
|
|
1286
|
+
<h2>エラーが発生しました</h2>
|
|
1287
|
+
<p>{this.state.error?.message}</p>
|
|
1288
|
+
<button onClick={() => window.location.reload()}>
|
|
1289
|
+
ページを再読み込み
|
|
1290
|
+
</button>
|
|
1291
|
+
</div>
|
|
1292
|
+
)
|
|
1293
|
+
}
|
|
1294
|
+
|
|
1295
|
+
return this.props.children
|
|
1296
|
+
}
|
|
1297
|
+
}
|
|
1298
|
+
```
|
|
1299
|
+
|
|
1300
|
+
---
|
|
1301
|
+
|
|
1302
|
+
## 11. 実装例
|
|
1303
|
+
|
|
1304
|
+
### 完全な投稿一覧ページ実装
|
|
1305
|
+
|
|
1306
|
+
```typescript
|
|
1307
|
+
// app/posts/page.tsx
|
|
1308
|
+
// ✅ page.tsxはServer Component('use client'なし)
|
|
1309
|
+
import { PostListWithPagination } from '@/components/features/posts/PostListWithPagination'
|
|
1310
|
+
import { Button } from '@/components/ui/button'
|
|
1311
|
+
import { apiClient } from '@/lib/api-client'
|
|
1312
|
+
import Link from 'next/link'
|
|
1313
|
+
|
|
1314
|
+
interface PostsPageProps {
|
|
1315
|
+
searchParams: { page?: string }
|
|
1316
|
+
}
|
|
1317
|
+
|
|
1318
|
+
export default async function PostsPage({ searchParams }: PostsPageProps) {
|
|
1319
|
+
const page = Number(searchParams.page) || 1
|
|
1320
|
+
const limit = 10
|
|
1321
|
+
|
|
1322
|
+
// サーバー側でデータフェッチ
|
|
1323
|
+
const response = await apiClient.posts.$get({
|
|
1324
|
+
query: { page: String(page), limit: String(limit) }
|
|
1325
|
+
})
|
|
1326
|
+
const data = await response.json()
|
|
1327
|
+
|
|
1328
|
+
return (
|
|
1329
|
+
<div className="container mx-auto py-8">
|
|
1330
|
+
<div className="flex justify-between items-center mb-8">
|
|
1331
|
+
<h1 className="text-3xl font-bold">投稿一覧</h1>
|
|
1332
|
+
<Link href="/posts/new">
|
|
1333
|
+
<Button variant="primary">新規作成</Button>
|
|
1334
|
+
</Link>
|
|
1335
|
+
</div>
|
|
1336
|
+
|
|
1337
|
+
{/* Client Componentにデータを渡す */}
|
|
1338
|
+
<PostListWithPagination
|
|
1339
|
+
initialData={data}
|
|
1340
|
+
currentPage={page}
|
|
1341
|
+
limit={limit}
|
|
1342
|
+
/>
|
|
1343
|
+
</div>
|
|
1344
|
+
)
|
|
1345
|
+
}
|
|
1346
|
+
```
|
|
1347
|
+
|
|
1348
|
+
```typescript
|
|
1349
|
+
// components/features/posts/PostListWithPagination.tsx (Client Component)
|
|
1350
|
+
'use client'
|
|
1351
|
+
|
|
1352
|
+
import { useRouter, usePathname } from 'next/navigation'
|
|
1353
|
+
import { PostCard } from '@/components/features/posts/PostCard'
|
|
1354
|
+
import { Button } from '@/components/ui/button'
|
|
1355
|
+
import type { Post } from '@repo/server-core/domain/entities/Post'
|
|
1356
|
+
|
|
1357
|
+
interface PostListWithPaginationProps {
|
|
1358
|
+
initialData: { posts: Post[], total: number }
|
|
1359
|
+
currentPage: number
|
|
1360
|
+
limit: number
|
|
1361
|
+
}
|
|
1362
|
+
|
|
1363
|
+
export function PostListWithPagination({
|
|
1364
|
+
initialData,
|
|
1365
|
+
currentPage,
|
|
1366
|
+
limit
|
|
1367
|
+
}: PostListWithPaginationProps) {
|
|
1368
|
+
const router = useRouter()
|
|
1369
|
+
const pathname = usePathname()
|
|
1370
|
+
const totalPages = Math.ceil(initialData.total / limit)
|
|
1371
|
+
|
|
1372
|
+
const handlePageChange = (newPage: number) => {
|
|
1373
|
+
router.push(`${pathname}?page=${newPage}`)
|
|
1374
|
+
}
|
|
1375
|
+
|
|
1376
|
+
return (
|
|
1377
|
+
<>
|
|
1378
|
+
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
|
|
1379
|
+
{initialData.posts.map(post => (
|
|
1380
|
+
<PostCard key={post.id} post={post} />
|
|
1381
|
+
))}
|
|
1382
|
+
</div>
|
|
1383
|
+
|
|
1384
|
+
{totalPages > 1 && (
|
|
1385
|
+
<div className="flex justify-center gap-2 mt-8">
|
|
1386
|
+
<Button
|
|
1387
|
+
onClick={() => handlePageChange(Math.max(1, currentPage - 1))}
|
|
1388
|
+
disabled={currentPage === 1}
|
|
1389
|
+
>
|
|
1390
|
+
前へ
|
|
1391
|
+
</Button>
|
|
1392
|
+
<span className="px-4 py-2">
|
|
1393
|
+
{currentPage} / {totalPages}
|
|
1394
|
+
</span>
|
|
1395
|
+
<Button
|
|
1396
|
+
onClick={() => handlePageChange(Math.min(totalPages, currentPage + 1))}
|
|
1397
|
+
disabled={currentPage === totalPages}
|
|
1398
|
+
>
|
|
1399
|
+
次へ
|
|
1400
|
+
</Button>
|
|
1401
|
+
</div>
|
|
1402
|
+
)}
|
|
1403
|
+
</>
|
|
1404
|
+
)
|
|
1405
|
+
}
|
|
1406
|
+
```
|
|
1407
|
+
|
|
1408
|
+
### 完全な投稿作成フォーム実装
|
|
1409
|
+
|
|
1410
|
+
```typescript
|
|
1411
|
+
// components/features/posts/PostCreateForm.tsx
|
|
1412
|
+
'use client'
|
|
1413
|
+
|
|
1414
|
+
import { useForm } from 'react-hook-form'
|
|
1415
|
+
import { zodResolver } from '@hookform/resolvers/zod'
|
|
1416
|
+
import { useRouter } from 'next/navigation'
|
|
1417
|
+
import { useCreatePost } from '@/hooks/use-posts'
|
|
1418
|
+
import { createPostSchema, type CreatePostInput } from '@repo/server-core/domain/validators/post'
|
|
1419
|
+
import { Button } from '@/components/ui/button'
|
|
1420
|
+
import { Input } from '@/components/ui/input'
|
|
1421
|
+
|
|
1422
|
+
export function PostCreateForm() {
|
|
1423
|
+
const router = useRouter()
|
|
1424
|
+
const createPost = useCreatePost()
|
|
1425
|
+
|
|
1426
|
+
const {
|
|
1427
|
+
register,
|
|
1428
|
+
handleSubmit,
|
|
1429
|
+
formState: { errors, isSubmitting },
|
|
1430
|
+
} = useForm<CreatePostInput>({
|
|
1431
|
+
resolver: zodResolver(createPostSchema),
|
|
1432
|
+
defaultValues: {
|
|
1433
|
+
status: 'draft',
|
|
1434
|
+
},
|
|
1435
|
+
})
|
|
1436
|
+
|
|
1437
|
+
const onSubmit = async (data: CreatePostInput) => {
|
|
1438
|
+
try {
|
|
1439
|
+
await createPost.mutateAsync(data)
|
|
1440
|
+
alert('投稿を作成しました')
|
|
1441
|
+
router.push('/posts')
|
|
1442
|
+
} catch (error) {
|
|
1443
|
+
console.error('投稿作成エラー:', error)
|
|
1444
|
+
alert('投稿の作成に失敗しました')
|
|
1445
|
+
}
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
return (
|
|
1449
|
+
<form onSubmit={handleSubmit(onSubmit)} className="max-w-2xl mx-auto space-y-6">
|
|
1450
|
+
<div>
|
|
1451
|
+
<label className="block text-sm font-medium mb-2">タイトル</label>
|
|
1452
|
+
<Input {...register('title')} placeholder="投稿のタイトルを入力" />
|
|
1453
|
+
{errors.title && (
|
|
1454
|
+
<p className="text-red-500 text-sm mt-1">{errors.title.message}</p>
|
|
1455
|
+
)}
|
|
1456
|
+
</div>
|
|
1457
|
+
|
|
1458
|
+
<div>
|
|
1459
|
+
<label className="block text-sm font-medium mb-2">本文</label>
|
|
1460
|
+
<textarea
|
|
1461
|
+
{...register('content')}
|
|
1462
|
+
rows={10}
|
|
1463
|
+
className="w-full border rounded px-3 py-2"
|
|
1464
|
+
placeholder="投稿の本文を入力"
|
|
1465
|
+
/>
|
|
1466
|
+
{errors.content && (
|
|
1467
|
+
<p className="text-red-500 text-sm mt-1">{errors.content.message}</p>
|
|
1468
|
+
)}
|
|
1469
|
+
</div>
|
|
1470
|
+
|
|
1471
|
+
<div>
|
|
1472
|
+
<label className="block text-sm font-medium mb-2">ステータス</label>
|
|
1473
|
+
<select
|
|
1474
|
+
{...register('status')}
|
|
1475
|
+
className="w-full border rounded px-3 py-2"
|
|
1476
|
+
>
|
|
1477
|
+
<option value="draft">下書き</option>
|
|
1478
|
+
<option value="published">公開</option>
|
|
1479
|
+
</select>
|
|
1480
|
+
{errors.status && (
|
|
1481
|
+
<p className="text-red-500 text-sm mt-1">{errors.status.message}</p>
|
|
1482
|
+
)}
|
|
1483
|
+
</div>
|
|
1484
|
+
|
|
1485
|
+
<div className="flex gap-4">
|
|
1486
|
+
<Button
|
|
1487
|
+
type="submit"
|
|
1488
|
+
variant="primary"
|
|
1489
|
+
disabled={isSubmitting || createPost.isPending}
|
|
1490
|
+
>
|
|
1491
|
+
{isSubmitting || createPost.isPending ? '作成中...' : '投稿を作成'}
|
|
1492
|
+
</Button>
|
|
1493
|
+
<Button
|
|
1494
|
+
type="button"
|
|
1495
|
+
variant="secondary"
|
|
1496
|
+
onClick={() => router.back()}
|
|
1497
|
+
>
|
|
1498
|
+
キャンセル
|
|
1499
|
+
</Button>
|
|
1500
|
+
</div>
|
|
1501
|
+
</form>
|
|
1502
|
+
)
|
|
1503
|
+
}
|
|
1504
|
+
```
|
|
1505
|
+
|
|
1506
|
+
---
|
|
1507
|
+
|
|
1508
|
+
## まとめ
|
|
1509
|
+
|
|
1510
|
+
このフロントエンド開発ガイドに従うことで、以下を実現できます:
|
|
1511
|
+
|
|
1512
|
+
1. **型安全性**: Hono Client + Zodによるエンドツーエンドの型推論
|
|
1513
|
+
2. **パフォーマンス**:
|
|
1514
|
+
- Tanstack Queryによる効率的なデータキャッシング
|
|
1515
|
+
- Server Componentによる最小限のJavaScriptバンドル
|
|
1516
|
+
- 高速な初期表示(First Contentful Paint)
|
|
1517
|
+
3. **保守性**:
|
|
1518
|
+
- コンポーネント分割とカスタムフックによる関心の分離
|
|
1519
|
+
- Server/Client Componentの適切な使い分け
|
|
1520
|
+
4. **開発効率**: React Hook Formによる宣言的なフォーム処理
|
|
1521
|
+
5. **スケーラビリティ**: App Routerとルートグループによる明確な構造
|
|
1522
|
+
|
|
1523
|
+
### 🚨 必須原則
|
|
1524
|
+
|
|
1525
|
+
すべてのフロントエンド実装は、以下の原則を**厳守**してください:
|
|
1526
|
+
|
|
1527
|
+
✅ **可能な限りServer Componentを使用する**
|
|
1528
|
+
- すべてのコンポーネントはデフォルトでServer Component
|
|
1529
|
+
- データフェッチはサーバー側で実行
|
|
1530
|
+
- インタラクティブ部分のみClient Component化
|
|
1531
|
+
|
|
1532
|
+
❌ **page.tsxでの`'use client'`使用は禁止**
|
|
1533
|
+
- ページコンポーネントは必ずServer Componentとして実装
|
|
1534
|
+
- インタラクティブな機能は別コンポーネントに分離
|
|
1535
|
+
- バンドルサイズを最小限に抑える
|
|
1536
|
+
|
|
1537
|
+
このガイドラインに従うことで、高速で保守性の高いモダンなWebアプリケーションを構築できます。
|