React, Vue.js
投稿日:

CursorとCopilotどっちがReact開発に向いてる?実案件で検証した

こんにちは、株式会社ファストコーディングのフルスタックエンジニア、独身貴族Fireです。

先日、バイクのヘルメットを買い替えようと思って、スペック比較をしていたんです。A社は軽さが売りだけど通気性がいまいち。B社は通気性抜群だけど少し重い。結局「どっちが上」ではなく「用途で選ぶ」が正解でした。夏のツーリングならB社、街乗りメインならA社。

AI開発ツールも、まったく同じ話だと思っています。

AI駆動開発でReact/Next.jsを書いていると、ツール選びの問題に直面します。現時点でメジャーな選択肢はCursorとGitHub Copilotの2つ。ネット上では「Cursorが最強」「いやCopilotで十分」という議論を見かけますが、正直、どちらも使っている身からするとタスクの種類によって向き不向きがあるのが実感です。

今回は、同じReactコンポーネント(データテーブル)を両方のツールで実装して、速度・品質・使い勝手を比較してみました。

比較の前提

ツールのバージョンと設定

  • Cursor: Composer機能使用(モデルはClaude Sonnet 4を選択)
  • GitHub Copilot: VS Code拡張、GPT-4.1モデル、インラインサジェスト + Copilot Chat

実装タスク

管理画面用のデータテーブルコンポーネント。以下の要件です。

  • TypeScript + React(関数コンポーネント)
  • ソート機能(カラムヘッダークリックで昇順/降順切替)
  • 検索フィルタ(テキスト入力でリアルタイム絞り込み)
  • ページネーション(10件ずつ表示、ページ切替)
  • レスポンシブ対応(モバイルでは横スクロール)

管理画面ではよくある要件ですよね。ライブラリを使えば一瞬ですが、今回はAIにゼロから書かせて比較します。

Cursorで実装した場合

作業フロー

CursorのComposer機能を使い、チャットで要件を伝えました。

データテーブルコンポーネントを作ってください。

- TypeScript + React
- props: data(配列)、columns(カラム定義配列)
- ソート:カラムヘッダークリックで昇順/降順
- 検索:テキスト入力で全カラム横断検索
- ページネーション:10件/ページ
- Tailwind CSSでスタイリング

Cursorはファイル全体を一度に生成してくれます。Composerが1ファイルにまとめてくれるので、コピペの手間がない。

Cursorが出したコード

// Cursorが生成:DataTable.tsx
'use client';

import { useState, useMemo } from 'react';

type Column<T> = {
  key: keyof T;
  label: string;
  sortable?: boolean;
};

type SortConfig<T> = {
  key: keyof T;
  direction: 'asc' | 'desc';
} | null;

type Props<T extends Record<string, unknown>> = {
  data: T[];
  columns: Column<T>[];
  pageSize?: number;
};

