0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Last updated at Posted at 2025-12-16

この記事は、ひとりでつくる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入門
0
0
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
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?