はじめに
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 Library | PR毎 |
| 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での保守性を高めるためのポイントをまとめます。
- コードの一貫性: ESLint、Prettier、pre-commit hooksを活用してコードスタイルを統一
- コンポーネント設計: 単一責任の原則に従い、再利用可能なコンポーネントを設計
- ファイル構造: 機能別・役割別に整理された明確なディレクトリ構造を採用
- TypeScript: 厳格な型設定とZodによるランタイム検証で型安全性を確保
- パフォーマンス: 動的インポート、画像最適化、メモ化を適切に活用
- テスト: 単体テスト、統合テスト、E2Eテストを組み合わせた包括的なテスト戦略
これらのベストプラクティスを実践することで、長期的に安定したアプリケーションを構築できます。小さな改善から始めて、徐々にプロジェクト全体に適用していきましょう。
参考文献
- Next.js Documentation - Next.js公式ドキュメント
- TypeScript Handbook - TypeScript公式ハンドブック
- ESLint Documentation - ESLint公式ドキュメント
- Prettier Documentation - Prettier公式ドキュメント
- React Testing Library - React Testing Library公式ドキュメント
- Playwright Documentation - Playwright公式ドキュメント
- Zod Documentation - Zodバリデーションライブラリ