Skip to content

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 スキーマを更新し、マイグレーション実行
  • [ ] バックエンドに reorderXxx mutation を追加
  • [ ] フロントエンドに SortableXxxItem コンポーネントを作成
  • [ ] リストを DndContext + SortableContext でラップ
  • [ ] 楽観的更新のためのローカル状態を追加
  • [ ] エラーハンドリングを実装
  • [ ] キーボード操作のサポートを確認

関連リンク ​