I've been working on a note-taking application, and part of that involved building a sidebar with drag-and-drop support.
The requirements were fairly straightforward:
- Notes should be nestable (drag one into another)
- A pinned section at the top
- Drag interactions shouldn't interfere with normal clicks for file selection
After looking around, I went with dnd-kit since it's actively maintained and widely recommended.
While the official documentation is good, there were a few gaps I had to figure out while integrating it into a real project, especially around TypeScript and some of the interaction patterns. This post covers the parts that weren't immediately obvious.
Here's a reference to the sidebar so the rest of the article has some context:
Choosing the right package
First things first, the version. I'm using @dnd-kit/react@0.3.2, which is the newer React-specific package and not @dnd-kit/core. Most tutorials, Stack Overflow answers, and examples online are still based on the older package. The APIs are similar enough to feel interchangeable, which can be misleading. You'll often think you're following the right approach, only to spend a lot of time debugging why things aren't working as expected.
Working with IDs
Everything in dnd-kit revolves around IDs. They're the link between what you render in the UI and what you receive in drag events.
In this sidebar, IDs are used to distinguish between dropping on a section versus dropping on another note. That distinction is what drives the actual behaviour, pinning, moving to root, or nesting.
Static IDs are for the fixed drop zones that always exist in the UI:
export const DROPPABLE_NOTES_SECTION_ID = 'notes-section';
export const DROPPABLE_PINNED_SECTION_ID = 'pinned-section';
Dynamic IDs are per note, using the note's own ID from your data model. This is what lets notes act as both draggable items and drop targets at the same time:
useDraggable({ id: note.id });
useDroppable({ id: note.id });
Draggable and droppable elements
Since each note needs to support both dragging and receiving drops, you end up attaching both hooks to the same element. Both hooks return a ref, so you rename them to avoid the collision and combine them into a single callback ref:
const { ref: draggableRef } = useDraggable({ id: note.id });
const { ref: droppableRef } = useDroppable({ id: note.id });
const combinedRef = (el: HTMLElement | null) => {
draggableRef(el);
droppableRef(el);
};
<div ref={combinedRef}>
{node.name}
</div>
This pattern ensures a note can be dragged while still acting as a valid drop target for other notes.
Setting up the provider
All drag and drop interactions live inside DragDropProvider. It's also where you wire up your event handlers:
<DragDropProvider
onDragStart={onDragStart}
onDragEnd={onDragEnd}
onDragOver={onDragOver}
>
{/* content */}
</DragDropProvider>
The main behaviour is defined in onDragEnd. This is where you decide what happens after the drag event completes.
const onDragEnd: OnDragEnd = (event) => {
const sourceId = event.operation?.source?.id;
const targetId = event.operation?.target?.id;
if (!sourceId || !targetId) return;
if (sourceId === targetId) return;
if (targetId === DROPPABLE_PINNED_SECTION_ID) {
setPin.mutate({ nodeIds: [sourceId], isPinned: true });
return;
}
if (targetId === DROPPABLE_NOTES_SECTION_ID) {
moveNodes.mutate({ nodeIds: [sourceId], newParentId: null });
return;
}
moveNodes.mutate({ nodeIds: [sourceId], newParentId: targetId });
};
At its core, we just compare the source and target IDs and mapping that to the appropriate action.
Gotchas
Typing event handlers in TypeScript
While dnd-kit exposes event types like DragEndEvent, using them directly didn't work in my case. Inferring them from the component props turned out to be more reliable:
import { type ComponentProps } from 'react';
type OnDragEnd =
NonNullable<ComponentProps<typeof DragDropProvider>['onDragEnd']>;
Same pattern works for onDragStart and onDragOver
Preventing drag from interfering with clicks
By default dnd-kit starts a drag on pointer down, which makes simple clicks feel unreliable in a sidebar. The fix is activation constraints on the PointerSensor, so a drag only starts after the pointer actually moves:
import { PointerActivationConstraints } from '@dnd-kit/dom';
import { PointerSensor, KeyboardSensor } from '@dnd-kit/react';
const sensors = [
PointerSensor.configure({
activationConstraints: [
new PointerActivationConstraints.Distance({ value: 6 }),
],
}),
KeyboardSensor,
];
<DragDropProvider sensors={sensors} ...>
value: 6 means the pointer has to move 6 pixels before dnd-kit treats it as a drag. Anything less and it fires as a normal click. I tried a few values between 4 and 10 and 6 felt the most natural.
The drag overlay (optional but a good add)
By default, dnd-kit moves the original element during a drag. In a tightly structured layout like a sidebar, this can lead to unwanted visual shifts. DragOverlay lets you render a separate preview that follows the cursor instead, and you have full control over what it looks like:
<DragOverlay>
{activeDragNodeId ? (
<div className="rounded-xl border bg-white px-2 py-1 text-xs shadow-md">
{nodes.find(n => n.id === activeDragNodeId)?.name ?? 'moving note'}
</div>
) : null}
</DragOverlay>
This keeps the layout stable and makes the whole drag feel intentional rather than like you're wrestling with the UI.
Wrapping up
I'm not sure if this is the "right" way to do it, but it worked well for my use case. If you have a better approach, I'd love to hear it. Let me know if you'd like the GitHub link, this was part of a personal project, but I can put together a simplified version to share.
I'm just starting to write, so I'd love any feedback on what I can do to make it better!
Until next time 👋


Top comments (0)