AI時代のReact入門 第12回:APIとの連携 – データの取得と更新

「APIからデータを取得して、画面に表示したい…」

フロントエンド開発において、APIとの連携は避けては通れない重要なテーマです。特にNext.jsでの開発では、外部APIやバックエンドとの通信が日常的に発生します。

TS
// jQueryでのAPI呼び出し
$.ajax({
  url: '/api/users',
  success: function(data) {
    $('#userList').html(data);
  }
});

// Next.jsでのAPI呼び出し
const UserList = async () => {
  const response = await fetch('/api/users');
  const data = await response.json();
  return <div>{/* データの表示 */}</div>;
};

このように、APIとの連携方法も、モダンな開発手法では大きく変化しています。

今回は、V0でプロトタイプを作成し、Cursorで実装を改善していく流れで、Next.jsにおけるAPI連携の基本を学んでいきます。

データの取得から更新、エラーハンドリングまで、実践的なコンポーネント開発を通じて理解を深めていきましょう。

はじめに

モダンなWeb開発において、フロントエンドとバックエンドは明確に分離され、APIを介して通信を行うのが一般的です。Next.jsは、このようなアーキテクチャに最適化された機能を提供しています。

APIとは何か

APIは「Application Programming Interface」の略で、アプリケーション間でデータをやり取りするための約束事です。Web APIの場合、主にHTTPプロトコルを使用して通信を行います:

TS
// 基本的なAPI通信のパターン
// 1. データの取得(GET)
const users = await fetch('/api/users');

// 2. データの作成(POST)
await fetch('/api/users', {
  method: 'POST',
  body: JSON.stringify(newUser)
});

// 3. データの更新(PUT/PATCH)
await fetch('/api/users/1', {
  method: 'PUT',
  body: JSON.stringify(updatedUser)
});

// 4. データの削除(DELETE)
await fetch('/api/users/1', { 
  method: 'DELETE' 
});

Next.jsでのAPI連携の概要

Next.jsでは、以下のような方法でAPIとの連携を実装できます:

  1. クライアントサイドでのデータ取得
    • useEffectでのデータフェッチ
    • イベントハンドラでのAPI呼び出し
    • エラー処理とローディング状態の管理
  2. サーバーサイドでのデータ取得
    • Server ComponentsでのAPI呼び出し
    • データのプリフェッチ
    • エッジでのキャッシュ

今回は主にクライアントサイドでのAPI連携に焦点を当てて、実装方法を見ていきましょう。

次のセクションでは、まずデータの取得(GET)から始めていきます。

データの取得(GET)

データの取得は、API連携の中で最も基本的な操作です。Next.jsでは、組み込みのfetch APIを使用してデータを取得できます。

V0でのプロトタイプ検証

まず、シンプルなデータ取得のコンポーネントをV0に依頼してみましょう:

質問:
ユーザー一覧を表示するコンポーネントを作成したいです。
以下の機能を含めてください:
- APIからのデータ取得
- ローディング状態の表示
- エラーハンドリング
- 取得したデータの表示

Next.jsのクライアントコンポーネントとして
実装をお願いします。

V0は以下のような例を示してくれます:

TS
'use client';

import { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

const UserList = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUsers = async () => {
      try {
        const response = await fetch('/api/users');
        const data = await response.json();
        setUsers(data);
      } catch (err) {
        setError('Failed to fetch users');
      } finally {
        setLoading(false);
      }
    };

    fetchUsers();
  }, []);

  if (loading) return <div>Loading...</div>;
  if (error) return <div>Error: {error}</div>;

  return (
    <div>
      <h1>Users</h1>
      <ul>
        {users.map(user => (
          <li key={user.id}>
            {user.name} ({user.email})
          </li>
        ))}
      </ul>
    </div>
  );
};

export default UserList;

では、このコードをベースにCursorでの実装改善に進みましょう。

Cursorでの実践的な実装

1. V0が生成したコードをCursorにコピーします。

2. Cursorのチャットパネルで以下のように質問します:

質問:
このUserListコンポーネントを改善したいです:
- データの再取得機能
- ページネーション
- 検索機能
実装案を提示してください。

3. Cursorからの提案に基づいて、コードを改善していきます:

TS
'use client';

import { useState, useEffect, useCallback } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
}

interface FetchUsersParams {
  page: number;
  search?: string;
}

