🧩

ノーコードでExcelライクなテーブル作成:ドラッグ&ドロップUIの実装

に公開

この記事は、ひとりでつくるSaaS - 設計・実装・運用の記録 Advent Calendar 2025 の16日目の記事です。

昨日の記事では「無限スクロールの落とし穴」について書きました。今日は、ドラッグ&ドロップでカラムを並び替えられるExcelライクなテーブルUIの実装について解説します。

🎯 実現したい機能

NotionやAirtableのような、ユーザーが自由にカラムを操作できるテーブルを作ります。

  • セルをクリックして直接編集(インライン編集)
  • ドラッグ&ドロップでカラムの順序を変更
  • テーブル内の行を並び替え
  • カラム幅のリサイズ

非エンジニアでも直感的に使えることを目指しました。この記事では、これらを実現するための設計判断と実装パターンを紹介します。

⚙️ ライブラリ選定

テーブル基盤:react-spreadsheet

テーブルUIのライブラリはいくつか選択肢があります。

ライブラリ 特徴
AG Grid 高機能・大規模向け・商用ライセンスあり
TanStack Table ヘッドレス・自由度高い・UI構築が必要
react-spreadsheet 軽量・Excel風・カスタマイズ容易

今回はreact-spreadsheetを採用しました。決め手はDataEditor/DataViewerパターンです。セルの「表示」と「編集」を別コンポーネントで定義でき、データ型ごとに異なるUIを実装しやすい設計になっています。

AG Gridは高機能ですが、カスタムセルエディタの実装がやや複雑でした。TanStack Tableはヘッドレスなので自由度は高いですが、UIを一から構築する必要があります。react-spreadsheetは「ちょうどいい」バランスでした。

ドラッグ&ドロップ:dnd-kit

ドラッグ&ドロップには@dnd-kitを使いました。

react-beautiful-dndも有名ですが、メンテナンスが停滞気味です。dnd-kitはReact 18のConcurrent Modeに対応しており、TypeScriptの型定義も充実しています。アクセシビリティ(キーボード操作)のサポートも組み込まれているため、将来的な拡張も見据えて選定しました。

✏️ インライン編集の設計

なぜインライン編集が必要か

従来の「編集ボタンを押してモーダルを開く」UIは、1件ずつの編集には適していますが、複数のセルを連続して編集する場合はストレスになります。Excelのように「セルをクリックしてその場で編集」できれば、ユーザーの操作効率は大きく向上します。

DataEditor/DataViewerパターン

react-spreadsheetでは、各セルに「表示用」と「編集用」のコンポーネントを割り当てます。

// 表示用:セルをクリックする前の状態
const TextViewer: DataViewerComponent<TextCell> = ({ cell }) => {
  return <span className="px-2">{cell?.value ?? ''}</span>;
};

// 編集用:セルをクリックした後の状態
const TextEditor: DataEditorComponent<TextCell> = ({ cell, onChange }) => {
  const inputRef = useRef<HTMLInputElement>(null);

  useEffect(() => {
    // 編集モードに入ったら自動でフォーカス&全選択
    inputRef.current?.focus();
    inputRef.current?.select();
  }, []);

  return (
    <input
      ref={inputRef}
      type="text"
      value={cell?.value ?? ''}
      onChange={(e) => onChange({ ...cell, value: e.target.value })}
    />
  );
};

このパターンの利点は、データ型ごとに最適なUIを提供できることです。テキストなら入力欄、日付ならカレンダーピッカー、選択肢ならドロップダウンと、それぞれに適したエディタを実装できます。

ドロップダウンの注意点

ドロップダウン(セレクトボックス)を実装する際、よくある問題があります。メニューがテーブルのoverflow: hiddenに隠れてしまうのです。

解決策は、メニューをbodyに直接描画することです。

<Select
  menuPortalTarget={document.body}
  styles={{ menuPortal: (base) => ({ ...base, zIndex: 9999 }) }}
  // ...
/>

menuPortalTarget={document.body}を指定すると、メニューがテーブルのDOM階層から外れ、他の要素に隠れなくなります。

🐧 カラム順序の並び替え

設計画面での並び替え

テーブルのカラム順序は、設計画面(フィールドデザイナー)で変更できるようにしました。ここではdnd-kitを使っています。

実装のポイントは誤操作の防止です。

const sensors = useSensors(
  useSensor(PointerSensor, {
    activationConstraint: { distance: 8 },
  })
);

