Documentation Next.js

はじめに

Next.jsでアプリケーションを開発していると、機能追加やバグ修正を重ねるうちにコードが複雑化し、メンテナンスが困難になることがあります。本記事では、プロジェクトを長期的に維持しやすくするための実践的なTipsを紹介します。

この記事の対象読者:

  • Next.jsでの開発経験があり、プロジェクトの品質向上を目指す方
  • チーム開発で一貫したコードスタイルを維持したい方
  • 長期運用を見据えた設計手法を学びたい方

この記事で学べること:

  • コードの一貫性を保つためのツール設定
  • 再利用可能なコンポーネント設計
  • 効率的なファイル構造の整理方法
  • TypeScriptの効果的な活用方法
  • テスト戦略の確立

保守性とは何か

保守性(Maintainability)とは、ソフトウェアの修正・拡張・理解のしやすさを指します。保守性の高いコードには以下の特徴があります。

特徴説明
可読性コードが読みやすく、意図が明確である
一貫性同じパターンが繰り返し使われている
モジュール性機能が適切に分離されている
テスト可能性単体テストが書きやすい構造である
拡張性新機能の追加が容易である

コードの一貫性を保つ

一貫性のあるコードスタイルは、保守性の基盤です。チーム全体で同じルールに従うことで、コードレビューの効率が上がり、バグの発見も容易になります。

ESLintとPrettierの設定

ESLintはコードの品質を検査するリンター、Prettierはコードフォーマッターです。両者を組み合わせることで、コーディング規約の自動チェックとフォーマットを実現できます。

# 必要なパッケージのインストール
npm install -D eslint prettier eslint-config-prettier eslint-plugin-react eslint-plugin-react-hooks @typescript-eslint/eslint-plugin @typescript-eslint/parser

.eslintrc.jsonの設定例:

{
  "extends": [
    "next/core-web-vitals",
    "eslint:recommended",
    "plugin:@typescript-eslint/recommended",
    "plugin:react/recommended",
    "plugin:react-hooks/recommended",
    "prettier"
  ],
  "parser": "@typescript-eslint/parser",
  "plugins": ["@typescript-eslint", "react"],
  "rules": {
    // 未使用変数をエラーとして検出
    "@typescript-eslint/no-unused-vars": "error",
    // any型の使用を警告
    "@typescript-eslint/no-explicit-any": "warn",
    // React 17以降ではimport Reactは不要
    "react/react-in-jsx-scope": "off",
    // コンポーネントのprops型定義を必須に
    "react/prop-types": "off"
  },
  "settings": {
    "react": {
      "version": "detect"
    }
  }
}

.prettierrcの設定例:

{
  "semi": true,
  "singleQuote": true,
  "tabWidth": 2,
  "trailingComma": "es5",
  "printWidth": 100,
  "arrowParens": "always"
}

Pre-commit hooksの導入

Huskyとlint-stagedを使用して、コミット前に自動でLintとフォーマットを実行する仕組みを構築します。これにより、品質の低いコードがリポジトリに混入することを防げます。

# Huskyとlint-stagedのインストール
npm install -D husky lint-staged

# Huskyの初期化
npx husky init

.husky/pre-commitの設定:

#!/usr/bin/env sh
. "$(dirname -- "$0")/_/husky.sh"

npx lint-staged

package.jsonにlint-stagedの設定を追加:

{
  "lint-staged": {
    "*.{ts,tsx}": [
      "eslint --fix",
      "prettier --write"
    ],
    "*.{json,md,css}": [
      "prettier --write"
    ]
  }
}

効率的なコンポーネント設計

Next.jsはコンポーネントベースのアーキテクチャを採用しています。保守性向上のためには、再利用可能で単一責任を持つコンポーネントを設計することが重要です。

単一責任のコンポーネント

1つのコンポーネントが1つの明確な役割を持つよう設計します。これにより、コンポーネントの理解・テスト・再利用が容易になります。

悪い例: 複数の責任を持つコンポーネント

