Building a Drag-and-Drop Kanban Board with React and dnd-kit

Building a Drag-and-Drop Kanban Board with React and dnd-kit

October 20, 2024

23 min read

Building a Drag-and-Drop Kanban Board with React and dnd-kit
Watch on YouTube

Introduction

Creating a drag-and-drop Kanban board can seem like a daunting task, but with the right tools and a bit of guidance, it becomes much more manageable. In this post, we’ll walk through how to build a Kanban board using the dnd-kit library, one of the most flexible and powerful drag-and-drop libraries for React. We’ll be referencing the Tasks board from Increaser, a productivity toolkit, and while the Increaser source code is private, all the reusable components you need are available in the open RadzionKit repository. Whether you’re looking to build a simple task board or a more complex system, this guide will give you the foundation to get started quickly.

Drag-and-drop Kanban board
Drag-and-drop Kanban board

The TaskBoard Component Overview

The TaskBoard component is the heart of our Kanban-style task management system. It handles task grouping, drag-and-drop functionality, and updating task order and status. Let’s walk through how it works.

import { Task, taskStatusName } from "@increaser/entities/Task"
import { TaskBoardContainer } from "./TaskBoardContainer"
import { useEffect, useState } from "react"
import { useUpdateUserEntityMutation } from "../../userEntity/api/useUpdateUserEntityMutation"
import { TaskColumnContainer } from "./column/TaskColumnContainer"
import { ColumnContent, ColumnHeader } from "./column/ColumnHeader"
import { Text } from "@lib/ui/text"
import { TaskColumnContent } from "./column/TaskColumnContent"
import { ColumnFooter } from "./column/ColumnFooter"
import { AddTaskColumn } from "./column/AddTaskColumn"
import { CurrentTaskProvider } from "../CurrentTaskProvider"
import { DnDGroups } from "@lib/dnd/groups/DnDGroups"
import { getNewOrder } from "@lib/utils/order/getNewOrder"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { DoneTasksInfo } from "./DoneTasksInfo"
import { ActiveTask } from "../ActiveTask"
import { groupTasks } from "./utils/groupTasks"
import { DraggableTaskItem } from "./item/DraggableTaskItem"
import { ComponentWithItemsProps } from "@lib/ui/props"
import { ActiveItemIdProvider } from "@lib/ui/list/ActiveItemIdProvider"

export const TaskBoard = ({ items }: ComponentWithItemsProps<Task>) => {
  const { mutate: updateTask } = useUpdateUserEntityMutation("task")

  const [groups, setGroups] = useState(() => groupTasks(items))
  useEffect(() => {
    setGroups(groupTasks(items))
  }, [items])

  return (
    <ActiveItemIdProvider initialValue={null}>
      <ActiveTask />
      <TaskBoardContainer>
        <DnDGroups
          groups={groups}
          getItemId={(task) => task.id}
          onChange={(id, { index, groupId }) => {
            const group = shouldBePresent(
              groups.find((group) => group.key === groupId)
            )

            const initialGroup = shouldBePresent(
              groups.find((group) => group.value.some((task) => task.id === id))
            )

            const order = getNewOrder({
              orders: group.value.map((task) => task.order),
              sourceIndex:
                initialGroup.key === group.key
                  ? group.value.findIndex((task) => task.id === id)
                  : null,
              destinationIndex: index,
            })

            updateTask({
              id,
              fields: {
                order,
                status: groupId,
              },
            })

            setGroups(
              groupTasks(
                items.map((task) =>
                  task.id === id ? { ...task, order, status: groupId } : task
                )
              )
            )
          }}
          renderGroup={({
            props: { children, ...containerProps },
            groupId: status,
            isDraggingOver,
          }) => (
            <TaskColumnContainer
              isDraggingOver={isDraggingOver}
              {...containerProps}
            >
              <ColumnHeader>
                <ColumnContent>
                  <Text weight="600">{taskStatusName[status]}</Text>
                </ColumnContent>
              </ColumnHeader>
              <TaskColumnContent>
                {status === "done" && <DoneTasksInfo />}
                {children}
              </TaskColumnContent>
              <ColumnFooter>
                <AddTaskColumn status={status} />
              </ColumnFooter>
            </TaskColumnContainer>
          )}
          renderItem={({ item, draggableProps, dragHandleProps, status }) => {
            return (
              <CurrentTaskProvider key={item.id} value={item}>
                <DraggableTaskItem
                  status={status}
                  {...draggableProps}
                  {...dragHandleProps}
                />
              </CurrentTaskProvider>
            )
          }}
        />
      </TaskBoardContainer>
    </ActiveItemIdProvider>
  )
}