distance: 8を指定すると、8ピクセル以上ドラッグしないとドラッグが開始されません。これがないと、クリックしただけでドラッグが始まり、意図しない並び替えが発生してしまいます。

もう一つのポイントはドラッグハンドルの限定です。

<div ref={setNodeRef} style={style} {...attributes}>
  {/* listenersはハンドルにのみ適用 */}
  <button {...listeners} className="cursor-grab">
    <GripVertical />
  </button>
  <span>{item.name}</span>
  <button onClick={onEdit}>編集</button>
</div>

listenersをドラッグハンドル(グリップアイコン)にのみ適用することで、「編集」ボタンなど他の要素をクリックしてもドラッグが始まりません。アイテム全体をドラッグ可能にすると、他の操作と競合しやすくなります。

🐰 テーブル行の並び替え

楽観的UI更新

テーブル内の行もドラッグで並び替えられるようにしました。ここで重要なのは楽観的UI更新です。

const handleDrop = async (targetIndex: number) => {
  // 1. まず画面を即座に更新(楽観的更新)
  const reordered = [...localRows];
  const [dragged] = reordered.splice(draggedIndex, 1);
  reordered.splice(targetIndex, 0, dragged);
  setLocalRows(reordered);

  // 2. その後サーバーに保存
  await saveReorder(reordered);
};

ドラッグ完了と同時に画面上の順序が変わり、サーバーへの保存はバックグラウンドで行います。ユーザーは待たされることなく、次の操作に移れます。

未保存状態の警告

並び替えた後、保存せずにページを離れようとした場合は警告を表示します。

useEffect(() => {
  if (!hasUnsavedChanges) return;

  const handleBeforeUnload = (e: BeforeUnloadEvent) => {
    e.preventDefault();
    e.returnValue = '変更が保存されていません';
  };

  window.addEventListener('beforeunload', handleBeforeUnload);
  return () => window.removeEventListener('beforeunload', handleBeforeUnload);
}, [hasUnsavedChanges]);

これにより、うっかりページを閉じてしまっても、データの損失を防げます。

🐙 カラム幅のリサイズ

localStorageで永続化

ユーザーが調整したカラム幅は、次回アクセス時も反映されたほうが使いやすいと考えました。サーバーに保存する方法もありますが、カラム幅はユーザーの好みであり、頻繁に変更されるものなので、localStorageに保存しました。

const useColumnWidths = (tableId: string) => {
  const storageKey = `table_widths_${tableId}`;

  const [widths, setWidths] = useState<Record<string, number>>(() => {
    const saved = localStorage.getItem(storageKey);
    return saved ? JSON.parse(saved) : {};
  });

  // 幅が変わるたびにlocalStorageを更新
  useEffect(() => {
    localStorage.setItem(storageKey, JSON.stringify(widths));
  }, [widths, storageKey]);

  return { widths, setWidths };
};

テーブルごとに異なるキーで保存することで、複数のテーブルを使い分けても設定が混ざりません。

最小幅の制限

リサイズ時は最小幅を設定しておくと、カラムが潰れて見えなくなる問題を防げます。

const handleResize = (columnId: string, newWidth: number) => {
  const clampedWidth = Math.max(50, newWidth); // 最小50px
  setWidths(prev => ({ ...prev, [columnId]: clampedWidth }));
};

✅ まとめ

ExcelライクなテーブルUIを実装する際のポイントをまとめました。

課題 解決策
データ型ごとに異なる編集UI DataEditor/DataViewerパターン
ドロップダウンが隠れる menuPortalTarget={document.body}
クリックでドラッグが誤発動 activationConstraint: { distance: 8 }
他のボタンとドラッグの競合 ドラッグハンドルにlistenersを限定
並び替え中の待ち時間 楽観的UI更新
未保存での離脱 beforeunloadで警告
カラム幅の永続化 localStorage

ノーコードツールのUIは、「動く」だけでなく「迷わず使える」ことが重要です。誤操作の防止、即座のフィードバック、状態の保持など、細部の積み重ねがユーザー体験を決めます。

明日は「pgvector + OpenAI Embeddingsで意味検索を実装する」について解説します。


シリーズの他の記事

  • 12/15: 無限スクロール × Zustand × React 19:非同期の落とし穴
  • 12/17: 「意味で検索」を実装する:pgvector + OpenAI Embeddings入門
GitHubで編集を提案

Discussion