// 悪い例: ユーザー情報の取得・表示・編集を1つのコンポーネントで行う
const UserProfile = () => {
  const [user, setUser] = useState(null);
  const [isEditing, setIsEditing] = useState(false);
  const [formData, setFormData] = useState({});

  useEffect(() => {
    // データ取得のロジック
    fetch('/api/user').then(res => res.json()).then(setUser);
  }, []);

  const handleSave = async () => {
    // 保存ロジック
    await fetch('/api/user', {
      method: 'PUT',
      body: JSON.stringify(formData)
    });
  };

  // 長大なレンダリングロジック...
  return (
    <div>
      {isEditing ? (
        // 編集フォーム
        <form>...</form>
      ) : (
        // 表示
        <div>...</div>
      )}
    </div>
  );
};

良い例: 責任を分離したコンポーネント

// hooks/useUser.ts - データ取得のロジックを分離
export const useUser = (userId: string) => {
  const [user, setUser] = useState<User | null>(null);
  const [isLoading, setIsLoading] = useState(true);
  const [error, setError] = useState<Error | null>(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        setIsLoading(true);
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('Failed to fetch user');
        const data = await response.json();
        setUser(data);
      } catch (e) {
        setError(e instanceof Error ? e : new Error('Unknown error'));
      } finally {
        setIsLoading(false);
      }
    };
    fetchUser();
  }, [userId]);

  return { user, isLoading, error };
};

// components/UserDisplay.tsx - 表示専用コンポーネント
type UserDisplayProps = {
  user: User;
  onEditClick: () => void;
};

export const UserDisplay = ({ user, onEditClick }: UserDisplayProps) => (
  <div className="user-profile">
    <img src={user.avatar} alt={user.name} />
    <h2>{user.name}</h2>
    <p>{user.email}</p>
    <button onClick={onEditClick}>編集</button>
  </div>
);

// components/UserEditForm.tsx - 編集フォームコンポーネント
type UserEditFormProps = {
  user: User;
  onSave: (data: Partial<User>) => Promise<void>;
  onCancel: () => void;
};

export const UserEditForm = ({ user, onSave, onCancel }: UserEditFormProps) => {
  const [formData, setFormData] = useState({
    name: user.name,
    email: user.email,
  });

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    await onSave(formData);
  };

  return (
    <form onSubmit={handleSubmit}>
      <input
        value={formData.name}
        onChange={(e) => setFormData((prev) => ({ ...prev, name: e.target.value }))}
      />
      <input
        value={formData.email}
        onChange={(e) => setFormData((prev) => ({ ...prev, email: e.target.value }))}
      />
      <button type="submit">保存</button>
      <button type="button" onClick={onCancel}>キャンセル</button>
    </form>
  );
};

// pages/user/[id].tsx - 親コンポーネントで組み合わせる
const UserProfilePage = () => {
  const router = useRouter();
  const { id } = router.query;
  const { user, isLoading, error } = useUser(id as string);
  const [isEditing, setIsEditing] = useState(false);

  if (isLoading) return <LoadingSpinner />;
  if (error) return <ErrorMessage message={error.message} />;
  if (!user) return <NotFound />;

  return isEditing ? (
    <UserEditForm
      user={user}
      onSave={async (data) => {
        await updateUser(user.id, data);
        setIsEditing(false);
      }}
      onCancel={() => setIsEditing(false)}
    />
  ) : (
    <UserDisplay user={user} onEditClick={() => setIsEditing(true)} />
  );
};

カスタムフックの活用

ロジックをコンポーネントから分離して、カスタムフックとして抽出すると、コードの再利用性が向上し、テストも書きやすくなります。

// hooks/useLocalStorage.ts
// ローカルストレージの読み書きを抽象化したカスタムフック
export function useLocalStorage<T>(key: string, initialValue: T) {
  // 初期値の取得(SSR対応)
  const [storedValue, setStoredValue] = useState<T>(() => {
    if (typeof window === 'undefined') {
      return initialValue;
    }
    try {
      const item = window.localStorage.getItem(key);
      return item ? JSON.parse(item) : initialValue;
    } catch (error) {
      console.error(`Error reading localStorage key "${key}":`, error);
      return initialValue;
    }
  });

  // 値を設定する関数
  const setValue = useCallback(
    (value: T | ((val: T) => T)) => {
      try {
        const valueToStore = value instanceof Function ? value(storedValue) : value;
        setStoredValue(valueToStore);
        if (typeof window !== 'undefined') {
          window.localStorage.setItem(key, JSON.stringify(valueToStore));
        }
      } catch (error) {
        console.error(`Error setting localStorage key "${key}":`, error);
      }
    },
    [key, storedValue]
  );

  return [storedValue, setValue] as const;
}

