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.
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
, oroverlay
). 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:
Check if the target is valid: If the item isn't being dragged over a valid target (
over
is undefined), the function exits.Get source and destination: The
source
is the original group and position of the item, and thedestination
is where the item is dragged to. These are determined usinggetDndGroupsItemSource
andgetDndGroupsItemDestination
.Skip unnecessary updates: If the item is still within the same group, the function exits.
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.
Reorder items: The destination group is sorted by item order using the
getNewOrder
andorder
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 thedefaultOrder
. -
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 theorderIncrementStep
. - 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 thednd-kit
drag context) as input. -
Extract data: The function retrieves the
containerId
(representing the group ID) andindex
(the position within the group) from the item's current data. It asserts that this data exists using theshouldBePresent
utility. -
Return: It returns a
DnDGroupsItemLocation
object containing thegroupId
(the original group ID) andindex
(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"). UsingactiveDrag.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 usingDnDItem
. The status of each item is determined based on whether it’s the currently dragged item, giving it either aplaceholder
oridle
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, withsetNodeRef
applied to ensure the droppable region is properly referenced. Theover
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 usinguseMemo
. 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.