const UserList = () => {
  const [users, setUsers] = useState<User[]>([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState<string | null>(null);
  const [page, setPage] = useState(1);
  const [search, setSearch] = useState('');

  const fetchUsers = useCallback(async ({ page, search }: FetchUsersParams) => {
    setLoading(true);
    setError(null);
    
    try {
      const searchParams = new URLSearchParams({
        page: String(page),
        ...(search && { search })
      });
      
      const response = await fetch(`/api/users?${searchParams}`);
      if (!response.ok) throw new Error('Failed to fetch users');
      
      const data = await response.json();
      setUsers(data);
    } catch (err) {
      setError(err instanceof Error ? err.message : 'Failed to fetch users');
    } finally {
      setLoading(false);
    }
  }, []);

  useEffect(() => {
    fetchUsers({ page, search });
  }, [fetchUsers, page, search]);

  const handleRefresh = () => {
    fetchUsers({ page, search });
  };

  const handleSearch = (e: React.ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value);
    setPage(1); // 検索時はページをリセット
  };

  return (
    <div className="space-y-4">
      {/* 検索とリフレッシュ */}
      <div className="flex gap-2">
        <input
          type="text"
          value={search}
          onChange={handleSearch}
          placeholder="Search users..."
          className="px-4 py-2 border rounded"
        />
        <button
          onClick={handleRefresh}
          className="px-4 py-2 bg-blue-500 text-white rounded"
        >
          Refresh
        </button>
      </div>

      {/* エラーとローディング */}
      {error && (
        <div className="p-4 bg-red-100 text-red-700 rounded">
          Error: {error}
        </div>
      )}
      
      {loading ? (
        <div className="p-4 text-center">Loading...</div>
      ) : (
        <>
          {/* ユーザーリスト */}
          <ul className="divide-y">
            {users.map(user => (
              <li key={user.id} className="py-2">
                <h3 className="font-medium">{user.name}</h3>
                <p className="text-gray-600">{user.email}</p>
              </li>
            ))}
          </ul>

          {/* ページネーション */}
          <div className="flex justify-center gap-2">
            <button
              onClick={() => setPage(p => Math.max(1, p - 1))}
              disabled={page === 1}
              className="px-4 py-2 bg-gray-100 rounded disabled:opacity-50"
            >
              Previous
            </button>
            <span className="px-4 py-2">Page {page}</span>
            <button
              onClick={() => setPage(p => p + 1)}
              disabled={users.length === 0}
              className="px-4 py-2 bg-gray-100 rounded disabled:opacity-50"
            >
              Next
            </button>
          </div>
        </>
      )}
    </div>
  );
};

export default UserList;

主な改善点:

  1. データの再取得機能の追加
  2. 検索機能の実装
  3. ページネーションの追加
  4. エラー表示の改善
  5. ローディング状態の視覚的な表示
  6. TypeScriptの型安全性の向上

次のセクションでは、データの更新(POST/PUT)について見ていきましょう。

データの更新(POST/PUT)

データの取得に続いて、データの作成(POST)と更新(PUT)の実装を見ていきましょう。

これらの操作は、フォームの送信やユーザーアクションに応じてデータを更新する際に必要となります。

V0でのプロトタイプ検証

ユーザー情報を編集するフォームコンポーネントをV0に依頼してみましょう:

質問:
ユーザー情報を編集するフォームコンポーネントを作成したいです。以下の機能を含めてください:
- 既存ユーザーの情報取得
- フォームでの情報編集
- APIへの更新リクエスト
- 送信中の状態管理
- エラーハンドリング

Next.jsのクライアントコンポーネントとして実装をお願いします。

V0は以下のような例を示してくれます:

TS
'use client';

import { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

interface UserFormProps {
  userId: number;
  onSuccess?: () => void;
}

const UserForm = ({ userId, onSuccess }: UserFormProps) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`);
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError('Failed to fetch user');
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]);

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!user) return;

    setSubmitting(true);
    setError(null);

    try {
      const response = await fetch(`/api/users/${userId}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(user),
      });

      if (!response.ok) throw new Error('Failed to update user');

      onSuccess?.();
    } catch (err) {
      setError('Failed to update user');
    } finally {
      setSubmitting(false);
    }
  };

  if (loading) return <div>Loading...</div>;
  if (!user) return <div>User not found</div>;

  return (
    <form onSubmit={handleSubmit}>
      {/* フォームの内容 */}
    </form>
  );
};