// 使用例
const ThemeToggle = () => {
  const [theme, setTheme] = useLocalStorage<'light' | 'dark'>('theme', 'light');

  return (
    <button onClick={() => setTheme(theme === 'light' ? 'dark' : 'light')}>
      現在のテーマ: {theme}
    </button>
  );
};

ファイル構造の整理

適切なファイル構造は、コードの管理と理解を容易にし、長期的な保守を助けます。

推奨ディレクトリ構造

src/
├── app/                    # App Router(Next.js 13以降)
│   ├── (auth)/            # 認証が必要なページグループ
│   │   ├── dashboard/
│   │   └── settings/
│   ├── api/               # APIルート
│   │   └── users/
│   ├── layout.tsx
│   └── page.tsx

├── components/            # 再利用可能なコンポーネント
│   ├── common/           # 汎用コンポーネント
│   │   ├── Button/
│   │   │   ├── Button.tsx
│   │   │   ├── Button.test.tsx
│   │   │   └── index.ts
│   │   └── Input/
│   ├── features/         # 機能別コンポーネント
│   │   ├── auth/
│   │   └── user/
│   └── layouts/          # レイアウトコンポーネント
│       ├── Header/
│       └── Footer/

├── hooks/                # カスタムフック
│   ├── useAuth.ts
│   ├── useLocalStorage.ts
│   └── index.ts

├── lib/                  # ライブラリ・ユーティリティ
│   ├── api/             # API関連
│   │   ├── client.ts
│   │   └── endpoints.ts
│   ├── utils/           # ユーティリティ関数
│   │   ├── date.ts
│   │   └── format.ts
│   └── constants/       # 定数
│       └── config.ts

├── types/               # 型定義
│   ├── api.ts
│   ├── user.ts
│   └── index.ts

└── styles/              # グローバルスタイル
    ├── globals.css
    └── variables.css

コンポーネントのディレクトリ構成

コンポーネントごとにディレクトリを作成し、関連ファイルをまとめることで、モジュール性が向上します。

components/common/Button/
├── Button.tsx           # コンポーネント本体
├── Button.test.tsx      # テストファイル
├── Button.module.css    # スタイル(CSS Modules使用時)
├── Button.stories.tsx   # Storybook用(使用時)
└── index.ts             # エクスポート用

index.tsでre-exportすることで、インポートパスを簡潔にできます:

// components/common/Button/index.ts
export { Button } from './Button';
export type { ButtonProps } from './Button';

// 使用時
import { Button } from '@/components/common/Button';

TypeScriptの効果的な活用

TypeScriptは型安全性を確保し、コードの可読性とメンテナンス性を大幅に向上させます。

厳格な型設定

tsconfig.jsonで厳格なオプションを有効にすることで、より安全なコードを書けます。

{
  "compilerOptions": {
    "strict": true,
    "noImplicitAny": true,
    "strictNullChecks": true,
    "noImplicitReturns": true,
    "noFallthroughCasesInSwitch": true,
    "noUncheckedIndexedAccess": true,
    "exactOptionalPropertyTypes": true
  }
}

型定義のベストプラクティス

// types/user.ts
// ベース型の定義
export type User = {
  id: string;
  name: string;
  email: string;
  avatar?: string;
  role: UserRole;
  createdAt: Date;
  updatedAt: Date;
};

// ユニオン型で役割を定義
export type UserRole = 'admin' | 'editor' | 'viewer';

// API レスポンスの型
export type UserResponse = {
  user: User;
  token: string;
};

// 部分的な更新用の型
export type UserUpdateInput = Partial<Pick<User, 'name' | 'email' | 'avatar'>>;

// コンポーネントProps用の型
export type UserCardProps = {
  user: User;
  onEdit?: (user: User) => void;
  onDelete?: (userId: string) => void;
  className?: string;
};

Zodによるランタイム型検証