export function DataTable<T extends Record<string, unknown>>({
  data,
  columns,
  pageSize = 10,
}: Props<T>) {
  const [search, setSearch] = useState('');
  const [sortConfig, setSortConfig] = useState<SortConfig<T>>(null);
  const [currentPage, setCurrentPage] = useState(1);

  // 検索フィルタ
  const filtered = useMemo(() => {
    if (!search.trim()) return data;
    const term = search.toLowerCase();
    return data.filter(row =>
      columns.some(col => {
        const value = row[col.key];
        return String(value).toLowerCase().includes(term);
      })
    );
  }, [data, search, columns]);

  // ソート
  const sorted = useMemo(() => {
    if (!sortConfig) return filtered;
    return [...filtered].sort((a, b) => {
      const aVal = a[sortConfig.key];
      const bVal = b[sortConfig.key];
      if (aVal < bVal) return sortConfig.direction === 'asc' ? -1 : 1;
      if (aVal > bVal) return sortConfig.direction === 'asc' ? 1 : -1;
      return 0;
    });
  }, [filtered, sortConfig]);

  // ページネーション
  const totalPages = Math.ceil(sorted.length / pageSize);
  const paginated = sorted.slice(
    (currentPage - 1) * pageSize,
    currentPage * pageSize
  );

  const handleSort = (key: keyof T) => {
    setSortConfig(prev => {
      if (prev?.key === key) {
        return prev.direction === 'asc'
          ? { key, direction: 'desc' }
          : null;
      }
      return { key, direction: 'asc' };
    });
    setCurrentPage(1);
  };

  const handleSearch = (value: string) => {
    setSearch(value);
    setCurrentPage(1);
  };

  return (
    <div className="space-y-4">
      <input
        type="text"
        value={search}
        onChange={e => handleSearch(e.target.value)}
        placeholder="検索..."
        className="w-full max-w-sm border rounded-lg px-4 py-2 focus:ring-2 focus:ring-blue-500"
      />

      <div className="overflow-x-auto">
        <table className="w-full border-collapse">
          <thead>
            <tr className="bg-gray-50 border-b">
              {columns.map(col => (
                <th
                  key={String(col.key)}
                  onClick={() => col.sortable !== false && handleSort(col.key)}
                  className={`px-4 py-3 text-left text-sm font-medium text-gray-700 ${
                    col.sortable !== false ? 'cursor-pointer hover:bg-gray-100' : ''
                  }`}
                >
                  <span className="flex items-center gap-1">
                    {col.label}
                    {sortConfig?.key === col.key && (
                      <span>{sortConfig.direction === 'asc' ? '↑' : '↓'}</span>
                    )}
                  </span>
                </th>
              ))}
            </tr>
          </thead>
          <tbody>
            {paginated.length === 0 ? (
              <tr>
                <td colSpan={columns.length} className="px-4 py-8 text-center text-gray-500">
                  データがありません
                </td>
              </tr>
            ) : (
              paginated.map((row, i) => (
                <tr key={i} className="border-b hover:bg-gray-50">
                  {columns.map(col => (
                    <td key={String(col.key)} className="px-4 py-3 text-sm">
                      {String(row[col.key] ?? '')}
                    </td>
                  ))}
                </tr>
              ))
            )}
          </tbody>
        </table>
      </div>

      {totalPages > 1 && (
        <div className="flex items-center justify-between">
          <span className="text-sm text-gray-600">
            {sorted.length}件中 {(currentPage - 1) * pageSize + 1}〜
            {Math.min(currentPage * pageSize, sorted.length)}件を表示
          </span>
          <div className="flex gap-1">
            <button
              onClick={() => setCurrentPage(p => Math.max(1, p - 1))}
              disabled={currentPage === 1}
              className="px-3 py-1 border rounded text-sm disabled:opacity-50"
            >
              前へ
            </button>
            {Array.from({ length: totalPages }, (_, i) => (
              <button
                key={i}
                onClick={() => setCurrentPage(i + 1)}
                className={`px-3 py-1 border rounded text-sm ${
                  currentPage === i + 1
                    ? 'bg-blue-600 text-white'
                    : 'hover:bg-gray-100'
                }`}
              >
                {i + 1}
              </button>
            ))}
            <button
              onClick={() => setCurrentPage(p => Math.min(totalPages, p + 1))}
              disabled={currentPage === totalPages}
              className="px-3 py-1 border rounded text-sm disabled:opacity-50"
            >
              次へ
            </button>
          </div>
        </div>
      )}
    </div>
  );
}

Cursorの所感

良かった点:

  • ファイル全体を一発で生成。コピペなしでそのまま保存できる
  • ジェネリクス(<T>)を使った型安全な設計を最初から出してきた
  • ソート→検索→ページネーションの処理順が正しい(検索で絞り込み→ソート→ページ分割)
  • 「データがありません」の空状態表示も入っている

修正が必要だった点:

  • key={i}でインデックスをkeyに使っている。ソート順が変わるとReactの再レンダリングが壊れる可能性がある
  • ページ数が多い場合にボタンが大量に並ぶ。省略表示(1 2 3 … 10)が欲しい

Copilotで実装した場合

