🥋

dnd kitのdataを型安全にしてみた

2024/04/25に公開

はじめに

こんにちは!株式会社 CastingONEの岡本です。

さて、今回は React のドラッグアンドドロップのライブラリのdnd kituseDroppableuseDraggableの data を型安全にする方法について書いていきます。dnd kit の基本的な機能の解説については本記事で行わないので、もしよろしければ以前書いた記事を参考にしてみてください。
https://dndkit.com/
https://zenn.dev/castingone_dev/articles/dndkit20231031

TL;DR

実装したコードが長いので、折りたたんでおります。

実装コード

型安全 にした dnd kit の component や hook

TypesafeDndContext.tsx
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} />;
}

useTypesafeDndMonitor
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);
}

useTypesafeDraggable.tsx
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,
    },
  };
};

useTypesafeDroppable.tsx
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 を使用する側

App.tsx
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 メールエディタはブロックアイテムを挿入するときや、挿入後の並び替えなどでドラッグアンドドロップが行われています。
https://zenn.dev/castingone_dev/articles/email_editor_a11y

ドラッグアンドドロップでドラッグしているアイテムが何なのか、ドラッグ先はどこなのかを識別するために、dnd kit のuseDraggableuseDroppableにそれぞれに適した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は、DndContextuseDndMonitorのイベントハンドラーで呼び出すことができ、受け取れるeventパラメーターの中にドラッグ中のアイテムの要素のactiveとドロップ先の要素のoverがあります。このactive, overdata.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 が上がっていたので、こちらを参考に自分なりに実装してみました!
https://github.com/clauderic/dnd-kit/issues/935

具体的には dnd kit が提供しているDndContextuseDraggableuseDroppableuseDndMonitorで登場する data に一つずつジェネリクスで型を指定できるように実装を行いました。基本的にラップして型を付与する形にしています。

型安全にした component や hook

DndContext

型安全 な DndContext は以下のコードのように定義しました。

TypesafeDndContext.tsx
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} />;
}

  • TypesafeDndActiveTypesafeDndOverを定義して、data プロパティにジェネリクス型のTを割り当てています。これにより、ドロップ中のアイテムとドロップ先の型を当てれるようにしています。
  • TypesafeDragEventを定義し、activeoverプロパティにそれぞれTypesafeDndActiveTypesafeDndOverを割り当てています。これによりイベントハンドラーの型情報を型安全にしています。
  • TypesafeDragStartEventTypesafeDragMoveEventTypesafeDragOverEventTypesafeDragEndEventTypesafeDragCancelEvent を定義し、それぞれ TypesafeDragEvent から必要なプロパティを継承しています。これにより、各イベントハンドラーの型情報を型安全にしています。
  • TypesafeDndContextPropsを定義し、dnd kit が提供しているDndContextPropsからonDragStartonDragMoveonDragOveronDragCancelプロパティを上書きしています。これにより、DndContextコンポーネントのプロパティの型情報を型安全にしています。

useDndMonitor

型安全 な useDndMonitor は以下のコードのように定義しました。

useTypesafeDndMonitor
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 は以下のコードのように定義しました。

useTypesafeDraggable.tsx
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 は以下のコードのように定義しました。

useTypesafeDroppable.tsx
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 の使用例

これを実際に使うと以下のようなコードになります。TypesafeDndContextuseTypesafeDraggableuseTypesafeDroppableuseTypesafeDndMonitorを使用する時に、使用したい型情報のジェネリクスを渡します。

App.tsx
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を型安全にしたい人の参考になればと思います!

弊社ではいっしょに働いてくれるエンジニアを募集中です。社員でもフリーランスでも、フルタイムでも短時間の副業でも大歓迎なので、気軽にご連絡ください!

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

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

Discussion