APIレスポンスなど外部データには、Zodを使用してランタイムでの型検証を行います。

// lib/validators/user.ts
import { z } from 'zod';

// スキーマ定義
export const userSchema = z.object({
  id: z.string().uuid(),
  name: z.string().min(1).max(100),
  email: z.string().email(),
  avatar: z.string().url().optional(),
  role: z.enum(['admin', 'editor', 'viewer']),
  createdAt: z.string().datetime(),
  updatedAt: z.string().datetime(),
});

// スキーマから型を推論
export type User = z.infer<typeof userSchema>;

// API呼び出しでの使用例
export async function fetchUser(id: string): Promise<User> {
  const response = await fetch(`/api/users/${id}`);
  const data = await response.json();

  // ランタイムで型検証
  const result = userSchema.safeParse(data);
  if (!result.success) {
    console.error('Invalid user data:', result.error);
    throw new Error('Invalid user data received from API');
  }

  return result.data;
}

パフォーマンス最適化

パフォーマンスの最適化も保守性に関わる重要な要素です。コードの効率性を高めることで、開発がスムーズになり、ユーザー体験の向上にもつながります。

動的インポート

必要なときにのみコンポーネントを読み込むことで、初期ロード時間を短縮できます。

// 重いコンポーネントを動的にインポート
import dynamic from 'next/dynamic';

// ローディング状態を表示しながら遅延読み込み
const HeavyChart = dynamic(() => import('@/components/features/analytics/Chart'), {
  loading: () => <ChartSkeleton />,
  ssr: false, // クライアントサイドのみでレンダリング
});

// 条件付きで読み込み
const AdminPanel = dynamic(() => import('@/components/features/admin/Panel'), {
  loading: () => <LoadingSpinner />,
});

export default function DashboardPage() {
  const { user } = useAuth();

  return (
    <div>
      <h1>ダッシュボード</h1>
      <HeavyChart data={chartData} />
      {user?.role === 'admin' && <AdminPanel />}
    </div>
  );
}

画像最適化

Next.jsのnext/imageコンポーネントを使用して、自動で画像を最適化します。

import Image from 'next/image';

// 最適化された画像コンポーネント
export const OptimizedAvatar = ({ src, name }: { src: string; name: string }) => (
  <Image
    src={src}
    alt={`${name}のアバター`}
    width={100}
    height={100}
    // 優先読み込み(Above the foldの画像に使用)
    priority={false}
    // 遅延読み込みの設定
    loading="lazy"
    // プレースホルダー
    placeholder="blur"
    blurDataURL="data:image/jpeg;base64,/9j/4AAQSkZJRg..."
    // スタイル
    className="rounded-full"
  />
);

// レスポンシブ画像
export const ResponsiveImage = ({ src, alt }: { src: string; alt: string }) => (
  <div className="relative w-full aspect-video">
    <Image
      src={src}
      alt={alt}
      fill
      sizes="(max-width: 768px) 100vw, (max-width: 1200px) 50vw, 33vw"
      style={{ objectFit: 'cover' }}
    />
  </div>
);

メモ化による再レンダリング防止

import { memo, useMemo, useCallback } from 'react';

// メモ化されたコンポーネント
export const UserList = memo(function UserList({ users }: { users: User[] }) {
  return (
    <ul>
      {users.map((user) => (
        <UserListItem key={user.id} user={user} />
      ))}
    </ul>
  );
});

// 親コンポーネントでの使用
export const UsersPage = () => {
  const [filter, setFilter] = useState('');
  const { users } = useUsers();

  // フィルタリング結果をメモ化
  const filteredUsers = useMemo(
    () => users.filter((user) => user.name.includes(filter)),
    [users, filter]
  );

  // コールバック関数をメモ化
  const handleFilterChange = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setFilter(e.target.value);
  }, []);

  return (
    <div>
      <input value={filter} onChange={handleFilterChange} placeholder="ユーザー検索" />
      <UserList users={filteredUsers} />
    </div>
  );
};

テスト戦略の確立

テストは保守性を高めるために欠かせません。コードに変更を加えた際に、予期せぬバグが発生しないことを保証します。

テストの種類と使い分け

