How To Build a Sortable List with Drag Handle in React Hook Form using Dnd-kit

Published on November 28, 2023   |   8 min read

Dragging and dropping items is a common user interaction pattern that can greatly enhance the user experience in web applications. When building sortable lists with drag handles in React.js, the Dnd-kit library provides a powerful and flexible solution.

Getting Started

In this post, you will see how to accomplish this in a simple react component and also in a complex form that uses react hook form. For the complex part, I will build the sortable list in an already existing invoice generator app.

Installing Dnd-kit library

Dnd-kit is a modern drag-and-drop library for React, making it easy to implement complex drag-and-drop interfaces. Before we dive into building our sortable list, let's make sure we have Dnd-kit installed in our project:

npm install @dnd-kit/core @dnd-kit/sortable @dnd-kit/utilities

Implementation

Simple Implementation

Let's assume we have 2 components: Items (array of products) and SingleItem. In the items component you map over the products and put the single item inside like this:

const Items = () => {
    const [products, setProducts] = useState(/* Some products here */)
    
    return (
        {
            products.map((product, index) => (
                <SingleItem
                    key={product.id} 
                    product={product}
                />
            ))
        }
    )
}

const SingleItem = ({product}) => {
    return (
        <div>
            {/* Display item here */}
        </div>
    )
}

To set up the initial sortable list, wrap the products.map with these:

// Dnd-kit imports
import {
    DndContext,
    closestCenter,
    MouseSensor,
    TouchSensor,
    useSensor,
    useSensors,
    DragEndEvent
} from "@dnd-kit/core";
import {
    SortableContext,
    verticalListSortingStrategy,
} from "@dnd-kit/sortable";

const Items = () => {
    const [products, setProducts] = useState(/* Some products here */)

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

    const handleDragEnd = useCallback(
        async (event: DragEndEvent) => {
            const { active, over } = event;
            setActiveId(active.id);

            if (active.id !== over?.id) {
                const oldIndex = products.findIndex(
                    (item) => item.id === active.id
                );
                const newIndex = products.findIndex(
                    (item) => item.id === over?.id
                );

                // Rearrange the items
                const updatedProducts = arrayMove(products, oldIndex, newIndex);
                setProducts(updatedProducts)
            }
        },
        [products, setValue]
    );
    return (
        <DndContext
            sensors={sensors}
            collisionDetection={closestCenter}
            onDragEnd={handleDragEnd}
        >
            <SortableContext
                items={products}
                strategy={verticalListSortingStrategy}
            >
                {
                    products.map((product, index) => (
                        <SingleItem 
                            key={product.id} 
                            product={product}
                        />
                    ))
                }
            </SortableContext>
        </DndContext>
    )
}

Next, we will add useSortable hook from dnd-kit to SingleItem component

const SingleItem = ({product}) => {
    // DnD
    const {
        attributes,
        listeners,
        setNodeRef,
        transform,
        transition,
    } = useSortable({ id: product.id });

    const style = {
        transition,
        transform: CSS.Transform.toString(transform),
    };
    return (
        <div
            style={style}
            {...attributes}
        >
            {/* Display item here */}

            {/* Drag and Drop grip button. */}
            <div ref={setNodeRef} {...listeners}>

                {/* This is an icon from Lucide icons */}
                <GripVertical /> 
            </div>
        </div>
    )
}

The parameter id inside useSortable has to be a unique identifier for this product

Complex Implementation inside an invoice generation app using react-hook-form

Again, let's assume we have 2 components: Items and SingleItem.

Unlike the simple implementation, let's say we get the products from a useFieldArray method from react-hook-form like this:

const { products, append, remove, move } = useFieldArray({
    control: control,
    name: ITEMS_NAME,
});

This method is useful when you have an array of things inside a form that you need to keep track of.

The append and remove functions are optional, but in this case, they are used to add or remove certain items from the invoice:

const addNewField = () => {
    append({
        name: "",
        description: "",
        quantity: 0,
        unitPrice: 0,
        total: 0,
    });
};

const removeField = (index: number) => {
    remove(index);
};

Our old handleDragEnd logic for dnd context, now will not work properly, so let's change up a few things.

Everything else can stay the same as with simple implementation, except we will change one little detail in the handleDragEnd function.

We will use the move method from useFieldArray and replace it with old rearrange logic:

Replace this:

// Rearrange the items
const updatedProducts = arrayMove(products, oldIndex, newIndex);
setProducts(updatedProducts)

With this:

// New rearrange logic for react-hook-form implementation
move(oldIndex, newIndex);

Now, the products can be grabbed and reordered properly.

Optional additions

Optionally, you can also add a few methods that can move a product up or down one time when clicking on certain buttons.

const moveFieldUp = (index: number) => {
    if (index > 0) {
        move(index, index - 1);
    }
};

const moveFieldDown = (index: number) => {
    if (index < fields.length - 1) {
        move(index, index + 1);
    }
};

Now, you need to pass the index and these functions to the SingleItem component. Next, you accept these new properties in SingleItem and place them accordingly:

const SingleItem = ({product, index, moveFieldUp, moveFieldDown}) => {
    return (
        <div>
            {/* Display item and grip handler here */}

            <button onClick={() =>moveFieldUp(index)}>
                Move up
            </button>

            <button onClick={() => moveFieldDown(index)}>
                Move down
            </button>
        </div>
    )
}

The final implementation looks like this:

Loading...

Related: