mcpField
P
phase-6-ui-integration

phase-6-ui-integration

github.com/ww-w-ai開発60649 回インストール4 週間前に更新
リポジトリ

phase-6-ui-integration について

アプリ画面の実装とバックエンド API の統合に関する開発ガイドです。UI の状態管理、API クライアントとサービス層の構成、エラー処理、統合確認を扱います。

主な機能

  1. 01ページと機能の実装指針
  2. 02API クライアントとサービス層のパターン
  3. 03状態管理の推奨事項
  4. 04ロード状態とエラー処理
  5. 05API 統合チェックリスト

活用シーン

  1. 01デザインシステムを使った画面構築
  2. 02UI 機能とバックエンド API の接続
  3. 03API 呼び出しとエラー処理の整理
  4. 04ログによる UI と API の動作確認

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/ww-w-ai/bkit-claude-code --skill phase-6-ui-integration

インストール後、Claude Code で/phase-6-ui-integrationを入力して読み込まれたか確認します。

詳細

フェーズ 6:UI 実装 + API 統合

実際の UI 実装と API 統合

目的

デザインシステムのコンポーネントを使用して実際の画面を実装し、API と統合します。

このフェーズで行うこと

  1. ページの実装:各画面を開発します
  2. 状態管理:クライアントの状態を管理します
  3. API 統合:バックエンド API を呼び出します
  4. エラー処理:読み込み状態とエラー状態を処理します

成果物

src/
├── pages/              # Page components
│   ├── index.tsx
│   ├── login.tsx
│   └── ...
├── features/           # Feature-specific components
│   ├── auth/
│   ├── product/
│   └── ...
└── hooks/              # API call hooks
    ├── useAuth.ts
    └── useProducts.ts

docs/03-analysis/
└── ui-qa.md            # QA results

PDCA の適用

  • 計画:実装する画面や機能を定義します
  • 設計:コンポーネント構成と状態管理を設計します
  • 実行:UI 実装 + API 統合
  • 評価:Zero Script QA
  • 改善:バグを修正し、フェーズ 7 に進みます

レベル別の適用方法

レベル適用方法
Starter静的 UI のみ(API 統合なし)
Dynamic完全な統合
Enterprise完全な統合 + 最適化

API クライアントのアーキテクチャ

集約型 API クライアントが必要な理由
問題(API 呼び出しの分散)解決策(集約型クライアント)
エラー処理ロジックの重複共通エラーハンドラー
認証トークン処理の分散トークンの自動付与
レスポンス形式の不統一レスポンス型の標準化
エンドポイント変更時に複数箇所の変更が必要一元管理
テストやモックの作成が困難簡単にモックへ置き換え可能
3 層の API クライアント構成
┌─────────────────────────────────────────────────────────┐
│                    UI Components                         │
│              (pages, features, hooks)                    │
├─────────────────────────────────────────────────────────┤
│                    Service Layer                         │
│         (Domain-specific API call functions)             │
│    authService, productService, orderService, ...        │
├─────────────────────────────────────────────────────────┤
│                    API Client Layer                      │
│         (Common settings, interceptors, error handling)  │
│              apiClient (axios/fetch wrapper)             │
└─────────────────────────────────────────────────────────┘
フォルダー構成
src/
├── lib/
│   └── api/
│       ├── client.ts           # API client (axios/fetch wrapper)
│       ├── interceptors.ts     # Request/response interceptors
│       └── error-handler.ts    # Error handling logic
├── services/
│   ├── auth.service.ts         # Auth-related APIs
│   ├── product.service.ts      # Product-related APIs
│   └── order.service.ts        # Order-related APIs
├── types/
│   ├── api.types.ts            # Common API types
│   ├── auth.types.ts           # Auth domain types
│   └── product.types.ts        # Product domain types
└── hooks/
    ├── useAuth.ts              # Hooks using Service
    └── useProducts.ts

API クライアントの実装

1. 基本 API クライアント(lib/api/client.ts)
// lib/api/client.ts
import { ApiError, ApiResponse } from '@/types/api.types';

const BASE_URL = process.env.NEXT_PUBLIC_API_URL || '/api';

interface RequestConfig extends RequestInit {
  params?: Record<string, string>;
}

class ApiClient {
  private baseUrl: string;

  constructor(baseUrl: string) {
    this.baseUrl = baseUrl;
  }