Grouping Tasks by Status

The TaskBoard component receives a list of tasks via the items prop, but to display them effectively on our board, we need to organize them by status. This is where the groupTasks utility function comes into play. It groups tasks based on their status, creating an array of groups where each group contains tasks with the same status.

import { Task, TaskStatus, taskStatuses } from "@increaser/entities/Task"
import { groupItems } from "@lib/utils/array/groupItems"
import { sortEntitiesWithOrder } from "@lib/utils/entities/EntityWithOrder"
import { makeRecord } from "@lib/utils/record/makeRecord"
import { recordMap } from "@lib/utils/record/recordMap"
import { toEntries } from "@lib/utils/record/toEntries"

export const groupTasks = (items: Task[]) =>
  toEntries<TaskStatus, Task[]>({
    ...makeRecord(taskStatuses, () => []),
    ...recordMap(
      groupItems<Task, TaskStatus>(Object.values(items), (task) => task.status),
      sortEntitiesWithOrder
    ),
  })

Type-Safe Entries with toEntries

The toEntries function acts as Object.entries, except it returns an Entry object with a key and value property. This is useful for working with records in a more type-safe way.

export type Entry<K, V> = {
  key: K
  value: V
}

export const toEntries = <K extends string, T>(
  record: Partial<Record<K, T>>
): Entry<K, T>[] =>
  Object.entries(record).map(([key, value]) => ({
    key: key as K,
    value: value as T,
  }))

Representing Empty Groups

To guarantee that every status is represented in the groups, we use the makeRecord utility function. This function creates an object where each status serves as a key, and the value is initialized as an empty array. This ensures that even if a particular status doesn't have any tasks, its column will still be rendered on the board.

export const taskStatuses = ["backlog", "todo", "inProgress", "done"] as const
export type TaskStatus = (typeof taskStatuses)[number]

Next, we merge the empty groups created by the makeRecord function with the actual task groups generated by the groupItems utility function. The groupItems function takes an array of items and a function getKey that extracts the grouping key from each item. It returns a record object, where each key corresponds to a group, and the value is an array of items for that group.

export const groupItems = <T, K extends string | number>(
  items: T[],
  getKey: (item: T) => K
): Record<K, T[]> => {
  const result = {} as Record<K, T[]>

  items.forEach((item) => {
    const key = getKey(item)
    if (!result[key]) {
      result[key] = []
    }
    result[key]?.push(item)
  })

  return result
}

Merging Groups

By combining these two utilities, we ensure that the task board not only contains the actual tasks grouped by their status, but also displays all possible status columns, even if they have no tasks yet.

Ensuring Order with sortEntitiesWithOrder

To ensure the tasks within each group are displayed in the correct sequence, we use the sortEntitiesWithOrder utility function. This function takes an array of entities that have an order property and sorts them in ascending order based on that property. This ensures that tasks appear in the intended order within each status column.

import { order } from "../array/order"

export type EntityWithOrder = {
  order: number
}

export const sortEntitiesWithOrder = <T extends EntityWithOrder>(items: T[]) =>
  order(items, ({ order }) => order, "asc")

Managing Active Tasks with Providers

Once the tasks are grouped, we can proceed with rendering them on the board. To manage the task that is currently being opened in a modal or edited, we wrap everything inside the ActiveItemId provider. This provider stores the ID of the task being edited. To quickly set up this state, we leverage the getStateProviderSetup utility from RadzionKit.

