dnd kitのdataを型安全にしてみた
はじめに
こんにちは!株式会社 CastingONEの岡本です。
さて、今回は React のドラッグアンドドロップのライブラリのdnd kitのuseDroppable
やuseDraggable
の data を型安全にする方法について書いていきます。dnd kit の基本的な機能の解説については本記事で行わないので、もしよろしければ以前書いた記事を参考にしてみてください。
TL;DR
実装したコードが長いので、折りたたんでおります。
実装コード
型安全 にした dnd kit の component や hook
import {
DndContextProps,
DndContext,
type Active,
type Collision,
type Over,
type Translate,
} from '@dnd-kit/core';
// ドロップ中のアイテムの型情報
interface TypesafeDndActive<T extends Record<string, any>>
extends Omit<Active, 'data'> {
data: React.MutableRefObject<T | undefined>;
}
// ドロップ先の型情報
interface TypesafeDndOver<T extends Record<string, any>>
extends Omit<Over, 'data'> {
data: React.MutableRefObject<T | undefined>;
}
// イベントハンドラーの型情報
interface TypesafeDragEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> {
activatorEvent: Event;
active: TypesafeDndActive<Active>;
collisions: Collision[] | null;
delta: Translate;
over: TypesafeDndOver<Over> | null;
}
export interface TypesafeDragStartEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends Pick<TypesafeDragEvent<Active, Over>, 'active'> {}
export interface TypesafeDragMoveEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends TypesafeDragEvent<Active, Over> {}
export interface TypesafeDragOverEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends TypesafeDragEvent<Active, Over> {}
export interface TypesafeDragEndEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends TypesafeDragEvent<Active, Over> {}
export interface TypesafeDragCancelEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends TypesafeDragEvent<Active, Over> {}
export interface TypesafeDndContextProps<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends Omit<
DndContextProps,
'onDragStart' | 'onDragMove' | 'onDragOver' | 'onDragEnd' | 'onDragCancel'
> {
onDragStart?(event: TypesafeDragStartEvent<Active, Over>): void;
onDragMove?(event: TypesafeDragMoveEvent<Active, Over>): void;
onDragOver?(event: TypesafeDragOverEvent<Active, Over>): void;
onDragEnd?(event: TypesafeDragEndEvent<Active, Over>): void;
onDragCancel?(event: TypesafeDragCancelEvent<Active, Over>): void;
}
// DndContextの型安全化
export function TypesafeDndContext<
Active extends Record<string, any>,
Over extends Record<string, any>
>(props: TypesafeDndContextProps<Active, Over>) {
return <DndContext {...props} />;
}
import { useDndMonitor } from '@dnd-kit/core';
import {
TypesafeDragCancelEvent,
TypesafeDragEndEvent,
TypesafeDragMoveEvent,
TypesafeDragOverEvent,
TypesafeDragStartEvent,
} from './TypesasfeDndContext';
export interface TypedDndMonitorListener<
Active extends Record<string, any>,
Over extends Record<string, any>
> {
onDragStart?(event: TypesafeDragStartEvent<Active, Over>): void;
onDragMove?(event: TypesafeDragMoveEvent<Active, Over>): void;
onDragOver?(event: TypesafeDragOverEvent<Active, Over>): void;
onDragEnd?(event: TypesafeDragEndEvent<Active, Over>): void;
onDragCancel?(event: TypesafeDragCancelEvent<Active, Over>): void;
}
// useDndMonitorの型安全化
export function useTypesafeDndMonitor<
Active extends Record<string, any>,
Over extends Record<string, any>
>(listener: TypedDndMonitorListener<Active, Over>) {
return useDndMonitor(listener);
}
import { useDraggable, type UseDraggableArguments } from '@dnd-kit/core';
interface UseDraggableTypesafeArguments<T extends Record<string, any>>
extends Omit<UseDraggableArguments, 'data'> {
data: T;
}
export const useTypesafeDraggable = <T extends Record<string, any>>(
draggableArgs: UseDraggableTypesafeArguments<T>
) => {
const draggable = useDraggable(draggableArgs);
return {
...draggable,
data: {
draggableArgs: draggableArgs.data,
},
};
};
import type { UseDroppableArguments } from '@dnd-kit/core';
import { useDroppable as useOriginalDroppable } from '@dnd-kit/core';
interface UseDroppableTypesafeArguments<T extends Record<string, any>>
extends Omit<UseDroppableArguments, 'data'> {
data: T;
}
export const useTypesafeDroppable = <T extends Record<string, any>>(
props: UseDroppableTypesafeArguments<T>
) => {
return useOriginalDroppable(props);
};
型安全にした components や hooks を使用する側
import { TypesafeDndContext } from './lib'
export type DraggableData = {
activeId: string;
hoge: number;
isDraggable: boolean;
};
export type DroppableData = {
overId: string;
fuga: number;
isDroppable: boolean;
};
function App() {
return (
<TypesafeDndContext<DraggableData, DroppableData>
onDragEnd={(event) => {
const { active, over } = event
if (
active.data.current == null ||
over == null ||
over.data.current == null
) {
return;
}
// これらに型が当たるようになる
const { activeId, hoge, isDraggable } = active.data.current;
const { overId, fuga, isDroppable } = over.data.current;
}}
>
...
</TypesafeDndContext>
)
}
import type { DraggableData, DroppableData } from './App'
import { useTypesafeDraggable, useTypesafeDroppable, useTypesafeDndMonitor } from './lib'
const { ... } = useTypesafeDraggable<DraggableData>({
id: 'hogehoge',
// ここに型が当たるようになる
data: {
activeId: 'hogehoge',
hoge: 1234,
isDraggable: true
}
})
const { ... } = useTypesafeDroppable<DroppableData>({
id: 'fugafuga',
// ここに型が当たるようになる
data: {
overId: 'fugafuga',
fuga: 9876,
isDroppable: false
}
})
useTypesafeDndMonitor<DraggableData, DroppableData>({
onDragEnd: (event) => {
const { active, over } = event;
if (
active.data.current == null ||
over?.data.current == null
) {
return;
}
// ここに型が当たるようになる
const { activeId, hoge, isDraggable } = active.data.current;
const { overId, fuga, isDroppable } = over.data.current;
}
})
型を当てたかった背景
前に弊社のアプリケーションで使われている HTML メールエディタのアクセシビリティ向上について書いた記事でも触れましたが、HTML メールエディタのドラッグアンドドロップに dnd kit を採用しました。HTML メールエディタはブロックアイテムを挿入するときや、挿入後の並び替えなどでドラッグアンドドロップが行われています。
ドラッグアンドドロップでドラッグしているアイテムが何なのか、ドラッグ先はどこなのかを識別するために、dnd kit のuseDraggable
やuseDroppable
にそれぞれに適したdata
を渡して、それを元に識別を行っていました。data
は以下のコードのように渡すことができます。
// useDraggable
const {
...
} = useDraggable({
id: "hogehoge",
// ここに好きなdataを渡すことができる
data: {
hoge: "hogehoge",
hogeNumber: 1234,
},
});
// useDroppable
const {
...
} = useDroppable({
id: "fugafuga",
// ここに好きなdataを渡すことができる
data: {
fuga: "fugafuga",
fugaNumber: 1234
}
})
それぞれの hooks に渡したdata
は、DndContext
やuseDndMonitor
のイベントハンドラーで呼び出すことができ、受け取れるevent
パラメーターの中にドラッグ中のアイテムの要素のactive
とドロップ先の要素のover
があります。このactive
, over
のdata.current
の中に、useDraggable
, useDroppable
でそれぞれ設定した data の情報が格納されています。
// DndContext
<DndContext
onDragEnd={(event) => {
const { active, over } = event;
if (active.data.current == null || over?.data.current == null) {
return;
}
// useDraggableで渡した情報が受け取れる
const { hoge, hogeNumber } = active.data.current;
// useDroppableで渡した情報が受け取れる
const { fuga, fugaNumber } = over.data.current;
}}
>
...
</DndContext>;
// useDndMonitor(DndContextのchildrenで使われる想定)
export const ChildComponent = () => {
useDndMonitor({
onDragEnd: (event) => {
const { active, over } = event;
if (active.data.current == null || over?.data.current == null) {
return;
}
// useDraggableで渡した情報が受け取れる
const { hoge, hogeNumber } = active.data.current;
// useDroppableで渡した情報が受け取れる
const { fuga, fugaNumber } = over.data.current;
},
});
};
上述のサンプルコードのようにすればdata
を受け取れますが、型が any になり推論や型エラー表示をしてくれません。 複雑な設計になっている HTML メールエディタを作っていた際に、頭が混乱しまくりだったので、型を当てたいなと思ったのがきっかけで型安全化に取り組みました!
実装方法
実装に入る時に、「そもそも dnd kit が data
を型安全にする何かを提供しているのでは?🤔」と思い、公式サイトやコードを確認してみました。特に公式が何かしているわけではなかったですが、github の issues に同じような悩みの issue が上がっていたので、こちらを参考に自分なりに実装してみました!
具体的には dnd kit が提供しているDndContext
、useDraggable
、useDroppable
、useDndMonitor
で登場する data
に一つずつジェネリクスで型を指定できるように実装を行いました。基本的にラップして型を付与する形にしています。
型安全にした component や hook
DndContext
型安全 な DndContext
は以下のコードのように定義しました。
import {
DndContextProps,
DndContext,
type Active,
type Collision,
type Over,
type Translate,
} from '@dnd-kit/core';
// ドロップ中のアイテムの型情報
interface TypesafeDndActive<T extends Record<string, any>>
extends Omit<Active, 'data'> {
data: React.MutableRefObject<T | undefined>;
}
// ドロップ先の型情報
interface TypesafeDndOver<T extends Record<string, any>>
extends Omit<Over, 'data'> {
data: React.MutableRefObject<T | undefined>;
}
// イベントハンドラーの型情報
interface TypesafeDragEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> {
activatorEvent: Event;
active: TypesafeDndActive<Active>;
collisions: Collision[] | null;
delta: Translate;
over: TypesafeDndOver<Over> | null;
}
export interface TypesafeDragStartEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends Pick<TypesafeDragEvent<Active, Over>, 'active'> {}
export interface TypesafeDragMoveEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends TypesafeDragEvent<Active, Over> {}
export interface TypesafeDragOverEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends TypesafeDragEvent<Active, Over> {}
export interface TypesafeDragEndEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends TypesafeDragEvent<Active, Over> {}
export interface TypesafeDragCancelEvent<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends TypesafeDragEvent<Active, Over> {}
export interface TypesafeDndContextProps<
Active extends Record<string, any>,
Over extends Record<string, any>
> extends Omit<
DndContextProps,
'onDragStart' | 'onDragMove' | 'onDragOver' | 'onDragEnd' | 'onDragCancel'
> {
onDragStart?(event: TypesafeDragStartEvent<Active, Over>): void;
onDragMove?(event: TypesafeDragMoveEvent<Active, Over>): void;
onDragOver?(event: TypesafeDragOverEvent<Active, Over>): void;
onDragEnd?(event: TypesafeDragEndEvent<Active, Over>): void;
onDragCancel?(event: TypesafeDragCancelEvent<Active, Over>): void;
}
// DndContextの型安全化
export function TypesafeDndContext<
Active extends Record<string, any>,
Over extends Record<string, any>
>(props: TypesafeDndContextProps<Active, Over>) {
return <DndContext {...props} />;
}
-
TypesafeDndActive
とTypesafeDndOver
を定義して、data プロパティにジェネリクス型のT
を割り当てています。これにより、ドロップ中のアイテムとドロップ先の型を当てれるようにしています。 -
TypesafeDragEvent
を定義し、active
とover
プロパティにそれぞれTypesafeDndActive
とTypesafeDndOver
を割り当てています。これによりイベントハンドラーの型情報を型安全にしています。 -
TypesafeDragStartEvent
、TypesafeDragMoveEvent
、TypesafeDragOverEvent
、TypesafeDragEndEvent
、TypesafeDragCancelEvent
を定義し、それぞれTypesafeDragEvent
から必要なプロパティを継承しています。これにより、各イベントハンドラーの型情報を型安全にしています。 -
TypesafeDndContextProps
を定義し、dnd kit が提供しているDndContextProps
からonDragStart
、onDragMove
、onDragOver
、onDragCancel
プロパティを上書きしています。これにより、DndContext
コンポーネントのプロパティの型情報を型安全にしています。
useDndMonitor
型安全 な useDndMonitor
は以下のコードのように定義しました。
import { useDndMonitor } from '@dnd-kit/core';
import {
TypesafeDragCancelEvent,
TypesafeDragEndEvent,
TypesafeDragMoveEvent,
TypesafeDragOverEvent,
TypesafeDragStartEvent,
} from './TypesasfeDndContext';
export interface TypedDndMonitorListener<
Active extends Record<string, any>,
Over extends Record<string, any>
> {
onDragStart?(event: TypesafeDragStartEvent<Active, Over>): void;
onDragMove?(event: TypesafeDragMoveEvent<Active, Over>): void;
onDragOver?(event: TypesafeDragOverEvent<Active, Over>): void;
onDragEnd?(event: TypesafeDragEndEvent<Active, Over>): void;
onDragCancel?(event: TypesafeDragCancelEvent<Active, Over>): void;
}
// useDndMonitorの型安全化
export function useTypesafeDndMonitor<
Active extends Record<string, any>,
Over extends Record<string, any>
>(listener: TypedDndMonitorListener<Active, Over>) {
return useDndMonitor(listener);
}
TypedDndMonitorListener
を定義し、上述の型安全にしたDndContext
で作成した型安全なインターフェースを流用することで、各イベントの data プロパティに適切な型が当たるようにしています。
useDraggable
型安全 な useDraggable
は以下のコードのように定義しました。
import { useDraggable, type UseDraggableArguments } from '@dnd-kit/core';
interface UseDraggableTypesafeArguments<T extends Record<string, any>>
extends Omit<UseDraggableArguments, 'data'> {
data: T;
}
export const useTypesafeDraggable = <T extends Record<string, any>>(
draggableArgs: UseDraggableTypesafeArguments<T>
) => {
const draggable = useDraggable(draggableArgs);
return {
...draggable,
data: {
draggableArgs: draggableArgs.data,
},
};
};
UseDraggableTypesafeArguments
を定義することで、useDraggable
フックの引数に型安全性を導入しています。UseDraggableTypesafeArguments
は、元の UseDraggableArguments
インターフェースから data
プロパティを除外(Omit)し、代わりに新しく data
プロパティを定義しています。この新しい data
プロパティには、ジェネリクス型 T
が割り当てられています。これにより、useDraggable
フックを使用する際に、data
の型を明示的に指定できるようになります。
useDroppable
型安全 な useDroppable
は以下のコードのように定義しました。
import type { UseDroppableArguments } from '@dnd-kit/core';
import { useDroppable as useOriginalDroppable } from '@dnd-kit/core';
interface UseDroppableTypesafeArguments<T extends Record<string, any>>
extends Omit<UseDroppableArguments, 'data'> {
data: T;
}
export const useTypesafeDroppable = <T extends Record<string, any>>(
props: UseDroppableTypesafeArguments<T>
) => {
return useOriginalDroppable(props);
};
上述の useDraggable
と同じような処理をしています。
これで component や hook で扱うdata
を型安全にすることができました。
型安全になった component や hook の使用例
これを実際に使うと以下のようなコードになります。TypesafeDndContext
、useTypesafeDraggable
、useTypesafeDroppable
、useTypesafeDndMonitor
を使用する時に、使用したい型情報のジェネリクスを渡します。
import { TypesafeDndContext } from './lib'
export type DraggableData = {
activeId: string;
hoge: number;
isDraggable: boolean;
};
export type DroppableData = {
overId: string;
fuga: number;
isDroppable: boolean;
};
function App() {
return (
<TypesafeDndContext<DraggableData, DroppableData>
onDragEnd={(event) => {
const { active, over } = event
if (
active.data.current == null ||
over?.data.current == null
) {
return;
}
// これらに型が当たるようになる
const { activeId, hoge, isDraggable } = active.data.current;
const { overId, fuga, isDroppable } = over.data.current;
}}
>
...
</TypesafeDndContext>
)
}
import type { DraggableData, DroppableData } from './App'
import { useTypesafeDraggable, useTypesafeDroppable, useTypesafeDndMonitor } from './lib'
const { ... } = useTypesafeDraggable<DraggableData>({
id: 'hogehoge',
// ここに型が当たるようになる
data: {
activeId: 'hogehoge',
hoge: 1234,
isDraggable: true
}
})
const { ... } = useTypesafeDroppable<DroppableData>({
id: 'fugafuga',
// ここに型が当たるようになる
data: {
overId: 'fugafuga',
fuga: 9876,
isDroppable: false
}
})
useTypesafeDndMonitor<DraggableData, DroppableData>({
onDragEnd: (event) => {
const { active, over } = event;
if (
active.data.current == null ||
over?.data.current == null
) {
return;
}
// ここに型が当たるようになる
const { activeId, hoge, isDraggable } = active.data.current;
const { overId, fuga, isDroppable } = over.data.current;
}
})
上記のコードを stackblitz でも用意したので、IDE で型が効いているかどうか確認したい方はご参照ください!
おわりに
以上が、dnd kit で扱うdata
を型安全にする方法でした!アプリケーション内で dnd kit を使用する箇所がたくさんある場合は、この記事のようにジェネリクスを使用できる形の hooks などを用意しておくと汎用性が高い気がしますが、逆にそこまでアプリケーション内で使わないのであれば、ジェネリクスにしていたところを実際の型情報を入れる方法もいいと思います。自分は HTML メールエディタを作っていた時に後者に落ち着きました。自分と同じように dnd kit のdata
を型安全にしたい人の参考になればと思います!
弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!
Discussion