🦔

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. 【第1部: 設計編】 - DDDの基礎とアーキテクチャ設計
  2. 【第2部: Domain層編】 - エンティティと値オブジェクトの実装
  3. 【第3部: Infrastructure & Application層編】 - FirebaseとServer Actions
  4. 【第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のメリット:

  1. 直接データフェッチ: async/awaitで簡潔
  2. 並列処理: Promise.allで効率的
  3. SEO最適化: サーバー側でレンダリング済み
  4. バンドルサイズ削減: クライアント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>
  );
}

フォーム実装のポイント:

  1. React Hook Form + Zod

    • 型安全なバリデーション
    • zodResolverで統合
  2. useFieldArray

    • 動的な質問・選択肢管理
    • 追加・削除が簡単
  3. 楽観的UI

    • isSubmittingでローディング状態
    • Loader2アイコンでフィードバック
  4. エラーハンドリング

    • フィールドごとのエラー表示
    • グローバルエラーメッセージ

インタラクティブコンポーネント

公開/非公開トグル

// 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ヶ月以内)
  • 個人開発

チームでの合意形成

  1. プロトタイプを作る

    • 実際に動くものを見せる
    • メリットを体感してもらう
  2. ドキュメント化

    • 設計思想を文書化
    • オンボーディング資料を準備
  3. コードレビュー

    • DDDの原則を守る
    • フィードバックループを回す

次のステップ

さらなる改善

  1. 認証・認可の追加

    • NextAuthやClerk導入
    • ロールベースアクセス制御
  2. E2Eテストの追加

    • Playwright導入
    • 重要フローのテスト
  3. CI/CDパイプライン

    • GitHub Actions設定
    • 自動デプロイ
  4. 監視・ログ

    • Sentry導入
    • パフォーマンス監視

学習リソース


おわりに

シリーズ全4回、お読みいただきありがとうございました!

DDDとNext.js 15を組み合わせることで、保守性が高く、変更に強い、プロフェッショナルなシステムを構築できます。

最初は学習コストが高く感じるかもしれませんが、長期的には必ず価値を発揮します。ぜひ実践で活用してみてください!

質問やフィードバックは、コメント欄やTwitterでお待ちしています。

Happy Coding! 🚀


サンプルコード

本シリーズで紹介したコードの完全版は、近日中にGitHubで公開予定です。

この記事が役に立ったら、いいね・ブックマークをいただけると嬉しいです!

Discussion