Joe Gilmore

20 mins read

React & NextJS Sortable Lists: Beautiful DnD vs DnD Kit

Using Sortable Lists in React is fairly easy, in NextJS it's a little bit trickier. Here's how to do it with Beautiful DnD and DnD Kit.

React & NextJS Sortable Lists: Beautiful DnD vs DnD Kit

Spoiler Alert

Although there are probably hundreds of sortable list modules available for React, 2 of the biggest are Beautiful DnD and DnD Kit (formerly React DnD).

In this article we will examine both, and although I was going to give my verdict at the end, I'll just say it now: DnD Kit is the clear winner.

Here is a simple demo page where you can see both Beautiful DnD and DnD Kit in action

Beautiful DnD

Beautiful DnD is a very popular module, and it's easy to see why. It's pretty well documented, and it's been around for a long time. It's also very fairly easy to use, it supports both Vertical and Horizontal lists, and you can have multiple columns and drop items between them.

Before I show you the code I used to get it working, I'll first address it's biggest flaws:

  1. It doesn't work with NextJS out of the box. You have to do a lot extra work to get it to work with NextJS.
  2. Although it has Horizontal support, as soon as you want the items to wrap to the next line, it breaks. This is a known issue, and there is a workaround, but it's not ideal.
  3. To get it working locally you have to disable React Strict mode. This is a big no-no for me, and I'm sure many others.

Lets take a look at the code:

import { ComponentType, useEffect, useState } from "react";

import dynamic from "next/dynamic";
import { DraggableProvided, DroppableProvided } from "react-beautiful-dnd";

interface DataElement {
  id: number;
  content: string;
  emoji: string;
}

type List = DataElement[];

const initialItems: List = [
  { id: 1, content: "First", emoji: "๐ŸŽ" },
  { id: 2, content: "Second", emoji: "๐ŸŒ" },
  { id: 3, content: "Third", emoji: "๐ŸŠ" },
  { id: 4, content: "Fourth", emoji: "๐Ÿ‰"},
  { id: 5, content: "Fifth", emoji: "๐Ÿ‹" },
  { id: 6, content: "Sixth", emoji: "๐Ÿ‡" },
  { id: 7, content: "Seventh", emoji: "๐Ÿ“" },
  { id: 8, content: "Eighth", emoji: "๐Ÿ’" },
	{ id: 9, content: "Ninth", emoji: "๐Ÿ‘" },
	{ id: 10, content: "Tenth", emoji: "๐Ÿ" },
	{ id: 11, content: "Eleventh", emoji: "๐Ÿฅ" },
	{ id: 12, content: "Twelfth", emoji: "๐Ÿฅญ" },
	{ id: 13, content: "Thirteenth", emoji: "๐Ÿฅฅ" },
	{ id: 14, content: "Fourteenth", emoji: "๐Ÿฅ‘" },
	{ id: 15, content: "Fifteenth", emoji: "๐Ÿฅฆ" },
	{ id: 16, content: "Sixteenth", emoji: "๐Ÿฅฌ" },
	{ id: 17, content: "Seventeenth", emoji: "๐Ÿฅ’" },
	{ id: 18, content: "Eighteenth", emoji: "๐ŸŒถ" },
	{ id: 19, content: "Nineteenth", emoji: "๐ŸŒฝ" },
	{ id: 20, content: "Twentieth", emoji: "๐Ÿฅ•" },
];

// Importing the components dynamically to avoid SSR issues
const DragDropContext: ComponentType<any> = dynamic(
  () =>
    import("react-beautiful-dnd").then((mod) => {
      return mod.DragDropContext;
    }),
  { ssr: false }
) as ComponentType<any>;

const Droppable: ComponentType<any> = dynamic(
  () =>
    import("react-beautiful-dnd").then((mod) => {
      return mod.Droppable;
    }),
  { ssr: false }
) as ComponentType<any>;

const Draggable: ComponentType<any> = dynamic(
  () =>
    import("react-beautiful-dnd").then((mod) => {
      return mod.Draggable;
    }),
  { ssr: false }
) as ComponentType<any>;

const ListItem = ({ item }: { item: DataElement }) => {
  return (
    <div className="px-4 py-1 my-2 bg-white rounded-md shadow-md border-zinc-300 border flex justify-start items-center gap-x-4">
      <span className="text-4xl">{item.emoji}</span>
      <span className="text-zinc-400 font-bold">
        {item.id} - {item.content} Item...
      </span>
    </div>
  );
};