  private async request<T>(
    endpoint: string,
    config: RequestConfig = {}
  ): Promise<ApiResponse<T>> {
    const { params, ...init } = config;

    // URL parameter handling
    const url = new URL(`${this.baseUrl}${endpoint}`);
    if (params) {
      Object.entries(params).forEach(([key, value]) => {
        url.searchParams.append(key, value);
      });
    }

    // Default header settings
    const headers = new Headers(init.headers);
    if (!headers.has('Content-Type')) {
      headers.set('Content-Type', 'application/json');
    }

    // Automatic auth token injection
    const token = this.getAuthToken();
    if (token) {
      headers.set('Authorization', `Bearer ${token}`);
    }

    try {
      const response = await fetch(url.toString(), {
        ...init,
        headers,
      });

      return this.handleResponse<T>(response);
    } catch (error) {
      throw this.handleNetworkError(error);
    }
  }

  private async handleResponse<T>(response: Response): Promise<ApiResponse<T>> {
    const data = await response.json();

    if (!response.ok) {
      throw new ApiError(
        data.error?.code || 'UNKNOWN_ERROR',
        data.error?.message || 'An error occurred',
        response.status,
        data.error?.details
      );
    }

    return data as ApiResponse<T>;
  }

  private handleNetworkError(error: unknown): ApiError {
    if (error instanceof TypeError && error.message === 'Failed to fetch') {
      return new ApiError('NETWORK_ERROR', 'Please check your network connection.', 0);
    }
    return new ApiError('UNKNOWN_ERROR', 'An unknown error occurred.', 0);
  }

  private getAuthToken(): string | null {
    if (typeof window === 'undefined') return null;
    return localStorage.getItem('auth_token');
  }

  // HTTP method wrappers
  get<T>(endpoint: string, params?: Record<string, string>) {
    return this.request<T>(endpoint, { method: 'GET', params });
  }

  post<T>(endpoint: string, body?: unknown) {
    return this.request<T>(endpoint, {
      method: 'POST',
      body: JSON.stringify(body),
    });
  }

  put<T>(endpoint: string, body?: unknown) {
    return this.request<T>(endpoint, {
      method: 'PUT',
      body: JSON.stringify(body),
    });
  }

  patch<T>(endpoint: string, body?: unknown) {
    return this.request<T>(endpoint, {
      method: 'PATCH',
      body: JSON.stringify(body),
    });
  }

  delete<T>(endpoint: string) {
    return this.request<T>(endpoint, { method: 'DELETE' });
  }
}

export const apiClient = new ApiClient(BASE_URL);
2. 共通型定義(types/api.types.ts)
// types/api.types.ts

// ===== Standard API Response Format (matches Phase 4) =====

/** Success response */
export interface ApiResponse<T> {
  data: T;
  meta?: {
    timestamp: string;
    requestId?: string;
  };
}

/** Paginated response */
export interface PaginatedResponse<T> extends ApiResponse<T[]> {
  pagination: {
    page: number;
    limit: number;
    total: number;
    totalPages: number;
  };
}

/** Error response */
export interface ApiErrorResponse {
  error: {
    code: string;
    message: string;
    details?: Array<{
      field: string;
      message: string;
    }>;
  };
}

// ===== Error Class =====

export class ApiError extends Error {
  constructor(
    public code: string,
    message: string,
    public status: number,
    public details?: Array<{ field: string; message: string }>
  ) {
    super(message);
    this.name = 'ApiError';
  }

  /** Check if validation error */
  isValidationError(): boolean {
    return this.code === 'VALIDATION_ERROR' && !!this.details;
  }

  /** Check if auth error */
  isAuthError(): boolean {
    return this.status === 401 || this.code === 'UNAUTHORIZED';
  }

  /** Check if forbidden error */
  isForbiddenError(): boolean {
    return this.status === 403 || this.code === 'FORBIDDEN';
  }

  /** Check if not found error */
  isNotFoundError(): boolean {
    return this.status === 404 || this.code === 'NOT_FOUND';
  }
}

// ===== Common Error Codes =====

