🦦

dnd KitのSortableとReact Hook Formを使ってソート可能なフォームを作る

2023/12/21に公開

これは CastingONE Advent Calendar 2023 20 日目の記事です。

こんにちは!株式会社 CastingONEで働いている岡本です。

はじめに

こちらの記事で、dnd kit のSortable機能を使用してアイテムの並び替えを行う方法について解説しました。今回はその応用編で、React Hook Formを絡ませてテキストフィールドのソート可能かつフィールドを増減可能なフォームを作成する手順について解説していきます。また、React Hook Form の使い方はこちらの記事で解説しておりますので、よろしければそちらもご覧ください!

https://zenn.dev/castingone_dev/articles/dndkit_sortalbe
https://zenn.dev/castingone_dev/articles/bec833cba5e7c9

TL;DR

今回の記事で作成していくフォームの成果物です。

実装方法

以下の構成で実装していきます。

src
|──components
| |──RhfSortables.tsx  <- 並び替え、フィールドの増減の処理をするためのコンポーネント
| |──RhfSortableBase.tsx <- 並び替えをするアイテムのスタイル部分のコンポーネント
| |──RhfSortableItem.tsx <- 並び替えをするアイテムの処理をするためのコンポーネント
|──App.tsx            <- フォームのsubmitをする

必要パッケージのインストール

以下のパッケージをインストールしておいてください。

  • @dnd-kit/core
  • @dnd-kit/modifiers
  • @dnd-kit/sortable
  • @dnd-kit/utilities
  • react-hook-form

フォームの定義

まずは、全体像を掴むためにフォームの送信ができるまでの部分を先に実装していきます。

App.tsx
import { useForm } from "react-hook-form";
import "./styles.css";
import { Box, Button, Typography } from "@mui/material";
import { RhfSortables } from "./components/RhfSortables";

export type SampleFormData = {
  textFields: { text: string }[];
};

export default function App() {
  const { control, handleSubmit } = useForm<SampleFormData>({
    defaultValues: {
      textFields: [{ text: "" }]
    }
  });

  const onSubmit = handleSubmit((data) => {
    console.log(data.textFields);
  });
  return (
    <div className="App">
      <Box
        sx={{
          p: 2
        }}
      >
        {/* 後で作るので一旦コメントアウト */}
        {/* <RhfSortables control={control} /> */}
        <Button sx={{ width: "150px" }} variant="outlined" onClick={onSubmit}>
          submit
        </Button>
      </Box>
    </div>
  );
}

react-hook-formパッケージからuseFormフックを呼び出し、フォームの状態を管理するcontrolとフォームの送信のためのhandleSubmitを受け取ります。handleSubmitで submit の結果を console.log に出すようにします。controlRhfSortablesに props として渡します。

並び替え可能なアイテムのスタイル作成

RhfSortableBase.tsxでアイテムのスタイルを作成していきます。

RhfSortableBase.tsx
import { FC } from "react";
import { Box, TextField, IconButton } from "@mui/material";
import DragHandleIcon from "@mui/icons-material/DragHandle";
import CloseIcon from "@mui/icons-material/Close";
import { Controller, Control } from "react-hook-form";
import {
  DraggableAttributes,
  DraggableSyntheticListeners
} from "@dnd-kit/core";
import { SampleFormData } from "../App";

type Props = {
  control: Control<SampleFormData>;
  handlerProps: {
    ref: (element: HTMLElement | null) => void;
    attributes: DraggableAttributes;
    listeners: DraggableSyntheticListeners;
  };
  index: number;
  isDragging: boolean;
  onRemove: () => void;
};

export const RhfSortableBase: FC<Props> = ({
  control,
  handlerProps,
  index,
  isDragging,
  onRemove
}) => {
  return (
    <Box
      sx={{
        display: "flex",
        alignItems: "center"
      }}
    >
      <Box
        ref={handlerProps.ref}
        {...handlerProps.attributes}
        {...handlerProps.listeners}
        sx={{
          display: "flex",
          alignItems: "center",
          cursor: isDragging ? "grabbing" : "grab"
        }}
        aria-label="ドラッグのつまみ"
      >
        <DragHandleIcon />
      </Box>
      <Box sx={{ ml: 2, width: "100%" }}>
        <Controller
          name={`textFields.${index}.text`}
          control={control}
          render={({ field }) => {
            return (
              <TextField
                ref={field.ref}
                name={field.name}
                value={field.value}
                onBlur={field.onBlur}
                onChange={field.onChange}
                fullWidth
              />
            );
          }}
        />
      </Box>
      <Box sx={{ ml: 2 }}>
        <IconButton onClick={onRemove} aria-label="削除">
          <CloseIcon />
        </IconButton>
      </Box>
    </Box>
  );
};

props でフォーム状態の管理のためのcontrol、ドラッグするときのつまみ部分を指定するためのhandleProps、何個目のフィールドかのindex、ドラッグ中かどうかのisDragging、フィールドを削除するためのイベントのonRemoveを受け取ります。

つまみ部分

DragHandleIconをつまみにするために、囲んでいる要素にhandlePropsで受け取ったrefattributeslistenersを渡します。

テキストフィールド

