みかづきブログ・カスタム

基本的にはちょちょいのほいです。

dnd-kitを使ってドラッグ&ドロップで並び替え可能なリストを実装する ✊

docs.dndkit.com

Next.jsとdnd-kitを組み合わせてドラッグ&ドロップで並び替え可能なリストを実装しました。

ソースコード

package.json

{
  "name": "dnd-kit-sortable",
  "version": "0.1.0",
  "private": true,
  "scripts": {
    "dev": "next dev --turbopack",
    "build": "next build",
    "start": "next start"
  },
  "dependencies": {
    "@dnd-kit/core": "^6.2.0",
    "@dnd-kit/modifiers": "^8.0.0",
    "@dnd-kit/sortable": "^9.0.0",
    "@dnd-kit/utilities": "^3.2.2",
    "next": "15.0.3",
    "react": "19.0.0-rc-66855b96-20241106",
    "react-dom": "19.0.0-rc-66855b96-20241106"
  },
  "devDependencies": {
    "@types/node": "^20",
    "@types/react": "^18",
    "@types/react-dom": "^18",
    "typescript": "^5"
  }
}

src/app/page.tsx

'use client';

import { DndContext, DragEndEvent } from '@dnd-kit/core';
import { restrictToVerticalAxis } from '@dnd-kit/modifiers';
import { arrayMove, SortableContext, useSortable } from '@dnd-kit/sortable';
import { CSS } from '@dnd-kit/utilities';
import { useEffect, useState } from 'react';

export default function IndexPage() {
  const [sortedCourseList, setSortedCourseList] = useState<
    {
      id: string;
      label: string;
    }[]
  >([]);

  useEffect(() => {
    const array = [];

    for (let i = 0; i < 10; ++i) {
      array.push({
        id: String(i),
        label: String(i),
      });
    }

    setSortedCourseList(array);
  }, []);

  function handleDragEnd({ active, over }: DragEndEvent) {
    if (active && over) {
      const oldIndex = sortedCourseList.findIndex((item) => item.id === active.id);
      const newIndex = sortedCourseList.findIndex((item) => item.id === over.id);
      const newItems = [...sortedCourseList];

      setSortedCourseList(arrayMove(newItems, oldIndex, newIndex));
    }
  }

  return (
    <div>
      <DndContext
        modifiers={[restrictToVerticalAxis]}
        onDragEnd={handleDragEnd}
      >
        <SortableContext items={sortedCourseList}>
          {sortedCourseList.map((box) => {
            return <SortableItem key={box.id} id={box.id} label={box.label} />;
          })}
        </SortableContext>
      </DndContext>
    </div>
  );
}

function SortableItem({ id, label }: { id: string; label: string }) {
  const [now, setNow] = useState(performance.now());
  const {
    setNodeRef,
    attributes,
    listeners,
    transform,
    transition,
    isDragging,
  } = useSortable({
    id,
  });

  useEffect(() => {
    if (transform) {
      setNow(performance.now());
    }
  }, [transform]);

  return (
    <div
      {...attributes}
      {...listeners}
      ref={setNodeRef}
      style={{
        background: isDragging ? '#82B1FF' : '#2962FF',
        color: isDragging ? '#212121' : '#FAFAFA',
        cursor: isDragging ? 'grabbing' : 'grab',
        display: 'flex',
        justifyContent: 'space-between',
        padding: 16,
        position: 'relative',
        transform: CSS.Transform.toString(transform),
        transition,
        zIndex: isDragging ? 1 : 'auto',
      }}
    >
      <div>{label}</div>
      <div style={{ fontSize: 1 }}>update: {now}</div>
    </div>
  );
}

簡略化のために、page.tsxをクライアントコンポーネントにして、コンポーネントもひとつのファイル内に書いてしまっています。

注意事項

DEMOではあえて、子のtransformが変更された時間を表示しているのですが、ドラッグ開始から終了まで毎フレーム、再レンダリングが走っていることがわかります。ドラッグされうる要素はなるべくレンダリングの負荷を減らす工夫が必要です。