import { getStateProviderSetup } from "../state/getStateProviderSetup"

export const { useState: useActiveItemId, provider: ActiveItemIdProvider } =
  getStateProviderSetup<string | null>("ActiveItemId")

Editing Tasks with ActiveTask Component

Next, the ActiveTask component checks if there is an active item ID. If a task is currently being edited, the component will render the EditTaskFormOverlay with the relevant task data. The EditTaskFormOverlay allows users to modify or delete the task. When the user finishes editing or deletes the task, the onFinish callback is triggered, which sets the active item ID to null, effectively closing the modal.

import { useUser } from "@increaser/ui/user/state/user"
import { CurrentTaskProvider } from "./CurrentTaskProvider"
import { EditTaskFormOverlay } from "./form/EditTaskFormOverlay"
import { useActiveItemId } from "@lib/ui/list/ActiveItemIdProvider"

export const ActiveTask = () => {
  const [activeItemId, setActiveItemId] = useActiveItemId()
  const { tasks } = useUser()

  if (!activeItemId) {
    return null
  }

  return (
    <CurrentTaskProvider value={tasks[activeItemId]}>
      <EditTaskFormOverlay onFinish={() => setActiveItemId(null)} />
    </CurrentTaskProvider>
  )
}

CurrentTaskProvider for Better Data Management

By using CurrentTaskProvider, we avoid the need to pass task data down through multiple levels of components. Instead, we can access the current task directly within any child component using the useCurrentTask hook. This is made possible by leveraging the getValueProviderSetup utility from RadzionKit, which, unlike getStateProviderSetup, only stores a value without allowing it to be modified by child components.

import { Task } from "@increaser/entities/Task"
import { getValueProviderSetup } from "@lib/ui/state/getValueProviderSetup"

export const { useValue: useCurrentTask, provider: CurrentTaskProvider } =
  getValueProviderSetup<Task>("Task")

Positioning the Columns with TaskBoardContainer

To position the columns on the board, we use the TaskBoardContainer, which is composed of two styled components: Wrapper and Container. The Wrapper component ensures that the board occupies the full available space, while the Container component arranges the columns in a horizontal stack. Additionally, the takeWholeSpaceAbsolutely utility function is used to guarantee that the Container fully occupies the space within the Wrapper, providing a fluid and responsive layout for the task columns.

import { takeWholeSpaceAbsolutely } from "@lib/ui/css/takeWholeSpaceAbsolutely"
import { toSizeUnit } from "@lib/ui/css/toSizeUnit"
import { HStack } from "@lib/ui/css/stack"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import styled from "styled-components"
import { taskBoardConfig } from "./config"

const Wrapper = styled.div`
  flex: 1;
  position: relative;
`

export const Container = styled(HStack)`
  ${takeWholeSpaceAbsolutely};
  overflow-x: auto;
  overflow-y: hidden;
  padding-bottom: 8px;
  gap: ${toSizeUnit(taskBoardConfig.columnGap)};
`

export const TaskBoardContainer = ({
  children,
}: ComponentWithChildrenProps) => {
  return (
    <Wrapper>
      <Container>{children}</Container>
    </Wrapper>
  )
}

Maintaining Consistent UI with taskBoardConfig

To maintain UI consistency, we store configuration settings such as padding and spacing in a taskBoardConfig object. By centralizing these values, we ensure that elements like column spacing and item padding are consistent across the task board, even when used in multiple places.

const columnHorizontalPadding = 8
const columnGap = columnHorizontalPadding * 2

export const taskBoardConfig = {
  itemHorizontalPadding: 8,
  columnHorizontalPadding,
  columnGap,
}

Migrating from react-beautiful-dnd to dnd-kit

Previously, we were using react-beautiful-dnd, but it came with several critical limitations and is no longer maintained. Fortunately, we had an abstraction layer in place, which provided a more comfortable API for managing drag-and-drop UI. This made switching to dnd-kit much easier since we only had to update the implementation of the DnDGroups component.