テキストを入力するコンポーネントをreact-hook-formから呼び出したControllerを使いnameに props で受け取ったindexフィールドを指定して、同じく props で受け取ったcontrolを指定します。そして、renderPropsで受け取ったfieldを使用して、コンポーネントのvalueonChangeに当てるようにします。

削除ボタン

削除アイコンに props で受け取った、onRemoveonClick時に発火させるようにします。

テキストフィールドを並び替え可能にするための処理

RhfSortableItem.tsxuseSortableフックを呼び出し、上で作ったRhfSortableBase.tsxを並び替えができるようにします。

RhfSortableItem.tsx
import { FC } from "react";
import { Control } from "react-hook-form";
import { SampleFormData } from "../App";
import { Box } from "@mui/material";
import { CSS } from "@dnd-kit/utilities";
import { useSortable } from "@dnd-kit/sortable";
import { RhfSortableBase } from "./RhfSortableBase";

type Props = {
  textField: FieldArrayWithId<SampleFormData>;
  control: Control<SampleFormData>;
  index: number;
  onRemove: () => void;
};

export const RhfSortableItem: FC<Props> = ({
  textField,
  control,
  index,
  onRemove
}) => {
  const {
    attributes,
    listeners,
    setNodeRef,
    setActivatorNodeRef,
    transform,
    transition,
    isDragging
  } = useSortable({
    id: textField.id
  });

  return (
    <Box
      ref={setNodeRef}
      style={{
        transform: CSS.Transform.toString(transform),
        transition
      }}
    >
      <RhfSortableBase
        handlerProps={{
          ref: setActivatorNodeRef,
          attributes,
          listeners
        }}
        control={control}
        index={index}
        onRemove={onRemove}
        isDragging={isDragging}
      />
    </Box>
  );
};

props でtextFieldcontrolindexonRemoveを受け取ります。@dnd-kit/sortableからuseSortableフックを呼び出し、props で受け取ったtextFieldid を引数で渡します。App.tsxで定義したtextFieldsidがありませんが、後述するフィールド増減useFieldArrayを使うとidが付与されるのでそのidを使用しています。そして、必要なプロパティを受け取り、RhfSortableBaseコンポーネントに必要な props を渡します。

フィールドの増減、並び替えを管理するための処理

RhfSortables.tsxuseFieldArrayを使用して、フィールドの増減、DndContext,SortableContextを使用して並び替えの管理をしていきます。

import { FC } from "react";
import { useFieldArray, Control } from "react-hook-form";
import { SampleFormData } from "../App";
import { Button, Stack } from "@mui/material";
import { RhfSortableItem } from "./RhfSortableItem";
import { DndContext } from "@dnd-kit/core";
import { SortableContext } from "@dnd-kit/sortable";

type Props = {
  control: Control<SampleFormData>;
};

export const RhfSortables: FC<Props> = ({ control }) => {
  const { fields, append, remove, move } = useFieldArray({
    control,
    name: "textFields",
  });

  return (
    <DndContext
      onDragEnd={(event) => {
        const { active, over } = event;
        if (over == null) {
          return;
        }
        if (active.id !== over.id) {
          const oldIndex = fields.findIndex((field) => field.id === active.id);
          const newIndex = fields.findIndex((field) => field.id === over.id);
          move(oldIndex, newIndex);
        }
      }}
    >
      <SortableContext items={fields}>
        <Stack spacing={2}>
          {fields.map((field, index) => {
            return (
              <RhfSortableItem
                key={field.id}
                textField={field}
                control={control}
                index={index}
                onRemove={() => remove(index)}
              />
            );
          })}
          <Button
            sx={{
              width: "50px",
            }}
            variant="contained"
            onClick={() => append({ text: "" })}
          >
            追加
          </Button>
        </Stack>
      </SortableContext>
    </DndContext>
  );
};

react-hook-formからuseFieldArrayを呼び出しtextFieldsを増減可能できるようにします。fieldstextFieldsの配列が格納され、@dnd-kit/sortableからSortableContextを呼び出し、並び替えをする部分を囲み、itemsfieldsを渡すことで、配列内の各要素の並び替えが可能になります。
fieldsを map メソッドでループし、RhfSortableItemコンポーネントに必要なものを渡し、onRemoveにはuseFieldArrayから受け取ったremoveメソッドを渡します。また、追加ボタンを用意し、同じくuseFieldArrayから受け取ったappendをクリックイベントとして結びつけます。

並び替えの処理は@dnd-kit/coreから呼び出した、DndContextonDragEventで行います。ここで、ドラッグ操作が終了した際に、useFieldArrayから受け取ったmoveメソッドを使用してフィールドの位置を更新します。moveメソッドは、指定されたインデックスのフィールドを別のインデックスに移動させる機能を持っています。onDragEndイベントのeventオブジェクトから、現在ドラッグされている要素(active)と、その要素が重なっている要素(over)を取得し、それぞれのインデックスをmoveメソッドに渡すことで、フィールドの並び替えが実現されます。

以下が実装したサンプルになるので、触ってみてください!

終わりに

以上が、dnd kit のSortableReact Hook Formを使ってフィールドの並び替えと増減ができる機能の実装手順でした。同じような実装をしようとしている人の参考になれば幸いです。
弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!

https://www.wantedly.com/projects/1130967

https://www.wantedly.com/projects/768663

Discussion