Next.js 15とDDDで作る堅牢なシステム設計【第4部: Presentation層編】
はじめに
シリーズ最終回となる第4部では、Presentation層の実装に焦点を当てます。
これまでに作成したDomain、Infrastructure、Application層の機能を、実際のUIで動かしていきます。Next.js 15のServer/Client Componentの使い分け、React Hook Formによるフォーム実装、shadcn/uiを活用したUI設計を解説します。
シリーズ振り返り
- 【第1部: 設計編】 - DDDの基礎とアーキテクチャ設計
- 【第2部: Domain層編】 - エンティティと値オブジェクトの実装
- 【第3部: Infrastructure & Application層編】 - FirebaseとServer Actions
- 【第4部: Presentation層編】 ← 今回(最終回)
この記事で学べること
- Server/Client Componentの適切な使い分け
- React Hook Formによる型安全なフォーム実装
- shadcn/uiを活用したUI設計
- 楽観的更新とローディング状態の管理
- パフォーマンス最適化のベストプラクティス
前提知識
- [第1部]〜[第3部]を読んでいること
- Reactの基本(Hooks、状態管理)を理解していること
- Tailwind CSSの基本を知っていること
Server/Client Componentの使い分け
Next.js 15では、コンポーネントをServer ComponentとClient Componentに分けて実装します。
基本的な判断基準
| 特徴 | Server Component | Client Component |
|---|---|---|
| 実行環境 | サーバーのみ | クライアント(+初回レンダリングはサーバー) |
| 用途 | データフェッチ、静的表示 | インタラクション、状態管理 |
| 使えるHooks | なし | useState、useEffect等 |
| バンドルサイズ | 含まれない(軽量) | 含まれる |
| データフェッチ | async/await直接 | useEffectやSWR |
判断フローチャート
コンポーネントを作る
↓
ユーザーインタラクションが必要?
↓
YES → Client Component ('use client')
- フォーム
- ボタンクリック
- useState/useEffect
NO → Server Component (デフォルト)
↓
データフェッチが必要?
↓
YES → async Server Component
- データベースアクセス
- Server Actions呼び出し
NO → 静的Server Component
- レイアウト
- 静的テキスト
ページコンポーネントの実装
クイズ一覧ページ
Server Componentでデータをフェッチし、表示します。
// app/quizzes/page.tsx
import Link from 'next/link';
import { Plus } from 'lucide-react';
import { getQuizzes } from '@/features/quiz/actions/get-quizzes';
import { getCategories } from '@/features/category/actions/get-categories';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
import { Badge } from '@/components/ui/badge';
import { QuizListClient } from '@/features/quiz/components/quiz-list-client';
/**
* クイズ一覧ページ(Server Component)
*/
export default async function QuizzesPage() {
// Server Componentで並列データフェッチ
const [quizzes, categories] = await Promise.all([
getQuizzes(),
getCategories(),
]);
// カテゴリマップを作成(効率化のため)
const categoryMap = new Map(
categories.map(cat => [cat.id, cat])
);
return (
<div className="container mx-auto px-4 py-8">
{/* ヘッダー */}
<div className="flex justify-between items-center mb-8">
<div>
<h1 className="text-3xl font-bold">クイズ管理</h1>
<p className="text-gray-600 mt-2">
クイズの作成・編集・公開を管理
</p>
</div>
<Link href="/quizzes/new">
<Button>
<Plus className="w-4 h-4 mr-2" />
新規作成
</Button>
</Link>
</div>
{/* 空状態 */}
{quizzes.length === 0 ? (
<Card className="p-12 text-center">
<div className="text-gray-400 mb-4">
<svg
className="mx-auto h-12 w-12"
fill="none"
viewBox="0 0 24 24"
stroke="currentColor"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"
/>
</svg>
</div>
<p className="text-gray-600 mb-4">クイズがまだありません</p>
<Link href="/quizzes/new">
<Button>最初のクイズを作成</Button>
</Link>
</Card>
) : (
<div className="grid gap-4">
{quizzes.map((quiz) => {
const category = categoryMap.get(quiz.categoryId);
return (
<Card
key={quiz.id}
className="p-6 hover:shadow-lg transition-shadow"
>
<div className="flex justify-between items-start">
<div className="flex-1">
<div className="flex items-center gap-3 mb-2">
<h2 className="text-xl font-semibold">{quiz.title}</h2>
{quiz.isPublished ? (
<Badge variant="default">公開中</Badge>
) : (
<Badge variant="secondary">下書き</Badge>
)}
{category && (
<Badge
variant="outline"
className={category.color}
>
{category.name}
</Badge>
)}
</div>
<div className="flex items-center gap-4 text-sm text-gray-600">
<span>{quiz.questionCount}問</span>
<span>·</span>
<span>
難易度:{' '}
{quiz.difficulty === 'easy'
? '簡単'
: quiz.difficulty === 'medium'
? '普通'
: '難しい'}
</span>
<span>·</span>
<span>
作成日:{' '}
{new Date(quiz.createdAt).toLocaleDateString('ja-JP')}
</span>
</div>
</div>
<div className="flex gap-2">
<Link href={`/quizzes/${quiz.id}/edit`}>
<Button variant="outline">編集</Button>
</Link>
</div>
</div>
</Card>
);
})}
</div>
)}
</div>
);
}
Server Componentのメリット:
- 直接データフェッチ: async/awaitで簡潔
-
並列処理:
Promise.allで効率的 - SEO最適化: サーバー側でレンダリング済み
- バンドルサイズ削減: クライアントJSに含まれない
クイズ作成ページ
Server Componentでカテゴリデータを取得し、Client Componentのフォームに渡します。
// app/quizzes/new/page.tsx
import { getActiveCategories } from '@/features/category/actions/get-categories';
import QuizForm from '@/features/quiz/components/quiz-form';
export default async function NewQuizPage() {
const categories = await getActiveCategories();
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">新規クイズ作成</h1>
<p className="text-gray-600 mt-2">
新しいクイズを作成します
</p>
</div>
<QuizForm categories={categories} mode="create" />
</div>
);
}
クイズ編集ページ
// app/quizzes/[id]/edit/page.tsx
import { notFound } from 'next/navigation';
import { getQuizById } from '@/features/quiz/actions/get-quizzes';
import { getActiveCategories } from '@/features/category/actions/get-categories';
import QuizForm from '@/features/quiz/components/quiz-form';
interface Props {
params: { id: string };
}
export default async function EditQuizPage({ params }: Props) {
// 並列でデータフェッチ
const [quiz, categories] = await Promise.all([
getQuizById(params.id),
getActiveCategories(),
]);
// クイズが見つからない場合は404
if (!quiz) {
notFound();
}
return (
<div className="container mx-auto px-4 py-8">
<div className="mb-8">
<h1 className="text-3xl font-bold">クイズ編集</h1>
<p className="text-gray-600 mt-2">{quiz.title}</p>
</div>
<QuizForm
categories={categories}
initialData={{
id: quiz.id,
title: quiz.title,
categoryId: quiz.categoryId,
difficulty: quiz.difficulty,
questions: quiz.questions,
}}
mode="edit"
/>
</div>
);
}
フォームコンポーネントの実装
React Hook FormとZodを組み合わせた型安全なフォームを実装します。
クイズフォーム
// features/quiz/components/quiz-form.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { useForm, useFieldArray } from 'react-hook-form';
import { zodResolver } from '@hookform/resolvers/zod';
import { Plus, Trash2, Save, Loader2 } from 'lucide-react';
import { createQuizJson } from '../actions/create-quiz';
import { updateQuizJson } from '../actions/update-quiz';
import { createQuizSchema, type CreateQuizInput } from '../schemas/quiz-schema';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Card } from '@/components/ui/card';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
import { Alert, AlertDescription } from '@/components/ui/alert';
interface QuizFormProps {
categories: { id: string; name: string; color: string }[];
initialData?: CreateQuizInput & { id?: string };
mode?: 'create' | 'edit';
}
export default function QuizForm({
categories,
initialData,
mode = 'create',
}: QuizFormProps) {
const router = useRouter();
const [isSubmitting, setIsSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const {
register,
control,
handleSubmit,
formState: { errors },
setValue,
watch,
} = useForm<CreateQuizInput>({
resolver: zodResolver(createQuizSchema),
defaultValues: initialData || {
title: '',
categoryId: '',
difficulty: 'medium',
questions: [
{
text: '',
choices: ['', ''],
correctAnswerIndex: 0,
explanation: '',
},
],
},
});
const { fields, append, remove } = useFieldArray({
control,
name: 'questions',
});
const onSubmit = async (data: CreateQuizInput) => {
setIsSubmitting(true);
setError(null);
try {
const result =
mode === 'edit' && initialData?.id
? await updateQuizJson({ ...data, id: initialData.id })
: await createQuizJson(data);
if (result.success) {
router.push('/quizzes');
router.refresh();
} else {
setError(result.error);
}
} catch (err) {
setError('予期しないエラーが発生しました');
} finally {
setIsSubmitting(false);
}
};
const addQuestion = () => {
append({
text: '',
choices: ['', ''],
correctAnswerIndex: 0,
explanation: '',
});
};
const addChoice = (questionIndex: number) => {
const currentChoices = watch(`questions.${questionIndex}.choices`);
if (currentChoices.length < 6) {
setValue(`questions.${questionIndex}.choices`, [...currentChoices, '']);
}
};
const removeChoice = (questionIndex: number, choiceIndex: number) => {
const currentChoices = watch(`questions.${questionIndex}.choices`);
if (currentChoices.length > 2) {
setValue(
`questions.${questionIndex}.choices`,
currentChoices.filter((_, i) => i !== choiceIndex)
);
}
};
return (
<div className="max-w-4xl mx-auto">
<form onSubmit={handleSubmit(onSubmit)} className="space-y-8">
{/* 基本情報カード */}
<Card className="p-6">
<h2 className="text-2xl font-bold mb-6">基本情報</h2>
<div className="space-y-4">
{/* タイトル */}
<div>
<Label htmlFor="title">
クイズタイトル <span className="text-red-500">*</span>
</Label>
<Input
id="title"
{...register('title')}
placeholder="例: 日本の歴史クイズ"
className="mt-1"
/>
{errors.title && (
<p className="text-sm text-red-500 mt-1">
{errors.title.message}
</p>
)}
</div>
{/* カテゴリ */}
<div>
<Label htmlFor="categoryId">
カテゴリ <span className="text-red-500">*</span>
</Label>
<Select
onValueChange={(value) => setValue('categoryId', value)}
defaultValue={initialData?.categoryId || ''}
>
<SelectTrigger className="mt-1">
<SelectValue placeholder="カテゴリを選択" />
</SelectTrigger>
<SelectContent>
{categories.map((cat) => (
<SelectItem key={cat.id} value={cat.id}>
{cat.name}
</SelectItem>
))}
</SelectContent>
</Select>
{errors.categoryId && (
<p className="text-sm text-red-500 mt-1">
{errors.categoryId.message}
</p>
)}
</div>
{/* 難易度 */}
<div>
<Label htmlFor="difficulty">
難易度 <span className="text-red-500">*</span>
</Label>
<Select
onValueChange={(value: any) => setValue('difficulty', value)}
defaultValue={initialData?.difficulty || 'medium'}
>
<SelectTrigger className="mt-1">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="easy">簡単</SelectItem>
<SelectItem value="medium">普通</SelectItem>
<SelectItem value="hard">難しい</SelectItem>
</SelectContent>
</Select>
</div>
</div>
</Card>
{/* 質問セクション */}
<div className="space-y-4">
<div className="flex items-center justify-between">
<h2 className="text-2xl font-bold">質問</h2>
<Button
type="button"
onClick={addQuestion}
variant="outline"
disabled={fields.length >= 50}
>
<Plus className="w-4 h-4 mr-2" />
質問を追加
</Button>
</div>
{fields.map((field, questionIndex) => (
<Card key={field.id} className="p-6">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold">
質問 {questionIndex + 1}
</h3>
{fields.length > 1 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => remove(questionIndex)}
>
<Trash2 className="w-4 h-4 text-red-500" />
</Button>
)}
</div>
<div className="space-y-4">
{/* 質問文 */}
<div>
<Label>
質問文 <span className="text-red-500">*</span>
</Label>
<Input
{...register(`questions.${questionIndex}.text`)}
placeholder="質問を入力してください"
className="mt-1"
/>
{errors.questions?.[questionIndex]?.text && (
<p className="text-sm text-red-500 mt-1">
{errors.questions[questionIndex]?.text?.message}
</p>
)}
</div>
{/* 選択肢 */}
<div>
<div className="flex items-center justify-between mb-2">
<Label>
選択肢 <span className="text-red-500">*</span>
</Label>
<Button
type="button"
variant="outline"
size="sm"
onClick={() => addChoice(questionIndex)}
disabled={
watch(`questions.${questionIndex}.choices`).length >= 6
}
>
<Plus className="w-3 h-3 mr-1" />
選択肢追加
</Button>
</div>
{watch(`questions.${questionIndex}.choices`).map(
(_, choiceIndex) => (
<div
key={choiceIndex}
className="flex items-center gap-2 mb-2"
>
<input
type="radio"
{...register(
`questions.${questionIndex}.correctAnswerIndex`,
{
valueAsNumber: true,
}
)}
value={choiceIndex}
className="w-4 h-4"
/>
<Input
{...register(
`questions.${questionIndex}.choices.${choiceIndex}`
)}
placeholder={`選択肢 ${choiceIndex + 1}`}
/>
{watch(`questions.${questionIndex}.choices`).length >
2 && (
<Button
type="button"
variant="ghost"
size="sm"
onClick={() => removeChoice(questionIndex, choiceIndex)}
>
<Trash2 className="w-4 h-4 text-gray-400" />
</Button>
)}
</div>
)
)}
</div>
{/* 解説 */}
<div>
<Label>解説(任意)</Label>
<Input
{...register(`questions.${questionIndex}.explanation`)}
placeholder="正解の解説を入力してください"
className="mt-1"
/>
{errors.questions?.[questionIndex]?.explanation && (
<p className="text-sm text-red-500 mt-1">
{errors.questions[questionIndex]?.explanation?.message}
</p>
)}
</div>
</div>
</Card>
))}
</div>
{/* エラー表示 */}
{error && (
<Alert variant="destructive">
<AlertDescription>{error}</AlertDescription>
</Alert>
)}
{/* アクションボタン */}
<div className="flex justify-end gap-4">
<Button
type="button"
variant="outline"
onClick={() => router.back()}
disabled={isSubmitting}
>
キャンセル
</Button>
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
保存中...
</>
) : (
<>
<Save className="w-4 h-4 mr-2" />
{mode === 'edit' ? '更新' : 'クイズを作成'}
</>
)}
</Button>
</div>
</form>
</div>
);
}
フォーム実装のポイント:
-
React Hook Form + Zod
- 型安全なバリデーション
- zodResolverで統合
-
useFieldArray
- 動的な質問・選択肢管理
- 追加・削除が簡単
-
楽観的UI
- isSubmittingでローディング状態
- Loader2アイコンでフィードバック
-
エラーハンドリング
- フィールドごとのエラー表示
- グローバルエラーメッセージ
インタラクティブコンポーネント
公開/非公開トグル
// features/quiz/components/publish-toggle.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { publishQuiz, unpublishQuiz } from '../actions/publish-quiz';
import { Button } from '@/components/ui/button';
import { Loader2 } from 'lucide-react';
import { useToast } from '@/components/ui/use-toast';
interface PublishToggleProps {
quizId: string;
isPublished: boolean;
questionCount: number;
}
export function PublishToggle({
quizId,
isPublished,
questionCount,
}: PublishToggleProps) {
const router = useRouter();
const { toast } = useToast();
const [isLoading, setIsLoading] = useState(false);
const handleToggle = async () => {
setIsLoading(true);
try {
const result = isPublished
? await unpublishQuiz(quizId)
: await publishQuiz(quizId);
if (result.success) {
toast({
title: isPublished ? '非公開にしました' : '公開しました',
description: isPublished
? 'クイズを非公開にしました'
: 'クイズを公開しました',
});
router.refresh();
} else {
toast({
title: 'エラー',
description: result.error,
variant: 'destructive',
});
}
} catch (error) {
toast({
title: 'エラー',
description: '処理に失敗しました',
variant: 'destructive',
});
} finally {
setIsLoading(false);
}
};
return (
<Button
onClick={handleToggle}
disabled={isLoading}
variant={isPublished ? 'outline' : 'default'}
>
{isLoading ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
処理中...
</>
) : isPublished ? (
'非公開にする'
) : (
'公開する'
)}
</Button>
);
}
削除確認ダイアログ
// features/quiz/components/delete-quiz-button.tsx
'use client';
import { useState } from 'react';
import { useRouter } from 'next/navigation';
import { Trash2, Loader2 } from 'lucide-react';
import { deleteQuiz } from '../actions/delete-quiz';
import { Button } from '@/components/ui/button';
import {
AlertDialog,
AlertDialogAction,
AlertDialogCancel,
AlertDialogContent,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
AlertDialogTrigger,
} from '@/components/ui/alert-dialog';
import { useToast } from '@/components/ui/use-toast';
interface DeleteQuizButtonProps {
quizId: string;
quizTitle: string;
isPublished: boolean;
}
export function DeleteQuizButton({
quizId,
quizTitle,
isPublished,
}: DeleteQuizButtonProps) {
const router = useRouter();
const { toast } = useToast();
const [isDeleting, setIsDeleting] = useState(false);
const [open, setOpen] = useState(false);
const handleDelete = async () => {
setIsDeleting(true);
try {
const result = await deleteQuiz(quizId);
if (result.success) {
toast({
title: '削除しました',
description: 'クイズを削除しました',
});
router.push('/quizzes');
router.refresh();
} else {
toast({
title: 'エラー',
description: result.error,
variant: 'destructive',
});
}
} catch (error) {
toast({
title: 'エラー',
description: '削除に失敗しました',
variant: 'destructive',
});
} finally {
setIsDeleting(false);
setOpen(false);
}
};
if (isPublished) {
return (
<Button variant="outline" disabled>
<Trash2 className="w-4 h-4 mr-2" />
削除(公開中は削除不可)
</Button>
);
}
return (
<AlertDialog open={open} onOpenChange={setOpen}>
<AlertDialogTrigger asChild>
<Button variant="outline">
<Trash2 className="w-4 h-4 mr-2" />
削除
</Button>
</AlertDialogTrigger>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>本当に削除しますか?</AlertDialogTitle>
<AlertDialogDescription>
「{quizTitle}」を削除します。この操作は取り消せません。
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel disabled={isDeleting}>
キャンセル
</AlertDialogCancel>
<AlertDialogAction
onClick={handleDelete}
disabled={isDeleting}
className="bg-red-500 hover:bg-red-600"
>
{isDeleting ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
削除中...
</>
) : (
'削除する'
)}
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
);
}
UI/UX の改善
ローディング状態
// app/quizzes/loading.tsx
import { Loader2 } from 'lucide-react';
export default function QuizzesLoading() {
return (
<div className="container mx-auto px-4 py-8">
<div className="flex items-center justify-center h-64">
<Loader2 className="w-8 h-8 animate-spin text-gray-400" />
</div>
</div>
);
}
エラー境界
// app/quizzes/error.tsx
'use client';
import { useEffect } from 'react';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
export default function QuizzesError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error(error);
}, [error]);
return (
<div className="container mx-auto px-4 py-8">
<Card className="p-12 text-center">
<h2 className="text-2xl font-bold mb-4">エラーが発生しました</h2>
<p className="text-gray-600 mb-6">
{error.message || 'クイズの読み込みに失敗しました'}
</p>
<Button onClick={reset}>再試行</Button>
</Card>
</div>
);
}
404ページ
// app/quizzes/[id]/not-found.tsx
import Link from 'next/link';
import { Button } from '@/components/ui/button';
import { Card } from '@/components/ui/card';
export default function QuizNotFound() {
return (
<div className="container mx-auto px-4 py-8">
<Card className="p-12 text-center">
<h2 className="text-2xl font-bold mb-4">クイズが見つかりません</h2>
<p className="text-gray-600 mb-6">
指定されたクイズは存在しないか、削除された可能性があります
</p>
<Link href="/quizzes">
<Button>クイズ一覧に戻る</Button>
</Link>
</Card>
</div>
);
}
パフォーマンス最適化
1. Suspenseによる段階的レンダリング
// app/quizzes/page.tsx
import { Suspense } from 'react';
import { QuizList } from '@/features/quiz/components/quiz-list';
import { QuizListSkeleton } from '@/features/quiz/components/quiz-list-skeleton';
export default function QuizzesPage() {
return (
<div className="container mx-auto px-4 py-8">
<h1 className="text-3xl font-bold mb-8">クイズ管理</h1>
<Suspense fallback={<QuizListSkeleton />}>
<QuizListAsync />
</Suspense>
</div>
);
}
async function QuizListAsync() {
const quizzes = await getQuizzes();
return <QuizList quizzes={quizzes} />;
}
2. 動的インポート
// features/quiz/components/quiz-form.tsx
import dynamic from 'next/dynamic';
// 重いコンポーネントを遅延ロード
const QuestionEditor = dynamic(
() => import('./question-editor'),
{
loading: () => <p>読み込み中...</p>,
ssr: false, // クライアントサイドのみ
}
);
3. メモ化
'use client';
import { useMemo } from 'react';
export function QuizList({ quizzes, categories }) {
// カテゴリマップをメモ化
const categoryMap = useMemo(
() => new Map(categories.map(cat => [cat.id, cat])),
[categories]
);
return (
<div>
{quizzes.map(quiz => {
const category = categoryMap.get(quiz.categoryId);
// ...
})}
</div>
);
}
4. 画像最適化
import Image from 'next/image';
export function QuizCard({ quiz }) {
return (
<Card>
{quiz.thumbnailUrl && (
<Image
src={quiz.thumbnailUrl}
alt={quiz.title}
width={300}
height={200}
className="rounded-t-lg"
priority={false} // 遅延ロード
/>
)}
</Card>
);
}
アクセシビリティ
1. セマンティックHTML
// ✅ 良い例
<main>
<h1>クイズ管理</h1>
<nav aria-label="クイズナビゲーション">
<Button>新規作成</Button>
</nav>
<section>
<h2>クイズ一覧</h2>
{/* ... */}
</section>
</main>
// ❌ 悪い例
<div>
<div className="title">クイズ管理</div>
<div>
<Button>新規作成</Button>
</div>
</div>
2. ARIA属性
<Button
aria-label="クイズを削除"
aria-describedby="delete-warning"
>
<Trash2 />
</Button>
<p id="delete-warning" className="sr-only">
この操作は取り消せません
</p>
3. キーボードナビゲーション
<form onSubmit={handleSubmit}>
<Input
tabIndex={1}
onKeyDown={(e) => {
if (e.key === 'Enter') {
e.preventDefault();
// 次のフィールドにフォーカス
}
}}
/>
</form>
ベストプラクティス
1. コンポーネント分割の指針
大きすぎる兆候:
- 1ファイルが300行以上
- 複数の責務を持つ
- テストが書きづらい
→ 分割する
適切なサイズ:
- 1ファイル100-200行程度
- 単一責任の原則
- テストしやすい
2. カスタムHooksの活用
// hooks/use-quiz-form.ts
export function useQuizForm(initialData?: CreateQuizInput) {
const form = useForm<CreateQuizInput>({
resolver: zodResolver(createQuizSchema),
defaultValues: initialData,
});
const addQuestion = () => {
// ロジック
};
const removeQuestion = (index: number) => {
// ロジック
};
return {
form,
addQuestion,
removeQuestion,
};
}
// 使用側
function QuizForm() {
const { form, addQuestion, removeQuestion } = useQuizForm();
// ...
}
3. エラーバウンダリの階層化
app/
├── error.tsx # グローバルエラー
├── quizzes/
│ ├── error.tsx # クイズセクションエラー
│ └── [id]/
│ └── error.tsx # 個別クイズエラー
まとめ
シリーズ全4回を通じて、Next.js 15とDDDを組み合わせた堅牢なシステム設計を解説しました。
各部の振り返り
第1部: 設計編
- レイヤードアーキテクチャの設計
- DDDの基本概念
- ディレクトリ構成
第2部: Domain層編
- エンティティと値オブジェクト
- リポジトリパターン
- ドメインロジックのテスト
第3部: Infrastructure & Application層編
- Firebaseとの連携
- Server Actionsの実装
- エラーハンドリング
第4部: Presentation層編(今回)
- Server/Client Componentの使い分け
- React Hook Formの活用
- UI/UXの改善
- パフォーマンス最適化
この設計の価値
技術的価値
- ✅ ビジネスロジックの保護
- ✅ 技術スタックからの独立性
- ✅ テストしやすい設計
- ✅ 変更に強いアーキテクチャ
ビジネス価値
- ✅ 長期的な保守コストの削減
- ✅ 機能追加の容易さ
- ✅ チーム開発の効率化
- ✅ 技術的負債の削減
実践へのアドバイス
段階的な導入
Step 1: 小さく始める
- 1つの機能だけDDDで実装
- チームで動作を確認
Step 2: 徐々に拡大
- 成功したら他の機能にも適用
- パターンを確立
Step 3: 完全移行
- 既存コードをリファクタリング
- アーキテクチャの統一
適用判断の指針
DDDを採用すべき:
- 複雑なビジネスルールがある
- 長期的な保守が必要(3年以上)
- チームで開発する(5人以上)
DDDは過剰:
- シンプルなCRUD操作のみ
- 短期プロジェクト(3ヶ月以内)
- 個人開発
チームでの合意形成
-
プロトタイプを作る
- 実際に動くものを見せる
- メリットを体感してもらう
-
ドキュメント化
- 設計思想を文書化
- オンボーディング資料を準備
-
コードレビュー
- DDDの原則を守る
- フィードバックループを回す
次のステップ
さらなる改善
-
認証・認可の追加
- NextAuthやClerk導入
- ロールベースアクセス制御
-
E2Eテストの追加
- Playwright導入
- 重要フローのテスト
-
CI/CDパイプライン
- GitHub Actions設定
- 自動デプロイ
-
監視・ログ
- Sentry導入
- パフォーマンス監視
学習リソース
- Domain-Driven Design by Eric Evans
- Implementing Domain-Driven Design by Vaughn Vernon
- Next.js Documentation
- React Hook Form
- shadcn/ui
おわりに
シリーズ全4回、お読みいただきありがとうございました!
DDDとNext.js 15を組み合わせることで、保守性が高く、変更に強い、プロフェッショナルなシステムを構築できます。
最初は学習コストが高く感じるかもしれませんが、長期的には必ず価値を発揮します。ぜひ実践で活用してみてください!
質問やフィードバックは、コメント欄やTwitterでお待ちしています。
Happy Coding! 🚀
サンプルコード
本シリーズで紹介したコードの完全版は、近日中にGitHubで公開予定です。
この記事が役に立ったら、いいね・ブックマークをいただけると嬉しいです!
Discussion