Utilizing DnDGroups Across Different Use Cases

The same DnDGroups component is also utilized in the "Scheduled" section of the "Tasks" page, where tasks are displayed in vertical lists grouped by date. Additionally, we use the DnDList component for simpler list-based drag-and-drop interfaces.

Having this abstraction layer significantly reduced the complexity of switching libraries, allowing us to maintain consistency across different parts of the app with minimal effort.

import { getNewOrder } from "@lib/utils/order/getNewOrder"
import { ReactNode, useCallback, useState } from "react"
import {
  DndContext,
  PointerSensor,
  useSensor,
  useSensors,
  UniqueIdentifier,
  DragEndEvent,
  DragOverlay,
  MeasuringStrategy,
  closestCorners,
} from "@dnd-kit/core"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { DnDItem } from "../DnDItem"
import { DnDGroup } from "./DnDGroup"
import { getDndGroupsItemDestination } from "./getDnDGroupsItemDestination"
import { getDndGroupsItemSource } from "./getDnDGroupsItemSource"
import {
  areEqualDnDGroupsItemLocations,
  DnDGroupsItemLocation,
} from "./DnDGroupsItemLocation"
import { Entry } from "@lib/utils/entities/Entry"
import { ComponentWithChildrenProps } from "@lib/ui/props"
import { DnDItemStatus } from "../DnDItemStatus"
import { order } from "@lib/utils/array/order"

type RenderGroupProps = Record<string, any> & ComponentWithChildrenProps

type RenderGroupParams<GroupId extends string> = {
  groupId: GroupId
  props: RenderGroupProps
  isDraggingOver: boolean
}

type RenderItemParams<Item> = {
  item: Item
  draggableProps?: Record<string, any>
  dragHandleProps?: Record<string, any>
  status: DnDItemStatus
}

export type DnDGroupsProps<
  GroupId extends string,
  ItemId extends UniqueIdentifier,
  Item
> = {
  groups: Entry<GroupId, Item[]>[]
  getItemId: (item: Item) => ItemId
  onChange: (itemId: ItemId, params: DnDGroupsItemLocation<GroupId>) => void
  renderGroup: (params: RenderGroupParams<GroupId>) => ReactNode
  renderItem: (params: RenderItemParams<Item>) => ReactNode
}

type ActiveDrag<
  GroupId extends string,
  ItemId extends UniqueIdentifier,
  Item
> = {
  id: ItemId
  initialLocation: DnDGroupsItemLocation<GroupId>
  groups: Entry<GroupId, Item[]>[]
}

export function DnDGroups<
  GroupId extends string,
  ItemId extends UniqueIdentifier,
  Item