作業フロー

VS CodeのCopilotチャットに同じ要件を伝えました。Copilotはファイル内にコードを挿入するスタイルです。空のファイルを開いた状態で「データテーブルコンポーネントを作って」と依頼しました。

Copilotが出したコード

CopilotもCursorと同等の機能を持つコードを出しましたが、いくつかの違いがありました。

主な違いだけ挙げます。

// Copilotの特徴的な部分(抜粋)

// 1. ソートの実装がシンプル
const handleSort = (key: keyof T) => {
  setSortConfig(prev =>
    prev?.key === key && prev.direction === 'asc'
      ? { key, direction: 'desc' }
      : { key, direction: 'asc' }
  );
};

// 2. 検索のデバウンスが入っている
const [debouncedSearch, setDebouncedSearch] = useState('');

useEffect(() => {
  const timer = setTimeout(() => setDebouncedSearch(search), 300);
  return () => clearTimeout(timer);
}, [search]);

Copilotの所感

良かった点:

  • 検索にデバウンス(300ms)を自動で入れてきた。Cursorにはなかった配慮
  • インラインサジェストで「次の行」を予測してくれるので、手動で微調整しながら書く体験が良い

修正が必要だった点:

  • ソートの3段階切替(昇順→降順→解除)がなく、昇順/降順の2段階のみ
  • レスポンシブ対応(overflow-x-auto)が入っていなかった
  • 型定義がCursorほど厳密でなく、anyが混じる箇所があった

比較結果

観点CursorCopilot
生成速度約15秒(全体一括)約3分(チャット+手動調整)
型の厳密さジェネリクス活用、any なし一部 any が混じる
UX配慮空状態表示あり、3段階ソートデバウンス検索あり
レスポンシブoverflow-x-auto ありなし(手動追加が必要)
コードの完成度そのまま使える率 80%手動調整込みで 70%
微調整のしやすさComposerで再生成(やり直し感がある)インラインで逐次修正(自然な流れ)

どう使い分けるか

半年ほど両方を使い分けてきて、以下のパターンに落ち着きました。

Cursorが向いているタスク:

  • 新規コンポーネントの一括生成(ゼロから1ファイル作る)
  • 複数ファイルにまたがるリファクタリング(Composerで複数ファイルを同時に扱える)
  • 設計の相談(「この構成どう思う?」と聞ける)

Copilotが向いているタスク:

  • 既存コードの拡張(1行ずつサジェストが出るので、流れを壊さない)
  • テストコードの生成(テスト対象を開いた状態でサジェストが出る)
  • 定型的なコードの高速入力(API呼び出し、型定義など)

バイクのギアと同じです。低速のコーナーではローギア(Copilot=精密な制御)、直線ではハイギア(Cursor=一気に距離を稼ぐ)。状況に応じて切り替えるのが、AI駆動開発で最も効率が良い方法です。

ちなみに、この記事の下書きもCursorで構成を出してからCopilotで細部を調整しています。メタ的ですが、これが一番速かったです。

まとめ

今回は、CursorとGitHub Copilotを同じReact実装タスクで比較しました。

  • Cursorは「一括生成」が強く、新規ファイルの作成やリファクタリングに向いている
  • Copilotは「逐次サジェスト」が強く、既存コードの拡張やテスト生成に向いている
  • 型の厳密さはCursorが上。UXの細かい配慮(デバウンス等)はCopilotが自動で入れてくることがある
  • 「どちらが優れているか」ではなく「タスクの種類で使い分ける」のがAI駆動開発の正解

AI駆動開発のポイントは、ツールの特性を理解した上で適材適所に使うことです。万能なツールはありません。自分の開発スタイルに合った使い分けを見つけるのが大事です。

株式会社ファストコーディングでは、React/Next.jsの開発と、AI駆動開発を取り入れた効率的な制作プロセスでプロジェクトを支援しています。「AI駆動開発のツール選定を相談したい」「React/Next.jsの開発を効率化したい」という方は、お問い合わせフォームからお気軽にご連絡ください。