export default UserForm;

では、このコードをベースにCursorでの実装改善に進みましょう。

Cursorでの実践的な実装

1. V0が生成したコードをCursorにコピーします。

2. Cursorのチャットパネルで以下のように質問します:

質問:
このUserFormコンポーネントを改善したいです:
- フォームのバリデーション
- フィールドごとの更新状態
- 確認ダイアログの追加
- 送信中のUI改善
実装案を提示してください。

3. Cursorからの提案に基づいて、コードを改善していきます:

TS
'use client';

import { useState, useEffect } from 'react';

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

interface UserFormProps {
  userId: number;
  onSuccess?: () => void;
}

interface FormErrors {
  name?: string;
  email?: string;
}

const UserForm = ({ userId, onSuccess }: UserFormProps) => {
  const [user, setUser] = useState<User | null>(null);
  const [loading, setLoading] = useState(true);
  const [submitting, setSubmitting] = useState(false);
  const [error, setError] = useState<string | null>(null);
  const [errors, setErrors] = useState<FormErrors>({});
  const [showConfirm, setShowConfirm] = useState(false);

  useEffect(() => {
    const fetchUser = async () => {
      try {
        const response = await fetch(`/api/users/${userId}`);
        if (!response.ok) throw new Error('User not found');
        const data = await response.json();
        setUser(data);
      } catch (err) {
        setError('Failed to fetch user');
      } finally {
        setLoading(false);
      }
    };

    fetchUser();
  }, [userId]);

  const validateForm = (): boolean => {
    const newErrors: FormErrors = {};

    if (!user?.name.trim()) {
      newErrors.name = 'Name is required';
    }

    if (!user?.email.trim()) {
      newErrors.email = 'Email is required';
    } else if (!/^[A-Z0-9._%+-]+@[A-Z0-9.-]+\.[A-Z]{2,}$/i.test(user.email)) {
      newErrors.email = 'Invalid email address';
    }

    setErrors(newErrors);
    return Object.keys(newErrors).length === 0;
  };

  const handleSubmit = async (e: React.FormEvent) => {
    e.preventDefault();
    if (!user) return;

    if (!validateForm()) return;

    setShowConfirm(true);
  };

  const handleConfirm = async () => {
    if (!user) return;

    setSubmitting(true);
    setError(null);

    try {
      const response = await fetch(`/api/users/${userId}`, {
        method: 'PUT',
        headers: {
          'Content-Type': 'application/json',
        },
        body: JSON.stringify(user),
      });

      if (!response.ok) throw new Error('Failed to update user');

      onSuccess?.();
    } catch (err) {
      setError('Failed to update user');
    } finally {
      setSubmitting(false);
      setShowConfirm(false);
    }
  };

  const handleChange = (field: keyof User, value: string) => {
    if (!user) return;
    setUser({ ...user, [field]: value });
    // フィールド変更時にそのフィールドのエラーをクリア
    setErrors(prev => ({ ...prev, [field]: undefined }));
  };

  if (loading) return (
    <div className="flex justify-center p-4">
      <div className="animate-spin rounded-full h-8 w-8 border-b-2 border-blue-500" />
    </div>
  );
  
  if (!user) return (
    <div className="p-4 bg-red-100 text-red-700 rounded">
      User not found
    </div>
  );

  return (
    <>
      <form onSubmit={handleSubmit} className="space-y-4">
        <div>
          <label className="block text-sm font-medium text-gray-700">
            Name
          </label>
          <input
            type="text"
            value={user.name}
            onChange={e => handleChange('name', e.target.value)}
            className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm
              ${errors.name ? 'border-red-500' : ''}`}
            disabled={submitting}
          />
          {errors.name && (
            <p className="mt-1 text-sm text-red-500">{errors.name}</p>
          )}
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700">
            Email
          </label>
          <input
            type="email"
            value={user.email}
            onChange={e => handleChange('email', e.target.value)}
            className={`mt-1 block w-full rounded-md border-gray-300 shadow-sm
              ${errors.email ? 'border-red-500' : ''}`}
            disabled={submitting}
          />
          {errors.email && (
            <p className="mt-1 text-sm text-red-500">{errors.email}</p>
          )}
        </div>

        <div>
          <label className="block text-sm font-medium text-gray-700">
            Role
          </label>
          <select
            value={user.role}
            onChange={e => handleChange('role', e.target.value)}
            className="mt-1 block w-full rounded-md border-gray-300 shadow-sm"
            disabled={submitting}
          >
            <option value="user">User</option>
            <option value="admin">Admin</option>
          </select>
        </div>

        {error && (
          <div className="p-4 bg-red-100 text-red-700 rounded">
            {error}
          </div>
        )}

        <button
          type="submit"
          className={`w-full py-2 px-4 border border-transparent rounded-md shadow-sm text-white
            bg-blue-500 hover:bg-blue-600 focus:outline-none focus:ring-2 focus:ring-offset-2
            focus:ring-blue-500 ${submitting ? 'opacity-50 cursor-not-allowed' : ''}`}
          disabled={submitting}
        >
          {submitting ? 'Updating...' : 'Update User'}
        </button>
      </form>

      {/* 確認ダイアログ */}
      {showConfirm && (
        <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
          <div className="bg-white p-4 rounded max-w-sm w-full mx-4">
            <h3 className="text-lg font-medium">Confirm Update</h3>
            <p className="mt-2">Are you sure you want to update this user?</p>
            <div className="mt-4 flex gap-2">
              <button
                onClick={handleConfirm}
                className="flex-1 py-2 px-4 bg-blue-500 text-white rounded"
                disabled={submitting}
              >
                {submitting ? 'Updating...' : 'Yes, Update'}
              </button>
              <button
                onClick={() => setShowConfirm(false)}
                className="flex-1 py-2 px-4 bg-gray-200 rounded"
                disabled={submitting}
              >
                Cancel
              </button>
            </div>
          </div>
        </div>
      )}
    </>
  );
};

export default UserForm;

主な改善点:

  1. フォームバリデーションの追加
  2. フィールドごとのエラー表示
  3. 確認ダイアログの実装
  4. 送信中の UI 改善
  5. アクセシビリティの向上
  6. エラー表示の改善

次のセクションでは、エラーハンドリングとローディング状態の管理について、より詳しく見ていきましょう。

エラーハンドリングとローディング

API連携において、エラーハンドリングとローディング状態の管理は非常に重要です。ユーザー体験を向上させ、問題が発生した際に適切なフィードバックを提供するために、これらを適切に実装する必要があります。

V0でのプロトタイプ検証

エラーハンドリングとローディング状態を管理するカスタムフックをV0に依頼してみましょう:

質問:
APIリクエストのエラーハンドリングとローディング状態を管理するカスタムフックを作成したいです。
以下の機能を含めてください:
- ローディング状態の管理
- エラーメッセージの管理
- リトライ機能
- タイムアウト処理

実装例を示してください。

V0は以下のような例を示してくれます:

TS
'use client';

import { useState, useCallback } from 'react';

interface RequestState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
}

interface UseApiOptions {
  timeout?: number;
  retries?: number;
}

function useApi<T>(url: string, options: UseApiOptions = {}) {
  const [state, setState] = useState<RequestState<T>>({
    data: null,
    loading: false,
    error: null
  });

  const execute = useCallback(async (fetchOptions?: RequestInit) => {
    setState(prev => ({ ...prev, loading: true, error: null }));

    let attempt = 0;
    const maxRetries = options.retries ?? 3;
    const timeoutDuration = options.timeout ?? 5000;

    const fetchWithTimeout = async (): Promise<T> => {
      const controller = new AbortController();
      const timeoutId = setTimeout(() => controller.abort(), timeoutDuration);

      try {
        const response = await fetch(url, {
          ...fetchOptions,
          signal: controller.signal
        });
        
        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }
        
        const data = await response.json();
        return data;
      } finally {
        clearTimeout(timeoutId);
      }
    };

    try {
      while (attempt < maxRetries) {
        try {
          const data = await fetchWithTimeout();
          setState({ data, loading: false, error: null });
          return;
        } catch (err) {
          attempt++;
          if (attempt === maxRetries) throw err;
          // 再試行前に待機
          await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
        }
      }
    } catch (err) {
      setState(prev => ({
        ...prev,
        loading: false,
        error: err instanceof Error ? err.message : 'An error occurred'
      }));
    }
  }, [url, options]);

  return { ...state, execute };
}

export default useApi;

では、このコードをベースにCursorでの実装改善に進みましょう。

1. V0が生成したコードをCursorにコピーします。

2. Cursorのチャットパネルで以下のように質問します:

質問:
このuseApiフックを改善したいです:
- 型の安全性の向上
- キャッシュ機能の追加
- エラーメッセージのカスタマイズ
- 進行状況の追跡
実装案を提示してください。

3. Cursorからの提案に基づいて、コードを改善していきます:

TS
'use client';

import { useState, useCallback, useRef } from 'react';

interface RequestState<T> {
  data: T | null;
  loading: boolean;
  error: string | null;
  progress: number;
}

interface UseApiOptions<T> {
  timeout?: number;
  retries?: number;
  cacheTime?: number;
  onError?: (error: Error) => string;
  initialData?: T;
}

// キャッシュの型定義
interface CacheEntry<T> {
  data: T;
  timestamp: number;
}

const cache = new Map<string, CacheEntry<any>>();

function useApi<T>(url: string, options: UseApiOptions<T> = {}) {
  const {
    timeout = 5000,
    retries = 3,
    cacheTime = 5 * 60 * 1000, // 5分
    onError,
    initialData
  } = options;

  const [state, setState] = useState<RequestState<T>>({
    data: initialData ?? null,
    loading: false,
    error: null,
    progress: 0
  });

  // AbortControllerの参照を保持
  const abortControllerRef = useRef<AbortController>();

  const checkCache = useCallback(() => {
    const cached = cache.get(url);
    if (cached && Date.now() - cached.timestamp < cacheTime) {
      return cached.data;
    }
    return null;
  }, [url, cacheTime]);

  const updateCache = useCallback((data: T) => {
    cache.set(url, {
      data,
      timestamp: Date.now()
    });
  }, [url]);

  const execute = useCallback(async (fetchOptions?: RequestInit) => {
    // 既存のリクエストをキャンセル
    abortControllerRef.current?.abort();
    const controller = new AbortController();
    abortControllerRef.current = controller;

    setState(prev => ({ ...prev, loading: true, error: null, progress: 0 }));

    // キャッシュをチェック
    const cachedData = checkCache();
    if (cachedData) {
      setState({
        data: cachedData,
        loading: false,
        error: null,
        progress: 100
      });
      return;
    }

    let attempt = 0;

    const fetchWithTimeout = async (): Promise<T> => {
      const timeoutId = setTimeout(() => controller.abort(), timeout);

      try {
        const response = await fetch(url, {
          ...fetchOptions,
          signal: controller.signal
        });

        if (!response.ok) {
          throw new Error(`HTTP error! status: ${response.status}`);
        }

        // レスポンスサイズを取得
        const contentLength = Number(response.headers.get('Content-Length')) || 0;
        const reader = response.body?.getReader();
        const chunks: Uint8Array[] = [];
        let receivedLength = 0;

        // レスポンスを読み込み、進行状況を更新
        while (reader) {
          const { done, value } = await reader.read();
          if (done) break;

          chunks.push(value);
          receivedLength += value.length;

          if (contentLength) {
            const progress = (receivedLength / contentLength) * 100;
            setState(prev => ({ ...prev, progress }));
          }
        }

        // レスポンスを結合
        const chunksAll = new Uint8Array(receivedLength);
        let position = 0;
        for (const chunk of chunks) {
          chunksAll.set(chunk, position);
          position += chunk.length;
        }

        const data = JSON.parse(new TextDecoder().decode(chunksAll));
        return data;
      } finally {
        clearTimeout(timeoutId);
      }
    };

    try {
      while (attempt < retries) {
        try {
          const data = await fetchWithTimeout();
          updateCache(data);
          setState({
            data,
            loading: false,
            error: null,
            progress: 100
          });
          return;
        } catch (err) {
          attempt++;
          if (attempt === retries) throw err;
          await new Promise(resolve => setTimeout(resolve, 1000 * attempt));
        }
      }
    } catch (err) {
      const errorMessage = onError?.(err as Error) ?? 
        (err instanceof Error ? err.message : 'An error occurred');
      
      setState(prev => ({
        ...prev,
        loading: false,
        error: errorMessage,
        progress: 0
      }));
    }
  }, [url, timeout, retries, onError, checkCache, updateCache]);

  // クリーンアップ関数
  const cancel = useCallback(() => {
    abortControllerRef.current?.abort();
    setState(prev => ({ ...prev, loading: false, progress: 0 }));
  }, []);

  return { ...state, execute, cancel };
}

export default useApi;

主な改善点:

  1. プログレス表示の追加
  2. キャッシュ機能の実装
  3. リクエストのキャンセル機能
  4. カスタムエラーハンドリング
  5. 型の安全性向上
  6. 初期データのサポート

次のセクションでは、これまでの機能を組み合わせた実践的なユーザー管理画面の作成を見ていきましょう。

実践:ユーザー管理画面の作成

これまで学んだAPI連携の知識を活用して、実践的なユーザー管理画面を作成していきましょう。データの取得、更新、エラーハンドリング、ローディング状態の管理など、すべての要素を組み合わせた実装を行います。

V0でのプロトタイプ検証

まず、基本的な機能を持つユーザー管理画面をV0に依頼してみましょう:

質問:
以下の機能を持つユーザー管理画面のコンポーネントを作成してください:
- ユーザー一覧の表示と検索
- ユーザー情報の編集
- 新規ユーザーの追加
- 削除機能
- エラー処理とローディング表示

先ほど作成したuseApiフックを使用した実装例を示してください。

V0は以下のような例を示してくれます:

TS
'use client';

import { useState } from 'react';
import useApi from './useApi';

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

const UserManagement = () => {
  const [search, setSearch] = useState('');
  const [selectedUser, setSelectedUser] = useState<User | null>(null);

  const {
    data: users,
    loading: loadingUsers,
    error: usersError,
    execute: fetchUsers
  } = useApi<User[]>('/api/users');

  const {
    loading: updating,
    error: updateError,
    execute: updateUser
  } = useApi<User>(`/api/users/${selectedUser?.id}`);

  // コンポーネントの実装
  return (
    <div>
      {/* UIの実装 */}
    </div>
  );
};

export default UserManagement;

では、このコードをベースにCursorでの実装改善に進みましょう。

Cursorでの実践的な実装

1. V0が生成したコードをCursorにコピーします。

2. Cursorのチャットパネルで以下のように質問します:

質問:
このユーザー管理画面コンポーネントを改善したいです:
- モーダルでのフォーム表示
- 一括操作機能
- アクションの確認ダイアログ
- ステータスメッセージの表示
実装案を提示してください。

3. Cursorからの提案に基づいて、コードを改善していきます:

TS
'use client';

import { useState, useCallback } from 'react';
import useApi from './useApi';

interface User {
  id: number;
  name: string;
  email: string;
  role: 'admin' | 'user';
}

type ModalType = 'create' | 'edit' | 'delete' | null;

const UserManagement = () => {
  // 状態管理
  const [search, setSearch] = useState('');
  const [selectedUser, setSelectedUser] = useState<User | null>(null);
  const [selectedUsers, setSelectedUsers] = useState<number[]>([]);
  const [modalType, setModalType] = useState<ModalType>(null);
  const [statusMessage, setStatusMessage] = useState('');

  // API Hooks
  const {
    data: users = [],
    loading: loadingUsers,
    error: usersError,
    execute: fetchUsers
  } = useApi<User[]>('/api/users');

  const {
    loading: updating,
    error: updateError,
    execute: updateUser
  } = useApi<User>(`/api/users/${selectedUser?.id}`);

  const {
    loading: deleting,
    error: deleteError,
    execute: deleteUser
  } = useApi<void>(`/api/users/${selectedUser?.id}`);

  // イベントハンドラ
  const handleSearch = useCallback((e: React.ChangeEvent<HTMLInputElement>) => {
    setSearch(e.target.value);
  }, []);

  const handleUserSelect = useCallback((user: User) => {
    setSelectedUser(user);
    setModalType('edit');
  }, []);

  const handleBulkSelect = useCallback((id: number) => {
    setSelectedUsers(prev => 
      prev.includes(id) 
        ? prev.filter(userId => userId !== id)
        : [...prev, id]
    );
  }, []);

  const handleDelete = useCallback(async () => {
    if (!selectedUser) return;

    try {
      await deleteUser({ method: 'DELETE' });
      setStatusMessage('User deleted successfully');
      setModalType(null);
      fetchUsers();
    } catch (error) {
      setStatusMessage('Failed to delete user');
    }
  }, [selectedUser, deleteUser, fetchUsers]);

  return (
    <div className="container mx-auto p-4">
      {/* ツールバー */}
      <div className="flex justify-between items-center mb-4">
        <div className="flex gap-2">
          <input
            type="text"
            value={search}
            onChange={handleSearch}
            placeholder="Search users..."
            className="px-4 py-2 border rounded"
          />
          <button
            onClick={() => setModalType('create')}
            className="px-4 py-2 bg-blue-500 text-white rounded"
          >
            Add User
          </button>
        </div>
        {selectedUsers.length > 0 && (
          <div className="flex gap-2">
            <button
              onClick={() => setModalType('delete')}
              className="px-4 py-2 bg-red-500 text-white rounded"
            >
              Delete Selected ({selectedUsers.length})
            </button>
          </div>
        )}
      </div>

      {/* ステータスメッセージ */}
      {statusMessage && (
        <div className="mb-4 p-4 bg-blue-100 text-blue-700 rounded">
          {statusMessage}
        </div>
      )}

      {/* エラーメッセージ */}
      {(usersError || updateError || deleteError) && (
        <div className="mb-4 p-4 bg-red-100 text-red-700 rounded">
          {usersError || updateError || deleteError}
        </div>
      )}

      {/* ユーザーリスト */}
      {loadingUsers ? (
        <div className="text-center py-4">Loading...</div>
      ) : (
        <div className="bg-white shadow rounded">
          <table className="min-w-full">
            <thead>
              <tr>
                <th className="px-4 py-2">
                  <input
                    type="checkbox"
                    onChange={() => {
                      const allIds = users.map(user => user.id);
                      setSelectedUsers(
                        selectedUsers.length === allIds.length ? [] : allIds
                      );
                    }}
                    checked={
                      users.length > 0 && 
                      selectedUsers.length === users.length
                    }
                  />
                </th>
                <th className="px-4 py-2">Name</th>
                <th className="px-4 py-2">Email</th>
                <th className="px-4 py-2">Role</th>
                <th className="px-4 py-2">Actions</th>
              </tr>
            </thead>
            <tbody>
              {users
                .filter(user => 
                  user.name.toLowerCase().includes(search.toLowerCase()) ||
                  user.email.toLowerCase().includes(search.toLowerCase())
                )
                .map(user => (
                  <tr key={user.id}>
                    <td className="px-4 py-2">
                      <input
                        type="checkbox"
                        checked={selectedUsers.includes(user.id)}
                        onChange={() => handleBulkSelect(user.id)}
                      />
                    </td>
                    <td className="px-4 py-2">{user.name}</td>
                    <td className="px-4 py-2">{user.email}</td>
                    <td className="px-4 py-2">{user.role}</td>
                    <td className="px-4 py-2">
                      <button
                        onClick={() => handleUserSelect(user)}
                        className="text-blue-500 hover:text-blue-700 mr-2"
                      >
                        Edit
                      </button>
                      <button
                        onClick={() => {
                          setSelectedUser(user);
                          setModalType('delete');
                        }}
                        className="text-red-500 hover:text-red-700"
                      >
                        Delete
                      </button>
                    </td>
                  </tr>
                ))}
            </tbody>
          </table>
        </div>
      )}

      {/* モーダル */}
      {modalType && (
        <div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center">
          <div className="bg-white p-4 rounded max-w-md w-full">
            {/* モーダルの内容は別コンポーネントとして実装 */}
          </div>
        </div>
      )}
    </div>
  );
};

export default UserManagement;

主な改善点:

  1. モーダルでのフォーム表示
  2. 一括選択と一括削除機能
  3. ステータスメッセージの表示
  4. 検索機能の実装
  5. エラー表示の統合
  6. ローディング状態の視覚的表示

まとめ

今回は、Next.jsでのAPI連携について、基本的な実装方法から実践的なユーザー管理画面の作成まで見てきました。

API連携は、モダンなWeb開発において避けては通れない重要な要素です。特にNext.jsでの開発では、効率的なデータ取得と更新、そして適切なエラーハンドリングが、アプリケーションの品質を大きく左右します。

今回学んだ内容は:

  • データの取得と更新の基本
  • エラーハンドリングとローディング状態の管理
  • 実践的なユーザー管理画面の実装

これらの知識は、今後より複雑なアプリケーションを開発していく上での基礎となります。

V0でプロトタイプを作成し、Cursorで実装を改善していく—このフローを続けることで、API連携に関する理解も自然と深まっていくはずです。

次回は、アプリケーションの規模が大きくなった際に必要となる状態管理について学んでいきます。より実践的なアプリケーション開発に向けて、一緒に理解を深めていきましょう。

それでは、次回もよろしくお願いします!