>({
  groups,
  getItemId,
  onChange,
  renderGroup,
  renderItem,
}: DnDGroupsProps<GroupId, ItemId, Item>) {
  const [activeDrag, setActiveDrag] = useState<ActiveDrag<
    GroupId,
    ItemId,
    Item
  > | null>(null)

  const pointerSensor = useSensor(PointerSensor, {
    activationConstraint: {
      distance: 0.01,
    },
  })

  const sensors = useSensors(pointerSensor)

  const getItem = useCallback(
    (id: ItemId) => {
      return shouldBePresent(
        groups
          .flatMap(({ value }) => value)
          .find((item) => getItemId(item) === id)
      )
    },
    [getItemId, groups]
  )

  const handleDragEnd = useCallback(
    ({ over }: DragEndEvent) => {
      if (!activeDrag) {
        return
      }
      const { id, initialLocation } = activeDrag

      setActiveDrag(null)

      if (!over) {
        return
      }

      const destination = getDndGroupsItemDestination<GroupId>({
        item: over,
      })

      if (areEqualDnDGroupsItemLocations(initialLocation, destination)) {
        return
      }

      onChange(id, destination)
    },
    [activeDrag, onChange]
  )

  const handleDragOver = useCallback(
    ({ active, over }: DragEndEvent) => {
      if (!over) {
        return
      }

      const source = getDndGroupsItemSource<GroupId>({
        item: active,
      })

      const destination = getDndGroupsItemDestination<GroupId>({
        item: over,
      })

      if (source.groupId === destination.groupId) {
        return
      }

      setActiveDrag((prev) => {
        const { groups, ...rest } = shouldBePresent(prev)
        const { id } = rest
        const newGroups = groups.map((group) => {
          const { key, value } = group

          if (key === source.groupId) {
            return {
              key,
              value: value.filter((item) => getItemId(item) !== active.id),
            }
          }

          if (key === destination.groupId) {
            const itemOrderPairs = value.map(
              (item, index) => [item, index] as const
            )

            itemOrderPairs.push([
              getItem(id),
              getNewOrder({
                orders: itemOrderPairs.map(([, order]) => order),
                destinationIndex: destination.index,
                sourceIndex: null,
              }),
            ])

            return {
              key,
              value: order(itemOrderPairs, ([, order]) => order, "asc").map(
                ([item]) => item
              ),
            }
          }

          return group
        })

        return {
          ...rest,
          groups: newGroups,
        }
      })
    },
    [getItem, getItemId]
  )

  return (
    <DndContext
      sensors={sensors}
      onDragStart={({ active }) => {
        setActiveDrag({
          id: active.id as ItemId,
          groups,
          initialLocation: getDndGroupsItemSource<GroupId>({
            item: active,
          }),
        })
      }}
      onDragEnd={handleDragEnd}
      onDragOver={handleDragOver}
      onDragCancel={() => {
        setActiveDrag(null)
      }}
      measuring={{
        droppable: {
          strategy: MeasuringStrategy.Always,
        },
      }}
      collisionDetection={closestCorners}
    >
      {(activeDrag ? activeDrag.groups : groups).map(
        ({ key: groupId, value: items }) => {
          return (
            <DnDGroup
              key={groupId}
              id={groupId}
              itemIds={items.map(getItemId)}
              render={({ props, isDraggingOver }) =>
                renderGroup({
                  groupId,
                  isDraggingOver,
                  props: {
                    ...props,
                    children: (
                      <>
                        {items.map((item) => {
                          const key = getItemId(item)
                          return (
                            <DnDItem
                              key={key}
                              id={key}
                              render={(params) =>
                                renderItem({
                                  item,
                                  ...params,
                                  status:
                                    activeDrag?.id === key
                                      ? "placeholder"
                                      : "idle",
                                })
                              }
                            />
                          )
                        })}
                      </>
                    ),
                  },
                })
              }
            />
          )
        }
      )}

      <DragOverlay>
        {activeDrag
          ? renderItem({
              item: getItem(activeDrag.id),
              status: "overlay",
            })
          : null}
      </DragOverlay>
    </DndContext>
  )
}

Customizing Groups with DnDGroup

Our DnDGroup component doesn't include any built-in styling. Instead, all the rendering logic is delegated to the renderItems and renderGroups functions, which are passed as props. This gives us the flexibility to fully customize the appearance of the groups and items based on the specific requirements of each use case. By decoupling the logic from the presentation, we can reuse the same component in different contexts, tailoring the UI as needed without modifying the core functionality.

DnDGroups Component Properties

Now, let's go over each property of the DnDGroups component in detail:

  • groups: An array of groups, where each group is represented by an entry with a key and an array of items. The component is generic, allowing you to define the types for the group key and the item ID, ensuring type safety and flexibility.
  • getItemId: A function that extracts the unique identifier for each item. This is essential for managing drag-and-drop operations since the library needs to track the item's movement based on its ID.
  • onChange: A callback function that is triggered whenever an item is moved to a new group or position. This function receives two arguments: the item ID and the new location of the item (group and position). It allows you to handle state updates when an item’s position changes.
  • renderGroup: A function responsible for rendering the container for each group. It receives the group ID, additional props, and a boolean (isDraggingOver) that indicates whether an item is currently being dragged over this group. This provides the flexibility to adjust the UI based on the drag state.
  • renderItem: A function responsible for rendering each individual item within a group. It receives the item itself, along with draggable props, drag handle props, and the item’s status (e.g., idle, placeholder, or overlay). This gives full control over how items are displayed and interacted with during drag-and-drop actions.

