dnd KitのSortableとReact Hook Formを使ってソート可能なフォームを作る
これは CastingONE Advent Calendar 2023 20 日目の記事です。
こんにちは!株式会社 CastingONEで働いている岡本です。
はじめに
こちらの記事で、dnd kit のSortable
機能を使用してアイテムの並び替えを行う方法について解説しました。今回はその応用編で、React Hook Form
を絡ませてテキストフィールドのソート可能かつフィールドを増減可能なフォームを作成する手順について解説していきます。また、React Hook Form の使い方はこちらの記事で解説しておりますので、よろしければそちらもご覧ください!
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
フォームの定義
まずは、全体像を掴むためにフォームの送信ができるまでの部分を先に実装していきます。
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 に出すようにします。control
はRhfSortables
に props として渡します。
並び替え可能なアイテムのスタイル作成
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
で受け取ったref
、attributes
、listeners
を渡します。
テキストフィールド
テキストを入力するコンポーネントをreact-hook-form
から呼び出したController
を使いname
に props で受け取ったindex
フィールドを指定して、同じく props で受け取ったcontrol
を指定します。そして、renderProps
で受け取ったfield
を使用して、コンポーネントのvalue
やonChange
に当てるようにします。
削除ボタン
削除アイコンに props で受け取った、onRemove
をonClick
時に発火させるようにします。
テキストフィールドを並び替え可能にするための処理
RhfSortableItem.tsx
でuseSortable
フックを呼び出し、上で作ったRhfSortableBase.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 でtextField
、control
、index
、onRemove
を受け取ります。@dnd-kit/sortable
からuseSortable
フックを呼び出し、props で受け取ったtextField
の id
を引数で渡します。App.tsx
で定義したtextFields
にid
がありませんが、後述するフィールド増減useFieldArray
を使うとid
が付与されるのでそのid
を使用しています。そして、必要なプロパティを受け取り、RhfSortableBase
コンポーネントに必要な props を渡します。
フィールドの増減、並び替えを管理するための処理
RhfSortables.tsx
でuseFieldArray
を使用して、フィールドの増減、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
を増減可能できるようにします。fields
にtextFields
の配列が格納され、@dnd-kit/sortable
からSortableContext
を呼び出し、並び替えをする部分を囲み、items
にfields
を渡すことで、配列内の各要素の並び替えが可能になります。
fields
を map メソッドでループし、RhfSortableItem
コンポーネントに必要なものを渡し、onRemove
にはuseFieldArray
から受け取ったremove
メソッドを渡します。また、追加ボタンを用意し、同じくuseFieldArray
から受け取ったappend
をクリックイベントとして結びつけます。
並び替えの処理は@dnd-kit/core
から呼び出した、DndContext
のonDragEvent
で行います。ここで、ドラッグ操作が終了した際に、useFieldArray
から受け取ったmove
メソッドを使用してフィールドの位置を更新します。move
メソッドは、指定されたインデックスのフィールドを別のインデックスに移動させる機能を持っています。onDragEnd
イベントのevent
オブジェクトから、現在ドラッグされている要素(active
)と、その要素が重なっている要素(over
)を取得し、それぞれのインデックスをmove
メソッドに渡すことで、フィールドの並び替えが実現されます。
以下が実装したサンプルになるので、触ってみてください!
終わりに
以上が、dnd kit のSortable
とReact Hook Form
を使ってフィールドの並び替えと増減ができる機能の実装手順でした。同じような実装をしようとしている人の参考になれば幸いです。
弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!
Discussion