0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

React / Next.js コーディング規約テンプレート

Posted at

React / Next.js コーディング規約

目次

  1. プロジェクト構成
  2. コンポーネント設計
  3. スタイリング
  4. 状態管理
  5. パフォーマンス最適化
  6. 型定義
  7. 命名規則
  8. コードフォーマット
  9. テスト
  10. アクセシビリティ
  11. ドキュメント
  12. セキュリティ

プロジェクト構成

ディレクトリ構造

project-root/
├── .github/               # GitHub Actions 設定
├── .husky/                # Husky 設定(Git フック)
├── public/                # 静的アセット
├── src/                   # ソースコード
│   ├── app/               # App Router ルート (Next.js 13+)
│   ├── pages/             # Pages Router ルート
│   ├── components/        # コンポーネント
│   │   ├── common/        # 共通コンポーネント
│   │   ├── layout/        # レイアウト関連コンポーネント
│   │   └── features/      # 機能単位のコンポーネント
│   ├── hooks/             # カスタムフック
│   ├── lib/               # ユーティリティ関数
│   ├── services/          # API関連のサービス
│   ├── styles/            # グローバルスタイル
│   ├── types/             # 型定義
│   ├── constants/         # 定数
│   └── config/            # 設定ファイル
├── .eslintrc.js           # ESLint 設定
├── .prettierrc            # Prettier 設定
├── jest.config.js         # Jest 設定
├── next.config.js         # Next.js 設定
├── package.json           # 依存関係
└── tsconfig.json          # TypeScript 設定

環境変数

  • .env.local.env.development.env.production などのファイルで環境変数を管理する
  • 環境変数はNEXT_PUBLIC_プレフィックスを使用してクライアントサイドで利用可能な変数を明示する
  • 秘密情報は必ず .env.local に保存し、.gitignore に追加する
# サーバーサイドのみで利用
API_SECRET_KEY=xxxx

# クライアントサイドでも利用可能
NEXT_PUBLIC_API_URL=https://api.example.com

コンポーネント設計

コンポーネントの分類

  • Atoms: ボタン、入力フィールド、アイコンなどの最小単位のコンポーネント
  • Molecules: 複数のAtomを組み合わせた小さな機能単位(例:検索バー、ナビゲーションリンク)
  • Organisms: 複数のMoleculeを組み合わせた大きな機能単位(例:ヘッダー、フッター)
  • Templates: ページのレイアウト構造を定義
  • Pages: 実際のページコンテンツ

コンポーネントの基本ルール

  • 一つのコンポーネントは一つの責務のみを持つ
  • プロップスの型定義を必ず行う
  • 条件付きレンダリングはシンプルに保つ
  • コンポーネントは小さく保ち、300行を超える場合は分割を検討する
  • 大きすぎる関数も分割する(50行以上は検討対象)

ファイル構成

機能単位のディレクトリ構成

components/features/ProductList/
├── index.ts               # エクスポート用
├── ProductList.tsx        # メインコンポーネント
├── ProductItem.tsx        # サブコンポーネント
├── ProductFilter.tsx      # サブコンポーネント
├── ProductList.module.css # スタイル
└── ProductList.test.tsx   # テスト

コンポーネントの基本構造

// Button.tsx
import { ReactNode } from 'react';
import styles from './Button.module.css';

type ButtonProps = {
  children: ReactNode;
  variant?: 'primary' | 'secondary';
  size?: 'small' | 'medium' | 'large';
  onClick?: () => void;
  disabled?: boolean;
};

export const Button = ({
  children,
  variant = 'primary',
  size = 'medium',
  onClick,
  disabled = false,
}: ButtonProps) => {
  return (
    <button
      className={`${styles.button} ${styles[variant]} ${styles[size]}`}
      onClick={onClick}
      disabled={disabled}
    >
      {children}
    </button>
  );
};

コンポーネントをエクスポートする方法

  • index.ts ファイルを使用してコンポーネントをエクスポートする
  • default export よりも named export を優先する
// components/Button/index.ts
export * from './Button';

スタイリング

スタイリング手法

次のいずれかの方法を採用する(プロジェクト内では一貫性を保つ):

  1. CSS Modules: コンポーネントスコープのスタイリング
  2. Tailwind CSS: ユーティリティファーストのスタイリング
  3. Styled Components / Emotion: CSS-in-JS
  4. SASS / SCSS: 拡張CSS

