🧩

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

4 か月前
13

この記事は、ひとりでつくる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を実装する際のポイントをまとめました。

課題解決策
データ型ごとに異なる編集UIDataEditor/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
ノーコードでExcelライクなテーブル作成:ドラッグ&ドロップUIの実装
0
投稿
0
フォロワー
0
いいね

プロパティ

ページ
テクノロジー
TECH