Zenn
🤛

React Hook Form(RHF)の配列操作をドラッグアンドドロップ(DnD)にしたい

2025/02/10に公開

はじめに

https://zenn.dev/nbr41to/articles/673344392d30ee
上記を読み、最近のDnDについて気になったので、試しに触っておこうと思う。
React Hook Form(RHF)とdnd kitを用いる。
https://www.react-hook-form.com/
https://dndkit.com/

DnDについて確認したいというのが主なので、RHFについてはFormのSubmitなどはしていない。その点ご了承ください。そもそもForm書いてないですし。

RHFという略称は伝わるのだろうか、といつも疑問だ。

package.jsonのバージョン情報は以下。

"@dnd-kit/core": "^6.3.1",
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"react-hook-form": "^7.54.2",

実装の前に

実は私のやろうとしていること(書いている時点ではやったこと)は既に記事になっていた。
https://zenn.dev/castingone_dev/articles/dndkit_sortalbe
重複となるが、許してほしい。

実装

Form.tsx
"use client";

import {
  useForm,
  useFieldArray,
  useWatch,
  FormProvider,
  useFormContext,
  UseFieldArrayRemove,
  UseFieldArrayInsert,
  FieldValues,
} from "react-hook-form";

import {
  DndContext,
  type DragEndEvent,
  KeyboardSensor,
  PointerSensor,
  closestCenter,
  useSensor,
  useSensors,
} from "@dnd-kit/core";
import {
  SortableContext,
  sortableKeyboardCoordinates,
  verticalListSortingStrategy,
} from "@dnd-kit/sortable";

import {
  FaGripVertical,
  FaMinus,
  FaPlus,
} from "react-icons/fa6";

import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";

const FormChild = ({
  item,
  index,
  remove,
  insert,
}: {
  item: Record<"id", string>;
  index: number;
  remove: UseFieldArrayRemove;
  insert: UseFieldArrayInsert<FieldValues, "test">;
}) => {
  const { register } = useFormContext();

  const {
    isDragging,
    // 並び替えのつまみ部分に設定するプロパティ
    setActivatorNodeRef,
    attributes,
    listeners,
    // DOM全体に対して設定するプロパティ
    setNodeRef,
    transform,
    transition,
  } = useSortable({ id: item.id });

  return (
    <div
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition,
      }}
      className="grid w-1/2 grid-cols-1"
    >
      <div
        className="flex items-center justify-between gap-4"
        key={item.id}
      >
        <div
          ref={setActivatorNodeRef}
          style={{
            cursor: isDragging ? "grabbing" : "grab",
          }}
          {...attributes}
          {...listeners}
        >
          <FaGripVertical />
        </div>
        <input
          {...register(`test.${index}.value`)}
          className="w-1/2 rounded-md p-2 text-black"
        />
        <button
          type="button"
          onClick={() => remove(index)}
          className="rounded-full border bg-red-500 p-2 hover:opacity-75"
        >
          <FaMinus />
        </button>
      </div>
      <button
        type="button"
        onClick={() => insert(index + 1, { value: "" })}
        className="flex flex-row items-center opacity-50 hover:bg-gray-500 hover:opacity-100"
      >
        <div className="rounded-full bg-blue-400">
          <FaPlus />
        </div>
        <div className="w-full border-b-2 border-blue-400" />
      </button>
    </div>
  );
};

const DraggableForm = () => {
  const methods = useForm();
  const { control } = methods;
  const { fields, append, remove, move, insert } =
    useFieldArray({
      control,
      name: "test",
    });

  const watchValues = useWatch({ control: control });

  const sensors = useSensors(
    useSensor(PointerSensor),
    useSensor(KeyboardSensor, {
      coordinateGetter: sortableKeyboardCoordinates,
    })
  );

  const handleDragEnd = async (event: DragEndEvent) => {
    const { active, over } = event;
    if (over === null) return;

    if (active.id !== over.id) {
      const oldIndex = fields.findIndex(
        (item) => item.id === active.id
      );
      const newIndex = fields.findIndex(
        (item) => item.id === over.id
      );
      move(oldIndex, newIndex);
    }
  };

  return (
    <div className="p-4">
      <div className="grid grid-cols-1 gap-4">
        <DndContext
          sensors={sensors}
          collisionDetection={closestCenter}
          onDragEnd={handleDragEnd}
        >
          <SortableContext
            items={fields}
            strategy={verticalListSortingStrategy}
          >
            <FormProvider {...methods}>
              {fields.map((field, index) => (
                <FormChild
                  key={field.id}
                  item={field}
                  index={index}
                  remove={remove}
                  insert={insert}
                />
              ))}
            </FormProvider>
          </SortableContext>
        </DndContext>
        {JSON.stringify(watchValues)}
      </div>

      <button
        type="button"
        onClick={() => append({ value: "" })}
        className="rounded-md border p-2 hover:bg-gray-500"
      >
        追加ボタン
      </button>
    </div>
  );
};

export default DraggableForm;

終わりに

これは面白い! すべてをDnDにしたくなってきた。
ネストした配列に対してどのような挙動をとるかは未検証。
できれば、テーブルカラムもDnDにしたい。

参考文献

https://docs.dndkit.com/
https://zenn.dev/nbr41to/articles/673344392d30ee
https://zenn.dev/wintyo/articles/d39841c63cc9c9
https://qiita.com/q_hallelujah/items/9628aa39badd6e5f0bdd

Discussion

ログインするとコメントできます