dnd-kit を使った並び替え実装パターン
リスト項目のドラッグ&ドロップによる並び替え機能を実装する際の標準パターンを解説します。
概要
このプロジェクトでは、並び替え機能の実装に dnd-kit を使用しています。 dnd-kit は React 用の軽量なドラッグ&ドロップライブラリで、アクセシビリティ対応も優れています。
パッケージ
bash
pnpm add @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities実装パターン
1. データベース設計
並び替え可能なエンティティには position カラムを追加します。
prisma
model ShopRoom {
id Int @id @default(autoincrement())
shopId Int
name String
position Int @default(0) // 並び順カラム
shop Shop @relation(fields: [shopId], references: [id])
@@index([shopId, position])
}TIP
position カラムにはインデックスを設定し、ORDER BY position ASC でのクエリを効率化しましょう。
既存コードについて
過去の実装では order という命名が使われている箇所があります(例: ShopRoom.order)。 新規実装では position を推奨します。理由:
orderは SQL の予約語であり、クエリでエスケープが必要になる場合があるorderBy: { order: 'asc' }のようなコードでorderが重複して紛らわしい
2. バックエンドの reorder mutation
並び替え専用の mutation を用意します。個別に updateXxx を呼び出すのではなく、一括で更新する専用 mutation を作成します。
Nexus (mdms_jp) の場合
typescript
// backend/src/schema/xxx/mutations/reorder_xxx.ts
import { arg, inputObjectType, mutationField, nonNull } from 'nexus';
export const reorderXxxInput = inputObjectType({
name: 'ReorderXxxInput',
definition(t) {
t.nonNull.int('parentId'); // 親エンティティのID
t.nonNull.list.nonNull.int('itemIds'); // 並び替え後のID配列
},
});
export const reorderXxx = mutationField((t) => {
t.nonNull.list.nonNull.field('reorderXxx', {
type: 'Xxx',
args: {
input: nonNull(arg({ type: reorderXxxInput })),
},
resolve: async (_, args, ctx) => {
// 権限チェック
if (!(await isAuthorized(ctx, args.input.parentId))) {
throw new ForbiddenError('権限がありません');
}
const { parentId, itemIds } = args.input;
// トランザクションで一括更新
await ctx.prisma.$transaction(
itemIds.map((itemId, index) =>
ctx.prisma.xxx.update({
where: { id: itemId },
data: { position: index },
}),
),
);
// 更新後のリストを返す
return ctx.prisma.xxx.findMany({
where: { parentId },
orderBy: [{ position: 'asc' }, { id: 'asc' }],
});
},
});
});gqlgen + ent (Go バックエンド) の場合
スキーマ定義(.gql ファイル):
graphql
# schema/xxx_reorder.gql
input ReorderXxxInput {
"""
新しいIDの並び順
"""
ids: [ID!]!
"""
対象の親エンティティID
"""
parentId: ID!
}
type ReorderXxxPayload {
succeeded: Boolean!
}
extend type Mutation {
"""
対象の並び順を更新する
"""
reorderXxx(input: ReorderXxxInput!): ReorderXxxPayload!
}リゾルバ実装(.resolvers.go ファイル):
go
// xxx_reorder.resolvers.go
func (r *mutationResolver) ReorderXxx(
ctx context.Context,
input model.ReorderXxxInput,
) (*model.ReorderXxxPayload, error) {
// 権限チェック
hasPermission, err := r.u.HasRole(ctx, input.ParentID, role.RoleAdmin)
if err != nil {
return nil, err
}
if !hasPermission {
return nil, NewForbiddenError(errors.New("権限がありません"))
}
// 各アイテムの position を更新
for index, id := range input.Ids {
if err := r.client.Xxx.UpdateOneID(id).SetPosition(index).Exec(ctx); err != nil {
return nil, err
}
}
return &model.ReorderXxxPayload{Succeeded: true}, nil
}WARNING
トランザクションを使用して、並び替えの途中で失敗した場合にロールバックされるようにしてください。
3. フロントエンド: SortableItem コンポーネント
ドラッグ可能な個別アイテムのコンポーネントを作成します。
tsx
// SortableXxxItem.tsx
'use client';
import { useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
type Props = {
item: { id: number; name: string };
onEdit: (item: { id: number; name: string }) => void;
onDelete: (item: { id: number; name: string }) => void;
};
export const SortableXxxItem = ({ item, onEdit, onDelete }: Props) => {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
isDragging,
} = useSortable({ id: item.id });
const style = {
transform: CSS.Transform.toString(transform),
transition,
opacity: isDragging ? 0.5 : 1,
};
return (
<div
ref={setNodeRef}
style={style}
className="flex items-center justify-between rounded-lg border p-4"
>
<div className="flex items-center gap-3">
{/* ドラッグハンドル */}
<button
type="button"
className="cursor-grab touch-none text-gray-400 hover:text-gray-600"
{...attributes}
{...listeners}
>
<GripIcon className="h-5 w-5" />
</button>
<span>{item.name}</span>
</div>
<div className="flex gap-2">
<button onClick={() => onEdit(item)}>編集</button>
<button onClick={() => onDelete(item)}>削除</button>
</div>
</div>
);
};ポイント
useSortableフックを使用してドラッグ機能を取得setNodeRefを要素に設定transformとtransitionをスタイルに適用- ドラッグハンドルには
attributesとlistenersを spread isDraggingでドラッグ中の視覚的フィードバックを提供
4. フロントエンド: リストコンテナ
DndContext と SortableContext でリストをラップします。
tsx
// XxxListPage.tsx
'use client';
import { useMutation, useQuery } from '@apollo/client/react';
import {
DndContext,
closestCenter,
KeyboardSensor,
PointerSensor,
useSensor,
useSensors,
DragEndEvent,
} from '@dnd-kit/core';
import {
arrayMove,
SortableContext,
sortableKeyboardCoordinates,
verticalListSortingStrategy,
} from '@dnd-kit/sortable';
import { useState, useEffect } from 'react';
export const XxxListPage = ({ parentId }: { parentId: number }) => {
// ドラッグ用のローカル状態(楽観的更新用)
const [items, setItems] = useState<Item[]>([]);
const { data } = useQuery(XxxListDocument, {
variables: { parentId },
});
// サーバーからのデータでローカル状態を更新
useEffect(() => {
if (data?.items) {
setItems([...data.items]);
}
}, [data?.items]);
const [reorderXxx] = useMutation(ReorderXxxDocument);
// センサー設定(ポインター + キーボード)
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);
const handleDragEnd = async (event: DragEndEvent) => {
const { active, over } = event;
if (active.id !== over?.id) {
const oldIndex = items.findIndex((item) => item.id === active.id);
const newIndex = items.findIndex((item) => item.id === over?.id);
// 楽観的更新:UIを即座に更新
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
// サーバーに並び順を送信
await reorderXxx({
variables: {
input: {
parentId,
itemIds: newItems.map((item) => item.id),
},
},
});
}
};
return (
<DndContext
sensors={sensors}
collisionDetection={closestCenter}
onDragEnd={handleDragEnd}
>
<SortableContext
items={items.map((item) => item.id)}
strategy={verticalListSortingStrategy}
>
<div className="flex flex-col gap-2">
{items.map((item) => (
<SortableXxxItem
key={item.id}
item={item}
onEdit={handleEdit}
onDelete={handleDelete}
/>
))}
</div>
</SortableContext>
</DndContext>
);
};実装上の注意点
楽観的更新
ドラッグ完了時に即座にUIを更新し、バックグラウンドでサーバーに保存します。 これにより、ユーザー体験が向上します。
tsx
// ローカル状態を先に更新
const newItems = arrayMove(items, oldIndex, newIndex);
setItems(newItems);
// その後サーバーに保存
await reorderXxx({ variables: { ... } });エラーハンドリング
サーバー保存が失敗した場合は、元の順序に戻すか、エラーを表示します。
tsx
try {
await reorderXxx({ variables: { ... } });
} catch (error) {
// エラー時は元の状態に戻す
setItems([...data.items]);
toast({ title: '並び替えに失敗しました', status: 'error' });
}アクセシビリティ
KeyboardSensor を追加することで、キーボードでの並び替えも可能になります。
tsx
const sensors = useSensors(
useSensor(PointerSensor),
useSensor(KeyboardSensor, {
coordinateGetter: sortableKeyboardCoordinates,
}),
);既存の実装例
プロジェクト内の実装例:
mdms_jp
| 場所 | ファイル |
|---|---|
| 部屋管理 | javascript/mdms_jp/shop/src/app/shops/[id]/rooms/ |
| スタッフ管理 | javascript/mdms_jp/shop/src/app/shops/[id]/staffs/ |
| カタログ管理 | javascript/mdms_jp/shop/src/app/shops/[id]/catalogs/ |
| アンケート質問 | javascript/mdms_jp/admin/src/app/surveys/[surveyId]/_components/ |
studio
| 場所 | ファイル |
|---|---|
| 作者管理 | javascript/apps/studio/src/features/Permission/components/AuthorList.tsx |
| 追加コンテンツ | javascript/apps/studio/src/features/Marketing/additionalContentData/ |
| エディション管理 | javascript/apps/studio/src/app/[locale]/scenarioSets/[id]/_components/EditionForm.tsx |
admin
| 場所 | ファイル |
|---|---|
| シナリオタグ | javascript/apps/admin/src/app/scenarioTags/_components/scenarioTagList.tsx |
| 汎用ドラッグ行 | javascript/apps/admin/src/components/draggableRow.tsx |
チェックリスト
新しい並び替え機能を実装する際のチェックリスト:
- [ ] データベースに
positionカラムを追加 - [ ] Prisma スキーマを更新し、マイグレーション実行
- [ ] バックエンドに
reorderXxxmutation を追加 - [ ] フロントエンドに
SortableXxxItemコンポーネントを作成 - [ ] リストを
DndContext+SortableContextでラップ - [ ] 楽観的更新のためのローカル状態を追加
- [ ] エラーハンドリングを実装
- [ ] キーボード操作のサポートを確認