Tracking the Dragged Item

We'll store the currently dragged item as an ActiveDrag object, which contains the item ID, the initial location of the item, and the groups array. This allows us to track the movement of the item during the drag operation and update the state accordingly when the drag ends.

Click Without Drag - PointerSensor Adjustment

Since we also want to allow users to click on an item without immediately triggering a drag operation, we adjust the activationConstraint for the PointerSensor to a very small distance. This ensures that the drag operation only begins if the user moves the pointer slightly after clicking, preventing accidental drags when the actual intent was to interact with the item through a click.

Handling Drag Operations

When the drag operation ends, we reset the activeDrag state and use the getDndGroupsItemDestination function to determine the final location of the dragged item. If the item was moved to a different group or position, we trigger the onChange callback with the updated item ID and its new location.

Determining Source and Destination

The getDndGroupsItemDestination function determines the destination of a dragged item during a drag-and-drop operation. It takes an item (of type Over) as input and checks if the item has current data. If it does, the function retrieves the containerId (representing the group) and index (position within the group) from the item's sortable data. It then returns the item's new location as a DnDGroupsItemLocation object. If no destination data is found, the function defaults to placing the item in the group identified by item.id at index 0.

import { Over } from "@dnd-kit/core"
import { DnDGroupsItemLocation } from "./DnDGroupsItemLocation"
import { SortableData } from "@dnd-kit/sortable"

type Input = {
  item: Over
}

export const getDndGroupsItemDestination = <GroupId extends string>({
  item,
}: Input): DnDGroupsItemLocation<GroupId> => {
  const destinationItem = item.data.current

  if (destinationItem) {
    const { containerId, index } = (destinationItem as SortableData).sortable

    return {
      groupId: containerId as GroupId,
      index,
    }
  }

  return {
    groupId: item.id as GroupId,
    index: 0,
  }
}

To check if two DnDGroupsItemLocation objects are equal, we compare their groupId and index properties. If both properties match, the locations are considered equal.

import { haveEqualFields } from "@lib/utils/record/haveEqualFields"

export type DnDGroupsItemLocation<GroupId extends string> = {
  groupId: GroupId
  index: number
}

export const areEqualDnDGroupsItemLocations = <GroupId extends string>(
  one: DnDGroupsItemLocation<GroupId>,
  another: DnDGroupsItemLocation<GroupId>
) => {
  return haveEqualFields(["groupId", "index"], one, another)
}

The handleDragOver callback handles moving items between groups during a drag-and-drop operation. Here's how it works:

  1. Check if the target is valid: If the item isn't being dragged over a valid target (over is undefined), the function exits.

  2. Get source and destination: The source is the original group and position of the item, and the destination is where the item is dragged to. These are determined using getDndGroupsItemSource and getDndGroupsItemDestination.

  3. Skip unnecessary updates: If the item is still within the same group, the function exits.

  4. Update the groups: The state is updated with the new group arrangement. The item is removed from the source group and added to the destination group.

  5. Reorder items: The destination group is sorted by item order using the getNewOrder and order utilities.

This ensures the item is moved between groups and properly ordered.

import { getLastItem } from "../array/getLastItem"
import { isEmpty } from "../array/isEmpty"
import { defaultOrder, orderIncrementStep } from "./config"

type GetNewOrderInput = {
  orders: number[]
  sourceIndex: number | null
  destinationIndex: number
}