export default function DragAndDropBeautifulDnD() {

  const [list, setList] = useState<List>(initialItems);
 
  const onDragEnd = async (result: any) => {
    const { destination, source, draggableId } = result;
    if (!destination) {
      return;
    }
    if (
      destination.droppableId === source.droppableId &&
      destination.index === source.index
    ) {
      return;
    }
    const newList = Array.from(list);
    const [removed] = newList.splice(source.index, 1);
    newList.splice(destination.index, 0, removed);
    setList(newList);

  };


  return (
    <>
      <DragDropContext onDragEnd={onDragEnd}>
        <Droppable droppableId="droppable">
          {(provided: DroppableProvided) => (
            <div
              {...provided.droppableProps}
              ref={provided.innerRef}
              className="max-w-md mx-auto">
              {list.map((item, index) => (
                <Draggable
                  key={item.id} // Ensure each key is unique
                  draggableId={item.id.toString()} // Use a unique identifier for each draggable item
                  index={index}>
                  {(provided: DraggableProvided) => (
                    <div
                      ref={provided.innerRef}
                      {...provided.draggableProps}
                      {...provided.dragHandleProps}
                      className="">
                      <ListItem item={item} />
                    </div>
                  )}
                </Draggable>
              ))}
              {provided.placeholder}
            </div>
          )}
        </Droppable>
      </DragDropContext>
    </>
  );
}

Note to get this working in development mode or locally, you will need to add the following to your next.config.js file:

reactStrictMode: false

I'm not going to lie, this was a pain to get working, and feels like you have to hack the hell out of it, especially with TypeScript. Also note the dynamic imports, this is to avoid SSR issues.

DnD Kit

DnD Kit allows grids, and for me this is one huge advantage over Beautiful DnD. It also works out of the box (Well almost) with NextJS, and it's much easier to get working with TypeScript. It also works with React Strict Mode, which is a huge plus.

Lets also take a look at the code, I've split this up into 3 files:

โ”œโ”€โ”€ index.tsx
โ”œโ”€โ”€ Grid.tsx
โ”œโ”€โ”€ SortableItem.tsx
โ”œโ”€โ”€ Item.tsx

First up, the index.tsx file:

import React, { FC, useState, useCallback } from 'react';
import {
    DndContext,
    closestCenter,
    MouseSensor,
    TouchSensor,
    DragOverlay,
    useSensor,
    useSensors,
    DragStartEvent,
    DragEndEvent,
} from '@dnd-kit/core';
import { arrayMove, SortableContext, rectSortingStrategy } from '@dnd-kit/sortable';
import Grid from './Grid';
import SortableItem from './SortableItem';
import Item from './Item';
import { DataElement, initialItems } from '../../../pages/blog-demos/react-drag-and-drop-list';


export interface DataElement {
  id: number;
  content: string;
  emoji: string;
}

export type List = DataElement[];

export const initialItems: List = [
  { id: 1, content: "First", emoji: "๐ŸŽ" },
  { id: 2, content: "Second", emoji: "๐ŸŒ" },
  { id: 3, content: "Third", emoji: "๐ŸŠ" },
  { id: 4, content: "Fourth", emoji: "๐Ÿ‰"},
  { id: 5, content: "Fifth", emoji: "๐Ÿ‹" },
  { id: 6, content: "Sixth", emoji: "๐Ÿ‡" },
  { id: 7, content: "Seventh", emoji: "๐Ÿ“" },
  { id: 8, content: "Eighth", emoji: "๐Ÿ’" },
	{ id: 9, content: "Ninth", emoji: "๐Ÿ‘" },
	{ id: 10, content: "Tenth", emoji: "๐Ÿ" },
	{ id: 11, content: "Eleventh", emoji: "๐Ÿฅ" },
	{ id: 12, content: "Twelfth", emoji: "๐Ÿฅญ" },
	{ id: 13, content: "Thirteenth", emoji: "๐Ÿฅฅ" },
	{ id: 14, content: "Fourteenth", emoji: "๐Ÿฅ‘" },
	{ id: 15, content: "Fifteenth", emoji: "๐Ÿฅฆ" },
	{ id: 16, content: "Sixteenth", emoji: "๐Ÿฅฌ" },
	{ id: 17, content: "Seventeenth", emoji: "๐Ÿฅ’" },
	{ id: 18, content: "Eighteenth", emoji: "๐ŸŒถ" },
	{ id: 19, content: "Nineteenth", emoji: "๐ŸŒฝ" },
	{ id: 20, content: "Twentieth", emoji: "๐Ÿฅ•" },
];