export const ERROR_CODES = {
  // Client errors
  VALIDATION_ERROR: 'VALIDATION_ERROR',
  UNAUTHORIZED: 'UNAUTHORIZED',
  FORBIDDEN: 'FORBIDDEN',
  NOT_FOUND: 'NOT_FOUND',
  CONFLICT: 'CONFLICT',

  // Server errors
  INTERNAL_ERROR: 'INTERNAL_ERROR',
  SERVICE_UNAVAILABLE: 'SERVICE_UNAVAILABLE',

  // Network errors
  NETWORK_ERROR: 'NETWORK_ERROR',
  TIMEOUT_ERROR: 'TIMEOUT_ERROR',
} as const;

export type ErrorCode = typeof ERROR_CODES[keyof typeof ERROR_CODES];

サービスレイヤーパターン

ドメインごとのサービス分離
// services/auth.service.ts
import { apiClient } from '@/lib/api/client';
import { User, LoginRequest, LoginResponse, SignupRequest } from '@/types/auth.types';

export const authService = {
  /** Login */
  login(credentials: LoginRequest) {
    return apiClient.post<LoginResponse>('/auth/login', credentials);
  },

  /** Signup */
  signup(data: SignupRequest) {
    return apiClient.post<User>('/auth/signup', data);
  },

  /** Logout */
  logout() {
    return apiClient.post<void>('/auth/logout');
  },

  /** Get current user info */
  getMe() {
    return apiClient.get<User>('/auth/me');
  },

  /** Refresh token */
  refreshToken() {
    return apiClient.post<LoginResponse>('/auth/refresh');
  },
};

// services/product.service.ts
import { apiClient } from '@/lib/api/client';
import { Product, ProductFilter, CreateProductRequest } from '@/types/product.types';
import { PaginatedResponse } from '@/types/api.types';

export const productService = {
  /** Get product list */
  getList(filter?: ProductFilter) {
    const params = filter ? {
      page: String(filter.page || 1),
      limit: String(filter.limit || 20),
      ...(filter.category && { category: filter.category }),
      ...(filter.search && { search: filter.search }),
    } : undefined;

    return apiClient.get<PaginatedResponse<Product>>('/products', params);
  },

  /** Get product details */
  getById(id: string) {
    return apiClient.get<Product>(`/products/${id}`);
  },

  /** Create product */
  create(data: CreateProductRequest) {
    return apiClient.post<Product>('/products', data);
  },

  /** Update product */
  update(id: string, data: Partial<CreateProductRequest>) {
    return apiClient.patch<Product>(`/products/${id}`, data);
  },

  /** Delete product */
  delete(id: string) {
    return apiClient.delete<void>(`/products/${id}`);
  },
};

エラー処理パターン

グローバルエラーハンドラー
// lib/api/error-handler.ts
import { ApiError, ERROR_CODES } from '@/types/api.types';
import { toast } from 'sonner'; // or another toast library

interface ErrorHandlerOptions {
  showToast?: boolean;
  redirectOnAuth?: boolean;
  customMessages?: Record<string, string>;
}

export function handleApiError(
  error: unknown,
  options: ErrorHandlerOptions = {}
): void {
  const { showToast = true, redirectOnAuth = true, customMessages = {} } = options;

  if (!(error instanceof ApiError)) {
    console.error('Unexpected error:', error);
    if (showToast) {
      toast.error('An unknown error occurred.');
    }
    return;
  }

  // Use custom message if available
  const message = customMessages[error.code] || error.message;

  // Handle by error type
  switch (error.code) {
    case ERROR_CODES.UNAUTHORIZED:
      if (redirectOnAuth && typeof window !== 'undefined') {
        localStorage.removeItem('auth_token');
        window.location.href = '/login';
      }
      break;

    case ERROR_CODES.FORBIDDEN:
      if (showToast) toast.error('You do not have permission.');
      break;

    case ERROR_CODES.NOT_FOUND:
      if (showToast) toast.error('The requested resource was not found.');
      break;

    case ERROR_CODES.VALIDATION_ERROR:
      // Validation errors are handled by form
      break;

    case ERROR_CODES.NETWORK_ERROR:
      if (showToast) toast.error('Please check your network connection.');
      break;

    default:
      if (showToast) toast.error(message);
  }

  // Error logging (development environment)
  if (process.env.NODE_ENV === 'development') {
    console.error(`[API Error] ${error.code}:`, {
      message: error.message,
      status: error.status,
      details: error.details,
    });
  }
}
フックでのエラー処理
// hooks/useProducts.ts
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { productService } from '@/services/product.service';
import { handleApiError } from '@/lib/api/error-handler';
import { ProductFilter } from '@/types/product.types';