export const getNewOrder = ({
  orders,
  sourceIndex,
  destinationIndex,
}: GetNewOrderInput): number => {
  if (isEmpty(orders)) {
    return defaultOrder
  }

  if (destinationIndex === 0) {
    return orders[0] - orderIncrementStep
  }

  const movedUp = sourceIndex !== null && sourceIndex < destinationIndex
  const previousIndex = movedUp ? destinationIndex : destinationIndex - 1
  const previous = orders[previousIndex]

  const shouldBeLast =
    (destinationIndex === orders.length - 1 && sourceIndex !== null) ||
    destinationIndex > orders.length - 1

  if (shouldBeLast) {
    return getLastItem(orders) + orderIncrementStep
  }

  const nextIndex = movedUp ? destinationIndex + 1 : destinationIndex
  const next = orders[nextIndex]

  return previous + (next - previous) / 2
}

The getNewOrder function calculates the new order value for an item being moved in a list. It uses an array of existing order values and adjusts the position based on the source and destination indices. Here's how it works:

  • Empty orders: If the orders array is empty, it returns the defaultOrder.
  • First position: If the item is moved to the first position (destinationIndex is 0), it returns a value slightly less than the current first item by subtracting the orderIncrementStep.
  • Middle of the list: The function calculates whether the item is moving up or down in the list, then selects the appropriate previous and next order values, returning the average between them to insert the item in the middle.
  • Last position: If the item is moved to the end of the list, it returns a value slightly greater than the last item by adding the orderIncrementStep.

This approach ensures that items are always placed in the correct order without needing to re-index the entire list.

import { Active } from "@dnd-kit/core"
import { DnDGroupsItemLocation } from "./DnDGroupsItemLocation"
import { shouldBePresent } from "@lib/utils/assert/shouldBePresent"
import { SortableData } from "@dnd-kit/sortable"

type Input = {
  item: Active
}

export const getDndGroupsItemSource = <GroupId extends string>({
  item,
}: Input): DnDGroupsItemLocation<GroupId> => {
  const { containerId, index } = (
    shouldBePresent(item.data.current) as SortableData
  ).sortable

  return {
    groupId: containerId as GroupId,
    index,
  }
}