CSS Modulesを使用する場合

// Button.module.css
.button {
  padding: 0.5rem 1rem;
  border-radius: 0.25rem;
}

.primary {
  background-color: #3182ce;
  color: white;
}

// Button.tsx
import styles from './Button.module.css';

export const Button = ({ children, variant = 'primary' }) => {
  return (
    <button className={`${styles.button} ${styles[variant]}`}>
      {children}
    </button>
  );
};

Tailwind CSSを使用する場合

// Button.tsx
export const Button = ({ children, variant = 'primary' }) => {
  const variantStyles = 
    variant === 'primary' 
      ? 'bg-blue-500 text-white hover:bg-blue-600' 
      : 'bg-gray-200 text-gray-800 hover:bg-gray-300';
  
  return (
    <button className={`px-4 py-2 rounded ${variantStyles}`}>
      {children}
    </button>
  );
};

状態管理

ローカル状態

  • 単一コンポーネントのステートには useState または useReducer を使用する
  • コンポーネント階層間の状態共有には Context APIを使用する
import { useState } from 'react';

export const Counter = () => {
  const [count, setCount] = useState(0);
  
  return (
    <div>
      <p>Count: {count}</p>
      <button onClick={() => setCount(count + 1)}>Increment</button>
    </div>
  );
};

グローバル状態

複雑なアプリケーションでは以下のいずれかを採用:

  • Context API + useReducer: 中規模アプリケーション向け
  • Zustand / Jotai: シンプルで軽量なグローバル状態
  • Redux Toolkit: 大規模かつ複雑なアプリケーション向け

Context APIの例

// UserContext.tsx
import { createContext, useContext, ReactNode, useState } from 'react';

type User = {
  id: string;
  name: string;
};

type UserContextType = {
  user: User | null;
  login: (user: User) => void;
  logout: () => void;
};

const UserContext = createContext<UserContextType | undefined>(undefined);

export const UserProvider = ({ children }: { children: ReactNode }) => {
  const [user, setUser] = useState<User | null>(null);
  
  const login = (user: User) => setUser(user);
  const logout = () => setUser(null);
  
  return (
    <UserContext.Provider value={{ user, login, logout }}>
      {children}
    </UserContext.Provider>
  );
};

export const useUser = () => {
  const context = useContext(UserContext);
  if (context === undefined) {
    throw new Error('useUser must be used within a UserProvider');
  }
  return context;
};

パフォーマンス最適化

メモ化

  • 不必要な再レンダリングを防ぐために React.memouseMemouseCallback を使用する
  • ただし、過剰な最適化は避け、パフォーマンスの問題が確認された場合に限り適用する
import { memo, useCallback } from 'react';

type ItemProps = {
  name: string;
  onDelete: (name: string) => void;
};

export const Item = memo(({ name, onDelete }: ItemProps) => {
  console.log(`Rendering ${name}`);
  
  return (
    <div>
      <span>{name}</span>
      <button onClick={() => onDelete(name)}>Delete</button>
    </div>
  );
});

// 親コンポーネント
export const ItemList = () => {
  const items = ['Item 1', 'Item 2', 'Item 3'];
  
  const handleDelete = useCallback((name: string) => {
    console.log(`Delete ${name}`);
  }, []);
  
  return (
    <div>
      {items.map(item => (
        <Item key={item} name={item} onDelete={handleDelete} />
      ))}
    </div>
  );
};

画像最適化

Next.jsの Image コンポーネントを使用して画像を最適化する:

import Image from 'next/image';

export const ProductImage = () => {
  return (
    <div className="relative h-64 w-full">
      <Image
        src="/products/product-1.jpg"
        alt="Product"
        fill
        sizes="(max-width: 768px) 100vw, 50vw"
        priority={false}
        className="object-cover"
      />
    </div>
  );
};

コード分割

  • next/dynamic または React.lazy を使用してコンポーネントを動的にインポートする
  • あまり使用されないコンポーネントやページ固有のコンポーネントを対象とする
// Next.js
import dynamic from 'next/dynamic';