テストの種類目的ツール実行頻度
単体テスト個別の関数やコンポーネントの動作確認Jest, Vitestコミット毎
統合テスト複数のコンポーネントの連携確認React Testing LibraryPR毎
E2Eテストユーザー操作のシミュレーションPlaywright, Cypressデプロイ前

単体テストの例

// components/common/Button/Button.test.tsx
import { render, screen, fireEvent } from '@testing-library/react';
import { Button } from './Button';

describe('Button', () => {
  it('正しいテキストでレンダリングされる', () => {
    render(<Button>クリック</Button>);
    expect(screen.getByRole('button', { name: 'クリック' })).toBeInTheDocument();
  });

  it('クリック時にonClickが呼ばれる', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>クリック</Button>);

    fireEvent.click(screen.getByRole('button'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('disabled時はクリックできない', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick} disabled>クリック</Button>);

    fireEvent.click(screen.getByRole('button'));
    expect(handleClick).not.toHaveBeenCalled();
  });

  it('ローディング中はスピナーが表示される', () => {
    render(<Button isLoading>クリック</Button>);
    expect(screen.getByRole('button')).toHaveAttribute('aria-busy', 'true');
    expect(screen.getByTestId('loading-spinner')).toBeInTheDocument();
  });
});

カスタムフックのテスト

// hooks/useLocalStorage.test.ts
import { renderHook, act } from '@testing-library/react';
import { useLocalStorage } from './useLocalStorage';

describe('useLocalStorage', () => {
  beforeEach(() => {
    localStorage.clear();
  });

  it('初期値が返される', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));
    expect(result.current[0]).toBe('initial');
  });

  it('値を更新できる', () => {
    const { result } = renderHook(() => useLocalStorage('test-key', 'initial'));

    act(() => {
      result.current[1]('updated');
    });

    expect(result.current[0]).toBe('updated');
    expect(localStorage.getItem('test-key')).toBe('"updated"');
  });

  it('関数で値を更新できる', () => {
    const { result } = renderHook(() => useLocalStorage('count', 0));

    act(() => {
      result.current[1]((prev) => prev + 1);
    });

    expect(result.current[0]).toBe(1);
  });
});

E2Eテストの例(Playwright)

// e2e/auth.spec.ts
import { test, expect } from '@playwright/test';

test.describe('認証フロー', () => {
  test('ログインが正常に動作する', async ({ page }) => {
    // ログインページに移動
    await page.goto('/login');

    // フォームに入力
    await page.fill('[name="email"]', 'test@example.com');
    await page.fill('[name="password"]', 'password123');

    // ログインボタンをクリック
    await page.click('button[type="submit"]');

    // ダッシュボードにリダイレクトされることを確認
    await expect(page).toHaveURL('/dashboard');

    // ユーザー名が表示されていることを確認
    await expect(page.locator('[data-testid="user-name"]')).toHaveText('Test User');
  });

  test('無効な認証情報でエラーが表示される', async ({ page }) => {
    await page.goto('/login');

    await page.fill('[name="email"]', 'invalid@example.com');
    await page.fill('[name="password"]', 'wrongpassword');
    await page.click('button[type="submit"]');

    // エラーメッセージが表示されることを確認
    await expect(page.locator('[role="alert"]')).toHaveText(
      'メールアドレスまたはパスワードが正しくありません'
    );
  });
});

まとめ

Next.jsでの保守性を高めるためのポイントをまとめます。

  1. コードの一貫性: ESLint、Prettier、pre-commit hooksを活用してコードスタイルを統一
  2. コンポーネント設計: 単一責任の原則に従い、再利用可能なコンポーネントを設計
  3. ファイル構造: 機能別・役割別に整理された明確なディレクトリ構造を採用
  4. TypeScript: 厳格な型設定とZodによるランタイム検証で型安全性を確保
  5. パフォーマンス: 動的インポート、画像最適化、メモ化を適切に活用
  6. テスト: 単体テスト、統合テスト、E2Eテストを組み合わせた包括的なテスト戦略

これらのベストプラクティスを実践することで、長期的に安定したアプリケーションを構築できます。小さな改善から始めて、徐々にプロジェクト全体に適用していきましょう。

参考文献

円