The getDndGroupsItemSource function determines the original location (group and position) of an item before it is dragged. Here's how it works:

  • Input: It takes an Active item (from the dnd-kit drag context) as input.
  • Extract data: The function retrieves the containerId (representing the group ID) and index (the position within the group) from the item's current data. It asserts that this data exists using the shouldBePresent utility.
  • Return: It returns a DnDGroupsItemLocation object containing the groupId (the original group ID) and index (the item's position in the group).

This function is used to track where an item originated during a drag-and-drop operation, ensuring the application knows the initial position before the item is moved.

import { Over } from "@dnd-kit/core"
import { DnDGroupsItemLocation } from "./DnDGroupsItemLocation"
import { SortableData } from "@dnd-kit/sortable"

type Input = {
  item: Over
}

export const getDndGroupsItemDestination = <GroupId extends string>({
  item,
}: Input): DnDGroupsItemLocation<GroupId> => {
  const destinationItem = item.data.current

  if (destinationItem) {
    const { containerId, index } = (destinationItem as SortableData).sortable

    return {
      groupId: containerId as GroupId,
      index,
    }
  }

  return {
    groupId: item.id as GroupId,
    index: 0,
  }
}

The getDndGroupsItemDestination function determines the target location (group and position) of an item being dragged. It extracts the containerId (group ID) and index (position) from the destination item's data. If no destination data is found, it defaults to placing the item at the beginning of the group (index: 0). This function is essential for identifying where the item is dropped during drag-and-drop.

{
  ;(activeDrag ? activeDrag.groups : groups).map(
    ({ key: groupId, value: items }) => {
      return (
        <DnDGroup
          key={groupId}
          id={groupId}
          itemIds={items.map(getItemId)}
          render={({ props, isDraggingOver }) =>
            renderGroup({
              groupId,
              isDraggingOver,
              props: {
                ...props,
                children: (
                  <>
                    {items.map((item) => {
                      const key = getItemId(item)
                      return (
                        <DnDItem
                          key={key}
                          id={key}
                          render={(params) =>
                            renderItem({
                              item,
                              ...params,
                              status:
                                activeDrag?.id === key ? "placeholder" : "idle",
                            })
                          }
                        />
                      )
                    })}
                  </>
                ),
              },
            })
          }
        />
      )
    }
  )
}

Rendering Groups and Items During Drag

This part of the code maps over the task groups and renders each group using the DnDGroup component. The key point here is that if an item is being dragged (activeDrag is not null), the activeDrag.groups are used instead of the regular groups. This ensures that the board reflects the real-time state of the groups during the drag operation.

  • Why use activeDrag.groups?: When an item is being dragged, its original group might change temporarily (e.g., the item is removed from its original group and is "in transit"). Using activeDrag.groups allows the UI to reflect this updated state immediately, making sure the drag-and-drop interaction is seamless.

  • Rendering logic: Each group is rendered with the renderGroup function, and the items within the group are displayed using DnDItem. The status of each item is determined based on whether it’s the currently dragged item, giving it either a placeholder or idle status for visual feedback during the drag operation.

import { UniqueIdentifier, useDroppable } from "@dnd-kit/core"
import { SortableContext, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { ReactNode, useMemo } from "react"

type RenderParams = {
  props: Record<string, any>
  isDraggingOver: boolean
}

type DnDGroupProps<GroupId extends string, ItemId extends UniqueIdentifier> = {
  id: GroupId
  itemIds: ItemId[]
  render: (params: RenderParams) => ReactNode
}

export function DnDGroup<
  GroupId extends string,
  ItemId extends UniqueIdentifier
>({ id, itemIds, render }: DnDGroupProps<GroupId, ItemId>) {
  const { setNodeRef, over } = useDroppable({
    id,
  })

  const isDraggingOver = useMemo(() => {
    if (!over) {
      return false
    }

    if (over.id === id) {
      return true
    }

    const destinationItem = over.data.current
    if (destinationItem && destinationItem.sortable.containerId === id) {
      return true
    }

    return false
  }, [id, over])

  return (
    <SortableContext
      id={id}
      items={itemIds}
      strategy={verticalListSortingStrategy}
    >
      {render({
        isDraggingOver,
        props: {
          "data-droppable-id": id,
          ref: setNodeRef,
        },
      })}
    </SortableContext>
  )
}

The DnDGroup component represents a droppable group in a drag-and-drop interface. It uses dnd-kit's useDroppable hook and the SortableContext to enable drag-and-drop functionality for a list of items within the group. Here's a breakdown of how it works:

  • Props:

    • id: The unique identifier for the group.
    • itemIds: An array of item IDs within the group.
    • render: A render function that receives parameters indicating if an item is being dragged over the group, as well as any necessary props for the droppable container.
  • Droppable Setup: The useDroppable hook is used to set up the group as a droppable area, with setNodeRef applied to ensure the droppable region is properly referenced. The over variable indicates if a dragged item is currently hovering over this group.

  • Determining if the group is being dragged over: The isDraggingOver flag is calculated using useMemo. It checks if the dragged item is either directly over the group or its container.

  • SortableContext: The SortableContext wraps the group, providing a vertical list sorting strategy for the items. It manages the sorting behavior and item movement within the group.

<DragOverlay>
  {activeDrag
    ? renderItem({
        item: getItem(activeDrag.id),
        status: "overlay",
      })
    : null}
</DragOverlay>

Using DragOverlay for Visual Feedback

The DragOverlay renders a visual representation of the dragged item. If activeDrag exists, it retrieves the item using getItem(activeDrag.id) and sets its status to 'overlay'. If no item is being dragged, the overlay is not rendered. This provides smooth feedback during the drag operation.

Conclusion

In this post, we explored how to build a flexible and efficient drag-and-drop Kanban board using the DnDGroups component. We broke down the key parts of the component, including how tasks are grouped, rendered, and moved between columns, while ensuring smooth interactions with the dnd-kit library. By leveraging utilities like getNewOrder, managing drag states with DragOverlay, and using customizable render functions for groups and items, we can create a robust drag-and-drop interface that adapts to various use cases. This approach not only simplifies implementation but also ensures a seamless user experience.