const DynamicChart = dynamic(() => import('../components/Chart'), {
  loading: () => <p>Loading chart...</p>,
  ssr: false, // クライアントサイドでのみレンダリング
});

// React
import { lazy, Suspense } from 'react';

const LazyChart = lazy(() => import('../components/Chart'));

export const Dashboard = () => {
  return (
    <div>
      <h1>Dashboard</h1>
      <Suspense fallback={<p>Loading chart...</p>}>
        <LazyChart />
      </Suspense>
    </div>
  );
};

型定義

基本的な型定義のガイドライン

  • any 型の使用を避け、具体的な型を定義する
  • 複数のコンポーネントで共有される型は、専用の型定義ファイルに分離する
  • インターフェースの命名には接頭辞 I を付けない(例:UserProps は良いが、IUserProps は避ける)
// types/user.ts
export type User = {
  id: string;
  name: string;
  email: string;
  roles: string[];
};

// types/api.ts
export type ApiResponse<T> = {
  data: T;
  status: number;
  message: string;
};

export type ApiError = {
  status: number;
  message: string;
  details?: Record<string, string[]>;
};

プロップスの型定義

import { ButtonHTMLAttributes } from 'react';

// HTMLタグの属性を継承する場合
type ButtonProps = ButtonHTMLAttributes<HTMLButtonElement> & {
  variant?: 'primary' | 'secondary';
  size?: 'small' | 'medium' | 'large';
  isLoading?: boolean;
};

export const Button = ({
  variant = 'primary',
  size = 'medium',
  isLoading = false,
  children,
  ...props
}: ButtonProps) => {
  // 実装...
};

命名規則

