Building Solo Kanban: A Developer's Project Board with Next.js and @dxsolo/ui Part 3

Welcome back. In Part 2, we built the board layout: an app shell with a collapsible sidebar, four scrollable columns with CSS Grid, and responsive issue cards with container queries. Everything renders. Nothing moves.
Now we fix that. By the end of this post, you will be able to drag issue cards between columns, reorder cards within a column, and see those changes persist to the database. We will handle optimistic updates so the UI feels instant, and we will deal with the edge cases that make drag and drop genuinely hard.
Series overview:
Before we write code, let's evaluate the options. There are three serious contenders for drag and drop in React, plus the native browser API.
The browser ships a drag-and-drop API. You can set draggable="true" on elements, listen for dragstart, dragover, and drop events, and move things around. It works, but it has significant drawbacks for a kanban board:
For a production kanban board, this is too much work. We want a library.
react-beautiful-dnd by Atlassian was the gold standard for years. It is now unmaintained. @hello-pangea/dnd is a community fork that added React 18 and 19 compatibility. It works well, has an excellent API, and handles accessibility out of the box.
The downside: it is opinionated about animations and layout. It controls the drag preview, the drop animation, and the placeholder element. If you want custom drag previews or non-standard layouts, you fight the library.
dnd-kit is modular and flexible. It gives you primitives (useDraggable, useDroppable, useSortable) and lets you compose them however you want. The @dnd-kit/react package targets React 19, but it is still pre-1.0 (version 0.3.x as of this writing). The API has changed between versions, documentation lags behind, and some edge cases with nested scroll containers require workarounds.
Great library with a promising future, but the instability risk is real for a tutorial that people will follow months from now.
This is Atlassian's replacement for react-beautiful-dnd. It is framework-agnostic, uses the native browser drag-and-drop API under the hood, and provides a small, composable API. The key packages:
@atlaskit/pragmatic-drag-and-drop — core draggable and drop target primitives@atlaskit/pragmatic-drag-and-drop-hitbox — edge detection for reordering@atlaskit/pragmatic-drag-and-drop-react-drop-indicator — visual drop indicatorsIt is stable (1.x), actively maintained by Atlassian (they use it in Jira and Confluence), works with any view layer, and handles the native DnD quirks for you. It does not control your rendering — you decide what the drag preview looks like, how the drop indicator renders, and what animations to use.
The trade-off: it is lower-level than @hello-pangea/dnd. You write more code. But you have full control.
We are going with Pragmatic Drag and Drop. Here is why:
Install the packages we need:
npm install @atlaskit/pragmatic-drag-and-drop @atlaskit/pragmatic-drag-and-drop-hitboxTwo packages:
**@atlaskit/pragmatic-drag-and-drop** — the core library. Provides draggable, dropTargetForElements, and monitorForElements.**@atlaskit/pragmatic-drag-and-drop-hitbox** — provides attachClosestEdge and extractClosestEdge, which we need to know whether a card was dropped above or below another card.We are not installing the drop indicator package (@atlaskit/pragmatic-drag-and-drop-react-drop-indicator). It pulls in Atlassian's styling dependencies which we do not want. We will build a simple Tailwind-based indicator ourselves.
Before we wire it up, let's understand the mental model. Pragmatic DnD has three core concepts:
draggable({ element, getInitialData }) and the library attaches the native drag listeners.dropTargetForElements({ element, getData, onDragEnter, onDrop }) and the library handles dragover/drop events.monitorForElements({ onDrop }) to react to any drop, regardless of which specific drop target received it.The flow:
draggable fires onDragStartdropTargetForElements fires onDragEnter, onDragonDrop, and the monitor fires onDropEverything is imperative. You call these functions inside useEffect, passing DOM refs. The library attaches and cleans up event listeners for you.
Let's start with the simplest step: making issue cards draggable. We will not handle drops yet — just make the cards pick-uppable.
Update src/components/board/IssueCard.tsx:
"use client";
import { useRef, useEffect, useState } from "react";
import { Card, CardContent } from "@dxsolo/ui";
import type { IssueWithRelations } from "@/types";
import { IssuePriority } from "@prisma/client";
import { draggable } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
interface IssueCardProps {
issue: IssueWithRelations;
}
const priorityConfig: Record<
IssuePriority,
{ label: string; className: string }
> = {
URGENT: { label: "Urgent", className: "bg-red-100 text-red-700" },
HIGH: { label: "High", className: "bg-orange-100 text-orange-700" },
MEDIUM: { label: "Med", className: "bg-yellow-100 text-yellow-700" },
LOW: { label: "Low", className: "bg-gray-100 text-gray-500" },
};
export function IssueCard({ issue }: IssueCardProps) {
const ref = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const priority = priorityConfig[issue.priority];
const completedSubtasks = issue.subtasks.filter((s) => s.completed).length;
const totalSubtasks = issue.subtasks.length;
useEffect(() => {
const el = ref.current;
if (!el) return;
return draggable({
element: el,
getInitialData: () => ({
type: "card",
issueId: issue.id,
status: issue.status,
order: issue.order,
}),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
});
}, [issue.id, issue.status, issue.order]);
return (
<div ref={ref}>
<Card
className={`cursor-grab hover:ring-2 hover:ring-blue-300 transition-shadow ${
isDragging ? "opacity-40" : ""
}`}
>
<CardContent className="p-3">
{/* Title */}
<h3 className="text-sm font-medium leading-snug">{issue.title}</h3>
{/* Meta row: priority + epic label */}
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className={`text-xs font-medium px-1.5 py-0.5 rounded ${priority.className}`}
>
{priority.label}
</span>
{/* Epic label: hidden when container is too narrow */}
{issue.epic && (
<span
className="text-xs px-1.5 py-0.5 rounded hidden @[200px]:inline-block"
style={{
backgroundColor: `${issue.epic.color}20`,
color: issue.epic.color,
}}
>
{issue.epic.name}
</span>
)}
</div>
{/* Subtask progress */}
{totalSubtasks > 0 && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{
width: `${(completedSubtasks / totalSubtasks) * 100}%`,
}}
/>
</div>
<span className="text-xs text-gray-400">
{completedSubtasks}/{totalSubtasks}
</span>
</div>
)}
</CardContent>
</Card>
</div>
);
}What changed:
<div ref={ref}> around the Card. Pragmatic DnD attaches native draggable="true" and event listeners to the element you give it. We use a wrapper div rather than trying to attach to the Card directly, because Card is a @dxsolo/ui component and we do not want to modify it.**draggable() in a useEffect.** This is the core API. We pass:element: the DOM node to make draggablegetInitialData: a function that returns data about what is being dragged. We include the issue ID, current status, and current order. Drop targets will read this data to know what was dropped.onDragStart / onDrop: callbacks to toggle a isDragging state for visual feedback**isDragging visual feedback.** When a card is being dragged, we reduce its opacity. The browser shows a native drag preview (a screenshot of the element), while the original element fades to indicate where it came from. We also changed cursor-pointer to cursor-grab to hint that cards are draggable.draggable() returns a cleanup function. Returning it from useEffect ensures listeners are removed when the component unmounts.Visit http://localhost:3000 (or let the dev server hot-reload). Try picking up a card. You should see:
If the cards are not draggable, check that the ref is attached to the wrapper div and that the useEffect dependencies include issue.id.
Now we make columns accept drops. When a card is dragged over a column, that column should visually indicate it is a valid drop zone. When the card is dropped, we need to know which column received it.
Update src/components/board/Column.tsx:
"use client";
import { useRef, useEffect, useState } from "react";
import { IssueStatus } from "@prisma/client";
import { IssueCard } from "./IssueCard";
import type { IssueWithRelations } from "@/types";
import { dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
interface ColumnProps {
status: IssueStatus;
label: string;
issues: IssueWithRelations[];
onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
}
const statusColors: Record<IssueStatus, string> = {
OPEN: "bg-gray-100 text-gray-700",
IN_PROGRESS: "bg-blue-100 text-blue-700",
REVIEW: "bg-amber-100 text-amber-700",
DONE: "bg-green-100 text-green-700",
};
const statusBorderColors: Record<IssueStatus, string> = {
OPEN: "border-t-gray-400",
IN_PROGRESS: "border-t-blue-500",
REVIEW: "border-t-amber-500",
DONE: "border-t-green-500",
};
const emptyMessages: Record<IssueStatus, string> = {
OPEN: "No open issues. Create one to get started.",
IN_PROGRESS: "Nothing in progress. Drag an issue here.",
REVIEW: "Review queue is empty.",
DONE: "No completed issues yet.",
};
export function Column({ status, label, issues, onMoveIssue }: ColumnProps) {
const ref = useRef<HTMLDivElement>(null);
const [isDraggedOver, setIsDraggedOver] = useState(false);
useEffect(() => {
const el = ref.current;
if (!el) return;
return dropTargetForElements({
element: el,
getData: () => ({ type: "column", status }),
canDrop: ({ source }) => source.data.type === "card",
onDragEnter: () => setIsDraggedOver(true),
onDragLeave: () => setIsDraggedOver(false),
onDrop: ({ source }) => {
setIsDraggedOver(false);
const issueId = source.data.issueId as string;
const sourceStatus = source.data.status as IssueStatus;
// Only handle drops directly on the column (not on a card within the column)
// Card-level drops are handled by the card drop targets
if (sourceStatus === status) return;
// Dropping on the column itself puts the card at the end
const newOrder = issues.length;
onMoveIssue(issueId, status, newOrder);
},
});
}, [status, issues.length, onMoveIssue]);
return (
<div
ref={ref}
className={`flex flex-col h-full min-h-0 rounded-lg bg-gray-50 border-t-2 ${statusBorderColors[status]} ${
isDraggedOver ? "ring-2 ring-blue-300 bg-blue-50/50" : ""
}`}
>
{/* Column header: sticky, never scrolls */}
<div className="flex items-center justify-between px-3 py-2 border-b border-gray-200">
<div className="flex items-center gap-2">
<span
className={`text-xs font-semibold px-2 py-0.5 rounded-full ${statusColors[status]}`}
>
{label}
</span>
<span className="text-xs text-gray-400">{issues.length}</span>
</div>
</div>
{/* Card list: scrollable, container query context */}
<div className="flex-1 overflow-y-auto p-2 flex flex-col gap-2 @container">
{issues.map((issue) => (
<IssueCard key={issue.id} issue={issue} />
))}
{issues.length === 0 && (
<div
className={`flex items-center justify-center h-32 border-2 border-dashed rounded-lg ${
isDraggedOver
? "border-blue-300 bg-blue-50"
: "border-gray-200"
}`}
>
<p className="text-xs text-gray-400 text-center px-4">
{emptyMessages[status]}
</p>
</div>
)}
</div>
</div>
);
}What changed:
**dropTargetForElements() in a useEffect.** This registers the column as a drop target. We pass:element: the column's root divgetData: returns { type: "column", status } so we can identify which column received the dropcanDrop: only accept drops from elements with type: "card". This prevents other draggable elements (if we add any later) from dropping into columnsonDragEnter / onDragLeave: toggle visual feedbackonDrop: compute the new order and call onMoveIssue**onMoveIssue prop.** The column does not manage issue state — the Board component does. When a drop happens, the column calls onMoveIssue(issueId, newStatus, newOrder) and the board handles the state update.Notice we are not handling card-level drop targets yet. Right now, dropping a card on a column always places it at the end. In the next section, we will make individual cards into drop targets so you can drop between specific cards.
The Board component needs to manage the issue list as local state so we can update it optimistically when a card is dropped. Right now it receives issues as a prop and groups them — it does not hold any state.
Update src/components/board/Board.tsx:
"use client";
import { useState, useCallback } from "react";
import { IssueStatus } from "@prisma/client";
import { Column } from "./Column";
import type { IssueWithRelations } from "@/types";
import { moveIssue } from "@/actions/move-issue";
const COLUMNS: { status: IssueStatus; label: string }[] = [
{ status: "OPEN", label: "Open" },
{ status: "IN_PROGRESS", label: "In Progress" },
{ status: "REVIEW", label: "Review" },
{ status: "DONE", label: "Done" },
];
interface BoardProps {
issues: IssueWithRelations[];
}
export function Board({ issues: initialIssues }: BoardProps) {
const [issues, setIssues] = useState(initialIssues);
const handleMoveIssue = useCallback(
async (issueId: string, newStatus: IssueStatus, newOrder: number) => {
// Snapshot for rollback
const previousIssues = issues;
// Optimistic update
setIssues((prev) => {
const issue = prev.find((i) => i.id === issueId);
if (!issue) return prev;
// Remove the issue from its current position
const without = prev.filter((i) => i.id !== issueId);
// Get issues in the target column, sorted by order
const targetColumnIssues = without
.filter((i) => i.status === newStatus)
.sort((a, b) => a.order - b.order);
// Clamp the order to valid range
const clampedOrder = Math.min(newOrder, targetColumnIssues.length);
// Insert at the new position and re-index the target column
targetColumnIssues.splice(clampedOrder, 0, {
...issue,
status: newStatus,
order: clampedOrder,
});
// Re-index all items in the target column
const reindexed = targetColumnIssues.map((item, index) => ({
...item,
order: index,
}));
// Rebuild the full issue list
const otherIssues = without.filter((i) => i.status !== newStatus);
return [...otherIssues, ...reindexed];
});
// Persist to database
try {
await moveIssue(issueId, newStatus, newOrder);
} catch (error) {
console.error("Failed to move issue:", error);
// Rollback on failure
setIssues(previousIssues);
}
},
[issues]
);
const issuesByStatus = COLUMNS.map((col) => ({
...col,
issues: issues
.filter((issue) => issue.status === col.status)
.sort((a, b) => a.order - b.order),
}));
return (
<div className="h-full p-4 overflow-x-auto">
<div className="grid grid-cols-4 gap-4 h-full min-w-[800px]">
{issuesByStatus.map((col) => (
<Column
key={col.status}
status={col.status}
label={col.label}
issues={col.issues}
onMoveIssue={handleMoveIssue}
/>
))}
</div>
</div>
);
}Key changes:
**useState for issues.** We renamed the prop to initialIssues and initialize local state from it. This gives us optimistic control — we can update the UI immediately and roll back if the server rejects the change.**handleMoveIssue callback.** This is the core of the drag-and-drop logic:moveIssue). If it fails, roll back to the snapshot.issuesByStatus grouping now explicitly sorts by order within each column. This ensures cards render in the correct order after a drag.onMoveIssue to Column. Each column receives the same handler.We need a server action to persist the move to the database. When a card moves, we need to update its status and order, and re-index the other cards in the target column to keep order values contiguous.
Create src/actions/move-issue.ts:
"use server";
import { prisma } from "@/lib/prisma";
import { IssueStatus } from "@prisma/client";
export async function moveIssue(
issueId: string,
newStatus: IssueStatus,
newOrder: number
) {
// Get the issue to find its project
const issue = await prisma.issue.findUnique({
where: { id: issueId },
select: { projectId: true, status: true, order: true },
});
if (!issue) {
throw new Error("Issue not found");
}
const oldStatus = issue.status;
const oldOrder = issue.order;
// If nothing changed, skip
if (oldStatus === newStatus && oldOrder === newOrder) {
return;
}
// Use a transaction to keep order values consistent
await prisma.$transaction(async (tx) => {
// If moving to a different column, close the gap in the old column
if (oldStatus !== newStatus) {
await tx.issue.updateMany({
where: {
projectId: issue.projectId,
status: oldStatus,
order: { gt: oldOrder },
},
data: {
order: { decrement: 1 },
},
});
}
// Make room in the target column at the new position
if (oldStatus !== newStatus) {
// Moving to a different column: shift everything at or after newOrder down
await tx.issue.updateMany({
where: {
projectId: issue.projectId,
status: newStatus,
order: { gte: newOrder },
},
data: {
order: { increment: 1 },
},
});
} else {
// Reordering within the same column
if (newOrder > oldOrder) {
// Moving down: shift items between old and new position up
await tx.issue.updateMany({
where: {
projectId: issue.projectId,
status: newStatus,
order: { gt: oldOrder, lte: newOrder },
id: { not: issueId },
},
data: {
order: { decrement: 1 },
},
});
} else if (newOrder < oldOrder) {
// Moving up: shift items between new and old position down
await tx.issue.updateMany({
where: {
projectId: issue.projectId,
status: newStatus,
order: { gte: newOrder, lt: oldOrder },
id: { not: issueId },
},
data: {
order: { increment: 1 },
},
});
}
}
// Update the moved issue
await tx.issue.update({
where: { id: issueId },
data: {
status: newStatus,
order: newOrder,
},
});
});
}This is the most complex piece of the feature, so let's walk through the logic:
The order problem. Each card has an integer order field. Cards in a column render sorted by this value: 0, 1, 2, 3. When you move card 0 to position 2, you cannot just set its order to 2 — card 2 already exists at that position. You need to shift other cards to make room.
Cross-column moves. When a card moves from In Progress to Done:
Within-column reorder. When a card moves from position 0 to position 2 within the same column:
Transaction. All of this happens in a Prisma transaction so the database stays consistent. If any step fails, everything rolls back.
Why integer ordering? You might wonder why we do not use fractional indexing (like 0.5 between 0 and 1). Fractional indexing avoids the need to re-index other items, but the fractions get increasingly long over time, and you eventually need to re-normalize anyway. For a solo dev's board with at most a few dozen issues per column, integer reindexing is fast and simple.
Visit http://localhost:3000. Try dragging a card from one column to another. You should see:
You cannot yet drop a card between specific cards in a column. That is next.
Right now, dropping on a column always appends the card to the end. To support reordering — dropping a card between two other cards — we need to make each card a drop target too, and use edge detection to figure out whether the user intended to drop above or below.
This is where @atlaskit/pragmatic-drag-and-drop-hitbox comes in. It provides attachClosestEdge and extractClosestEdge, which calculate whether the cursor is closer to the top or bottom edge of the drop target.
Update src/components/board/IssueCard.tsx to add drop target behavior:
"use client";
import { useRef, useEffect, useState } from "react";
import { Card, CardContent } from "@dxsolo/ui";
import type { IssueWithRelations } from "@/types";
import { IssuePriority, IssueStatus } from "@prisma/client";
import { draggable, dropTargetForElements } from "@atlaskit/pragmatic-drag-and-drop/element/adapter";
import { attachClosestEdge, extractClosestEdge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
import type { Edge } from "@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge";
interface IssueCardProps {
issue: IssueWithRelations;
onMoveIssue: (issueId: string, newStatus: IssueStatus, newOrder: number) => void;
}
const priorityConfig: Record<
IssuePriority,
{ label: string; className: string }
> = {
URGENT: { label: "Urgent", className: "bg-red-100 text-red-700" },
HIGH: { label: "High", className: "bg-orange-100 text-orange-700" },
MEDIUM: { label: "Med", className: "bg-yellow-100 text-yellow-700" },
LOW: { label: "Low", className: "bg-gray-100 text-gray-500" },
};
export function IssueCard({ issue, onMoveIssue }: IssueCardProps) {
const ref = useRef<HTMLDivElement>(null);
const [isDragging, setIsDragging] = useState(false);
const [closestEdge, setClosestEdge] = useState<Edge | null>(null);
const priority = priorityConfig[issue.priority];
const completedSubtasks = issue.subtasks.filter((s) => s.completed).length;
const totalSubtasks = issue.subtasks.length;
useEffect(() => {
const el = ref.current;
if (!el) return;
const cleanupDrag = draggable({
element: el,
getInitialData: () => ({
type: "card",
issueId: issue.id,
status: issue.status,
order: issue.order,
}),
onDragStart: () => setIsDragging(true),
onDrop: () => setIsDragging(false),
});
const cleanupDrop = dropTargetForElements({
element: el,
getData: ({ input, element }) => {
return attachClosestEdge(
{ type: "card", issueId: issue.id, status: issue.status, order: issue.order },
{ input, element, allowedEdges: ["top", "bottom"] }
);
},
canDrop: ({ source }) => {
// Don't allow dropping on yourself
return source.data.type === "card" && source.data.issueId !== issue.id;
},
onDragEnter: ({ self }) => {
setClosestEdge(extractClosestEdge(self.data));
},
onDrag: ({ self }) => {
setClosestEdge(extractClosestEdge(self.data));
},
onDragLeave: () => {
setClosestEdge(null);
},
onDrop: ({ self, source }) => {
setClosestEdge(null);
const edge = extractClosestEdge(self.data);
const targetOrder = issue.order;
const sourceId = source.data.issueId as string;
const sourceStatus = source.data.status as IssueStatus;
// Calculate the new order based on which edge was closest
let newOrder: number;
if (edge === "top") {
newOrder = targetOrder;
} else {
newOrder = targetOrder + 1;
}
// If moving within the same column and the source was above the target,
// the target's order will shift after removal, so adjust
if (sourceStatus === issue.status) {
const sourceOrder = source.data.order as number;
if (sourceOrder < targetOrder) {
newOrder = newOrder - 1;
}
}
onMoveIssue(sourceId, issue.status, newOrder);
},
});
return () => {
cleanupDrag();
cleanupDrop();
};
}, [issue.id, issue.status, issue.order, onMoveIssue]);
return (
<div ref={ref} className="relative">
{/* Drop indicator: top edge */}
{closestEdge === "top" && (
<div className="absolute top-0 left-0 right-0 h-0.5 bg-blue-500 -translate-y-1 rounded-full" />
)}
<Card
className={`cursor-grab hover:ring-2 hover:ring-blue-300 transition-shadow ${
isDragging ? "opacity-40" : ""
}`}
>
<CardContent className="p-3">
{/* Title */}
<h3 className="text-sm font-medium leading-snug">{issue.title}</h3>
{/* Meta row: priority + epic label */}
<div className="flex items-center gap-2 mt-2 flex-wrap">
<span
className={`text-xs font-medium px-1.5 py-0.5 rounded ${priority.className}`}
>
{priority.label}
</span>
{/* Epic label: hidden when container is too narrow */}
{issue.epic && (
<span
className="text-xs px-1.5 py-0.5 rounded hidden @[200px]:inline-block"
style={{
backgroundColor: `${issue.epic.color}20`,
color: issue.epic.color,
}}
>
{issue.epic.name}
</span>
)}
</div>
{/* Subtask progress */}
{totalSubtasks > 0 && (
<div className="mt-2 flex items-center gap-2">
<div className="flex-1 h-1 bg-gray-200 rounded-full overflow-hidden">
<div
className="h-full bg-green-500 rounded-full transition-all"
style={{
width: `${(completedSubtasks / totalSubtasks) * 100}%`,
}}
/>
</div>
<span className="text-xs text-gray-400">
{completedSubtasks}/{totalSubtasks}
</span>
</div>
)}
</CardContent>
</Card>
{/* Drop indicator: bottom edge */}
{closestEdge === "bottom" && (
<div className="absolute bottom-0 left-0 right-0 h-0.5 bg-blue-500 translate-y-1 rounded-full" />
)}
</div>
);
}This is a lot of code, but the pieces are straightforward:
Edge detection. When a card is dragged over another card, attachClosestEdge calculates whether the cursor is closer to the top or bottom edge of the target card. We store this in state and render a thin blue line above or below the card to show where the drop will land.
Order calculation. When the user drops:
Within-column adjustment. When moving a card within the same column, there is a subtle off-by-one issue. If the card you are dragging was above the target, removing it from the list shifts the target's effective index down by one. We compensate by subtracting 1 from the new order.
Drop indicator. We render a 2px blue line at the closest edge. It is positioned absolutely relative to the card wrapper div. The translate-y-1 nudges it slightly outside the card so it sits in the gap between cards.
**canDrop filter.** We prevent a card from being dropped on itself. Without this check, picking up and releasing a card in the same spot would trigger an unnecessary move.
onMoveIssue to CardsThe Column component now needs to pass onMoveIssue through to each IssueCard:
{issues.map((issue) => (
- <IssueCard key={issue.id} issue={issue} />
+ <IssueCard key={issue.id} issue={issue} onMoveIssue={onMoveIssue} />
))}Update the card list mapping in Column.tsx with this one-line change.
Visit http://localhost:3000. The full drag-and-drop experience should now work:
Try these edge cases:
Drag and drop has a lot of edge cases. Let's address the most important ones.
If a user drags a card, drops it, and immediately drags it again before the first server action completes, we could end up with inconsistent state. The optimistic update from the first drag has not been confirmed yet, and the second drag is working with potentially incorrect data.
For a solo dev's kanban board, this is unlikely to cause real problems — there is only one user. But let's be defensive. Add a simple lock to the board:
export function Board({ issues: initialIssues }: BoardProps) {
const [issues, setIssues] = useState(initialIssues);
+ const [isMoving, setIsMoving] = useState(false);
const handleMoveIssue = useCallback(
async (issueId: string, newStatus: IssueStatus, newOrder: number) => {
+ if (isMoving) return;
+ setIsMoving(true);
+
// Snapshot for rollback
const previousIssues = issues;
// Optimistic update
setIssues((prev) => {
// ... same logic as before ...
});
- try {
+ try {
await moveIssue(issueId, newStatus, newOrder);
} catch (error) {
console.error("Failed to move issue:", error);
setIssues(previousIssues);
+ } finally {
+ setIsMoving(false);
}
},
- [issues]
+ [issues, isMoving]
);The try...finally ensures setIsMoving(false) always runs — even if the server action throws. Without finally, a failed move would permanently lock the board.
Our board uses useState(initialIssues) to initialize. If the page is reloaded or the server re-renders, we get fresh data. But what if the server data changes while the board is open (e.g., another browser tab)? For a solo dev app, this is a non-issue. But if you want to be thorough, you can sync by adding a key to the Board component:
// In page.tsx, force re-mount when data changes:
<Board key={JSON.stringify(project.issues.map(i => i.id + i.status + i.order))} issues={project.issues} />This forces React to unmount and remount the Board whenever the issue data changes, resetting the local state. It is a blunt instrument but effective for server-rendered pages.
Here is every file we created or modified in this part:
| File | Purpose |
|---|---|
src/components/board/Board.tsx | State management, optimistic updates, move handler |
src/components/board/Column.tsx | Drop target for columns, visual feedback |
src/components/board/IssueCard.tsx | Draggable + drop target with edge detection |
src/actions/move-issue.ts | Server action for persisting moves with order reindexing |
Here is what we accomplished:
Key concepts we covered:
If something is not working, check these common issues:
ref is on the wrapper <div>, not on the <Card> component. Pragmatic DnD needs a real DOM element.canDrop returns true for your drag source. The type: "card" must match between getInitialData and canDrop.src/actions/move-issue.ts has "use server" at the top and that the prisma import resolves correctly.In Part 4, we build the issue detail view. Clicking a card will open a panel or modal showing the full issue: title, markdown description with a live editor, subtask list with checkboxes, and metadata editing. We will evaluate markdown editors, handle real-time saving, and add a few components to @dxsolo/ui along the way.
See you there.
Source code for this part: github.com/jpDxsoloOrg/solo-kanban/tree/part-3
Live demo: Coming in Part 6
@dxsolo/ui Storybook: jpdxsoloorg.github.io/jpComponent