export function useProducts(filter?: ProductFilter) {
  return useQuery({
    queryKey: ['products', filter],
    queryFn: () => productService.getList(filter),
    // Auto error handling
    throwOnError: false,
    meta: {
      errorHandler: (error: unknown) => handleApiError(error),
    },
  });
}

export function useCreateProduct() {
  const queryClient = useQueryClient();

  return useMutation({
    mutationFn: productService.create,
    onSuccess: () => {
      // Invalidate cache
      queryClient.invalidateQueries({ queryKey: ['products'] });
    },
    onError: (error) => {
      handleApiError(error, {
        customMessages: {
          CONFLICT: 'Product name already exists.',
        },
      });
    },
  });
}

クライアントとサーバー間の型共有

型の一貫性を保つ方法
Method 1: Shared Package (Monorepo)
├── packages/
│   └── shared-types/       # Common types
│       ├── api.types.ts
│       ├── auth.types.ts
│       └── product.types.ts
├── apps/
│   ├── web/                # Frontend
│   └── api/                # Backend

Method 2: Auto-generate Types from API Spec
├── openapi.yaml            # OpenAPI spec
└── scripts/
    └── generate-types.ts   # Type auto-generation script

Method 3: tRPC / GraphQL CodeGen
└── Auto-infer types from schema
型定義の例
// types/auth.types.ts (client-server shared)

export interface User {
  id: string;
  email: string;
  name: string;
  role: 'user' | 'admin';
  createdAt: string;
  updatedAt: string;
}

export interface LoginRequest {
  email: string;
  password: string;
}

export interface LoginResponse {
  user: User;
  token: string;
  expiresAt: string;
}

export interface SignupRequest {
  email: string;
  password: string;
  name: string;
  termsAgreed: boolean;
}

API 統合パターン

基本パターン(fetch)
async function getProducts() {
  const response = await fetch('/api/products');
  if (!response.ok) throw new Error('Failed to fetch');
  return response.json();
}
React Query パターン
function useProducts() {
  return useQuery({
    queryKey: ['products'],
    queryFn: getProducts,
  });
}
SWR パターン
function useProducts() {
  return useSWR('/api/products', fetcher);
}

状態管理ガイド

Server state (API data) → React Query / SWR
Client state (UI state) → useState / useReducer
Global state (auth, etc.) → Context / Zustand
Form state → React Hook Form

Zero Script QA の適用

Validate UI behavior with logs:

[UI] Login button clicked
[STATE] isLoading: true
[API] POST /api/auth/login
[RESPONSE] { token: "...", user: {...} }
[STATE] isLoading: false, isLoggedIn: true
[NAVIGATE] → /dashboard
[RESULT] ✅ Login successful

API 統合チェックリスト

アーキテクチャ
  • API クライアントレイヤーの構築

    • 集約型 API クライアント(lib/api/client.ts)
    • 認証トークンの自動付与
    • 共通ヘッダーの設定
  • サービスレイヤーの分離

    • ドメインごとのサービスファイル(auth、product、order など)
    • 各サービスでは apiClient のみを使用
  • 型の一貫性

    • 共通 API 型定義(ApiResponse、ApiError)
    • ドメインごとの型定義(Request、Response)
    • サーバーとクライアント間で型を共有する方法を決定
エラー処理
  • エラーコードの標準化

    • フェーズ 4 の API 仕様に準拠したエラーコード
    • エラーコードごとにユーザーメッセージを定義
  • グローバルエラーハンドラー

    • 認証エラー発生時のリダイレクト
    • ネットワークエラーの処理
    • トースト通知
  • フォームのバリデーションエラー処理

    • フィールドごとのエラーメッセージの表示
    • サーバー側のバリデーションエラーとの連携
コーディング規約
  • API 呼び出しのルール

    • コンポーネント内で fetch を直接呼び出さない
    • hooks → services → apiClient の順序に従う
    • 同じデータへの重複呼び出しを防止(キャッシュ)
  • 命名規則

    • サービス:{domain}.service.ts
    • フック:use{Domain}{Action}.ts
    • 型:{domain}.types.ts

テンプレート

templates/pipeline/phase-6-ui.template.md を参照してください

次のフェーズ

フェーズ 7:SEO/セキュリティ → 機能が完成したら、最適化とセキュリティ強化を行います

コメント