ファイル命名

  • コンポーネント: PascalCase(例:Button.tsxUserProfile.tsx
  • フック: camelCaseで use プレフィックス(例:useAuth.tsuseWindowSize.ts
  • ユーティリティ: camelCase(例:formatDate.tsstringUtils.ts
  • CSS Modules: コンポーネントと同名(例:Button.module.css

変数とプロパティの命名

  • 一般変数: camelCase(例:userDataisLoading
  • 定数: UPPER_SNAKE_CASE(例:MAX_RETRY_COUNTAPI_BASE_URL
  • 真偽値: ishasshould などのプレフィックス(例:isActivehasPermission
  • イベントハンドラ: handle または on プレフィックス(例:handleSubmitonUserChange

コンポーネントとフックの命名

// コンポーネント
export const UserProfileCard = () => { /* ... */ };

// イベントハンドラ
const handleSubmit = () => { /* ... */ };

// カスタムフック
export const useUserProfile = () => {
  // ...
  return { user, loading, error, updateUser };
};

コードフォーマット

ESLintとPrettier

以下のツールを設定して使用する:

  • ESLint: コード品質の検証
  • Prettier: コードフォーマット
  • Husky: コミット前に自動でフォーマットやlintチェックを実行

.eslintrc.js の例

module.exports = {
  extends: [
    'next/core-web-vitals',
    'eslint:recommended',
    'plugin:react/recommended',
    'plugin:react-hooks/recommended',
    'plugin:@typescript-eslint/recommended',
    'plugin:jsx-a11y/recommended',
    'prettier',
  ],
  rules: {
    'react/react-in-jsx-scope': 'off',
    'react/prop-types': 'off',
    'react-hooks/rules-of-hooks': 'error',
    'react-hooks/exhaustive-deps': 'warn',
    '@typescript-eslint/explicit-module-boundary-types': 'off',
    '@typescript-eslint/no-unused-vars': ['warn', { argsIgnorePattern: '^_' }],
    'jsx-a11y/anchor-is-valid': 'warn',
  },
};

.prettierrc の例

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

テスト

テストのアプローチ

  • ユニットテスト: 個々のコンポーネントや関数のテスト
  • インテグレーションテスト: 複数のコンポーネントの相互作用のテスト
  • E2Eテスト: ユーザーの視点でのアプリケーション全体のテスト

テストのフレームワークとライブラリ

  • Jest: テストランナー
  • React Testing Library: コンポーネントのテスト
  • Cypress / Playwright: E2Eテスト

コンポーネントテストの例

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

describe('Button', () => {
  it('renders with correct text', () => {
    render(<Button>Click me</Button>);
    expect(screen.getByText('Click me')).toBeInTheDocument();
  });

  it('calls onClick when clicked', () => {
    const handleClick = jest.fn();
    render(<Button onClick={handleClick}>Click me</Button>);
    fireEvent.click(screen.getByText('Click me'));
    expect(handleClick).toHaveBeenCalledTimes(1);
  });

  it('is disabled when disabled prop is true', () => {
    render(<Button disabled>Click me</Button>);
    expect(screen.getByText('Click me')).toBeDisabled();
  });
});

アクセシビリティ

基本ルール

  • セマンティックなHTML要素を使用する
  • インタラクティブ要素には適切なARIAロールを設定する
  • フォーム要素には適切なラベルを付ける
  • キーボードナビゲーションをサポートする
  • 色のコントラスト比を確保する(WCAG AA基準以上)

アクセシブルなコンポーネントの例

// アクセシブルなモーダル
import { useEffect, useRef } from 'react';

type ModalProps = {
  isOpen: boolean;
  onClose: () => void;
  title: string;
  children: React.ReactNode;
};

export const Modal = ({ isOpen, onClose, title, children }: ModalProps) => {
  const modalRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const handleEscape = (e: KeyboardEvent) => {
      if (e.key === 'Escape') onClose();
    };

    if (isOpen) {
      document.addEventListener('keydown', handleEscape);
      // フォーカストラップの実装
      modalRef.current?.focus();
    }

    return () => {
      document.removeEventListener('keydown', handleEscape);
    };
  }, [isOpen, onClose]);

  if (!isOpen) return null;

  return (
    <div
      className="modal-overlay"
      onClick={onClose}
      role="presentation"
    >
      <div
        ref={modalRef}
        className="modal"
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
        tabIndex={-1}
        onClick={e => e.stopPropagation()}
      >
        <header>
          <h2 id="modal-title">{title}</h2>
          <button
            onClick={onClose}
            aria-label="Close modal"
            className="close-button"
          >
            ×
          </button>
        </header>
        <div className="modal-content">{children}</div>
      </div>
    </div>
  );
};

ドキュメント

コンポーネントドキュメント

  • Storybookを使用してコンポーネントの使用例を示す
  • JSDocスタイルのコメントでコンポーネントとプロップスを説明する
/**
 * プライマリボタンコンポーネント
 * ユーザーアクションのトリガーに使用します
 *
 * @example
 * ```tsx
 * <Button variant="primary" onClick={handleClick}>
 *   送信する
 * </Button>
 * ```
 */
export const Button = ({
  variant = 'primary',
  size = 'medium',
  children,
  ...props
}: ButtonProps) => {
  // 実装...
};

プロジェクトドキュメント

README.mdファイルに以下の情報を含める:

  • プロジェクトの概要と目的
  • 開発環境のセットアップ手順
  • 利用可能なスクリプトとコマンド
  • アーキテクチャの概要
  • コントリビューションのガイドライン

セキュリティ

セキュリティのベストプラクティス

  • ユーザー入力は常にサニタイズする
  • クロスサイトスクリプティング(XSS)を防止する
  • 機密情報はクライアント側には保存しない
  • APIルートでは適切な検証とエラーハンドリングを行う
  • 最新の依存関係を維持する
// 安全なデータレンダリング
import DOMPurify from 'dompurify';

export const SafeHTML = ({ html }: { html: string }) => {
  const sanitizedHTML = DOMPurify.sanitize(html);
  
  return <div dangerouslySetInnerHTML={{ __html: sanitizedHTML }} />;
};

// APIエンドポイントでのバリデーション
// pages/api/users.ts
import { NextApiRequest, NextApiResponse } from 'next';
import { z } from 'zod';

const UserSchema = z.object({
  name: z.string().min(2),
  email: z.string().email(),
  age: z.number().min(18).optional(),
});

export default async function handler(req: NextApiRequest, res: NextApiResponse) {
  if (req.method !== 'POST') {
    return res.status(405).json({ message: 'Method not allowed' });
  }
  
  try {
    const validatedData = UserSchema.parse(req.body);
    // データ処理...
    return res.status(201).json({ success: true });
  } catch (error) {
    return res.status(400).json({ message: 'Invalid input', error });
  }
}

0
1
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?