const DndKitDragAndDrop: FC = () => {
    const [ items, setItems ] = useState(initialItems);
    const [ activeItem, setActiveItem ] = useState<DataElement | null>(null);

    const sensors = useSensors(useSensor(MouseSensor), useSensor(TouchSensor));

    const handleDragStart = useCallback((event: DragStartEvent) => {
        setActiveItem(items.find((item) => item.id === event.active.id) || null);
    }, []);
    const handleDragEnd = useCallback(async (event: DragEndEvent) => {
        const { active, over } = event;
        let updatedItems = items;

        if (active.id !== over?.id) {
            setItems((items) => {
                const oldIndex = items.findIndex((item) => item.id === active.id);
                const newIndex = items.findIndex((item) => item.id === over?.id);
                updatedItems = arrayMove(items, oldIndex, newIndex);
                return updatedItems;
            });
        }

        setActiveItem(null);

    }, []);
    const handleDragCancel = useCallback(() => {
        setActiveItem(null);
    }, []);

    return (
        <>
          <DndContext
              sensors={sensors}
              collisionDetection={closestCenter}
              onDragStart={handleDragStart}
              onDragEnd={handleDragEnd}
              onDragCancel={handleDragCancel}
          >
              <SortableContext items={items} strategy={rectSortingStrategy}>
                  <Grid columns={2}>
                      {items.map((item) => (
                          <SortableItem 
                            key={item.id} 
                            item={item} 
              
                          />
                      ))}
                  </Grid>
              </SortableContext>
              <DragOverlay adjustScale style={{ transformOrigin: '0 0 ' }}>
                  {activeItem ? <Item 
                    item={activeItem} 
                    isDragging 
                  /> : null}
              </DragOverlay>
          </DndContext>

        </>
    );
};

export default ReactDragAndDropListGrid;

Next up let's look as Grid.tsx:

import React, { FC } from 'react';

type GridProps = {
    columns: number;
		children: React.ReactNode;
};

const Grid: FC<GridProps> = ({ children, columns }) => {
    return (
        <div
            className='grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-6 xl:grid-cols-8 gap-4 max-w-800 mx-auto my-10'
        >
            {children}
        </div>
    );
};

export default Grid;

Next, the SortableItem.tsx file:

import React, { FC } from "react";
import { useSortable } from "@dnd-kit/sortable";
import { CSS } from "@dnd-kit/utilities";
import Item, { ItemProps } from "./Item";


const SortableItem: FC<ItemProps> = (props) => {
    const {
        isDragging,
        attributes,
        listeners,
        setNodeRef,
        transform,
        transition
    } = useSortable({ id: props.item.id });

    const style = {
        transform: CSS.Transform.toString(transform),
        transition: transition || undefined,
    };

    return (
        <Item
            ref={setNodeRef}
            style={style}
            withOpacity={isDragging}
            {...props}
            {...attributes}
            {...listeners}
			      aria-describedby="Draggable Item"
        />
    );
};

export default SortableItem;

And finally the Item.tsx file:

import clsx from 'clsx';
import React, { forwardRef, HTMLAttributes } from 'react';
import { DataElement } from '.';

type ItemProps = HTMLAttributes<HTMLDivElement> & {
    item: DataElement;
    withOpacity?: boolean;
    isDragging?: boolean;
};

const Item = forwardRef<HTMLDivElement, ItemProps>(({ withOpacity, isDragging, style, ...props }, ref) => {
    return <div 
			ref={ref} 
			className={
				clsx(
					(
						isDragging 
						? 'cursor-grabbing shadow-lg text-zinc-700 font-extrabold' 
						: 'cursor-grab shadow-sm text-zinc-400 font-bold' 
					)
				, 
				`bg-white border border-gray-200 
				rounded-lg shadow-sm cursor-pointer  
				text-center flex flex-col justify-center items-center
				h-24
				hover:border-gray-300 hover:bg-gray-50`
			)}
			style={{
				opacity: withOpacity ? '0.5' : '1',
				transform: isDragging ? 'scale(1.1)' : 'scale(1)',
				...style,
			}}
		{...props}>
			<span className=' text-3xl'>{props.item.emoji}</span>
			<div className='flex flex-wrap justify-center items-center gap-x-2 flex-wrap'>
				<span className='text-lg'>{props.item.id}</span>
				<span className='text-xs'>{props.item.content}</span>
			</div>
		</div>;
});

// ๐Ÿ‘‡๏ธ set display name (Fixes TypeScript issue)
Item.displayName = 'Item';

export default Item;

I found it much easier to get DndKit working "out of the box" and it required way less hacking around in NextJS and TypeScript. Plus the fact that it works with grid layouts nicely is a huge plus for me.

Conclusion

Although Beautiful DnD is a great module, it's just too much of a pain to get working with NextJS, and the fact that it doesn't work with React Strict Mode is a big no-no for me.

DnD Kit on the other hand is a breeze to get working, it works with React Strict Mode, and it works with grid layouts. It's a clear winner for me.

Now one final thing to note here, is that in my demos I am using only one single column, but both libraries actually allow multiple columns and let you drag and drop items between them, I left this out of my demo page to keep them simple, but it's worth noting that both libraries support this.

I will be adding a new demo page soon to show this in action (most likely using Dnd Kit!!)