Skip to main content

React Drag-and-Drop: Optimize Performance & Speed

A kanban board with hundreds of cards can become sluggish during drag operations if your component tree re-renders excessively. Smooth, responsive dragging requires strategic use of React.memo, virtualization, and deferred state updates. Production kanban apps (Trello handles 10+ million cards daily) combine memoization, lazy evaluation, and layout recalculation throttling to maintain 60 fps during drag.

Identifying Performance Bottlenecks

Use React DevTools Profiler to measure re-render times. During a drag operation, profile which components re-render and why:

// Wrap your Board in Profiler to track rendering time
import { Profiler } from 'react';

function onRenderCallback(id, phase, actualDuration) {
console.log(`${id} (${phase}) took ${actualDuration}ms`);
}

export default function App() {
return (
<Profiler id="Board" onRender={onRenderCallback}>
<Board />
</Profiler>
);
}

Run a drag operation and observe the console. If dragging a single card causes the entire board to re-render (50+ components), you have a re-render bottleneck. Common culprits: passing a new object/function as a prop on every render, storing a large state object that all components depend on, or not memoizing child components.

Memoizing Card and Column Components

React.memo prevents a component from re-rendering if its props haven't changed. For a card component:

const Card = React.memo(({ task, columnId, onDragStart }) => {
return (
<div
draggable
onDragStart={() => onDragStart(task.id, columnId)}
className="card"
>
<h3>{task.title}</h3>
<p>{task.description}</p>
</div>
);
}, (prevProps, nextProps) => {
// Return true if props are equal (skip re-render)
return (
prevProps.task.id === nextProps.task.id &&
prevProps.task.title === nextProps.task.title &&
prevProps.task.description === nextProps.task.description &&
prevProps.columnId === nextProps.columnId
);
});

By default, React.memo does a shallow equality check. For complex objects, pass a custom comparison function (second argument) that returns true if props are equal, false if they differ (triggering a re-render).

The catch: onDragStart is a function passed from the parent. If the parent creates onDragStart inline, it's a new function on every render, causing Card to re-render even if the task data is unchanged. Fix this by wrapping the handler in useCallback:

function Column({ columnId, tasks, onTaskDropped }) {
const handleDragStart = React.useCallback(
(taskId, srcColumn) => {
console.log(`Dragging ${taskId} from ${srcColumn}`);
// ... drag logic
},
[columnId] // Re-create only if columnId changes
);

return (
<div className="column">
{tasks.map((task) => (
<Card
key={task.id}
task={task}
columnId={columnId}
onDragStart={handleDragStart}
/>
))}
</div>
);
}

useCallback memoizes the function. It's re-created only if the dependency array changes. Now, Card receives the same function reference across renders, so React.memo skips unnecessary re-renders.

Virtualizing Long Card Lists

If a single column contains 1000+ cards, rendering all of them is wasteful. Virtual scrolling renders only the visible subset. Use a library like react-window:

npm install react-window
import { FixedSizeList as List } from 'react-window';

function Column({ columnId, tasks, onTaskDropped }) {
const itemSize = 120; // height of each card

const Row = ({ index, style }) => (
<div style={style}>
<Card
task={tasks[index]}
columnId={columnId}
onDragStart={() => {}}
/>
</div>
);

return (
<div className="column">
<h2>Column {columnId}</h2>
<List
height={600}
itemCount={tasks.length}
itemSize={itemSize}
width="100%"
>
{Row}
</List>
</div>
);
}

react-window renders a scrollable viewport of fixed-height items. If your list is 1000 cards tall but the viewport shows 5 cards, only those 5 (plus 2-3 buffer cards above/below) are in the DOM. Scrolling updates the rendered range. This reduces DOM nodes from 1000 to ~10, massively improving drag responsiveness.

Trade-off: virtualization makes certain operations harder (e.g., finding the exact drop position when reordering within the column). For simpler kanban boards (<100 cards per column), virtualization may be overkill.

Debouncing and Batching Updates

During a drag, you might receive dozens of "over" events as the cursor moves. Updating state on every event is expensive. Debounce or throttle:

import { useDrop } from 'react-dnd';

function Column({ columnId, tasks }) {
const [highlightedIndex, setHighlightedIndex] = React.useState(-1);
const timeoutRef = React.useRef(null);

const [{ isOver }, drop] = useDrop(
() => ({
accept: 'CARD',
hover: (item) => {
// Clear the previous timeout
if (timeoutRef.current) clearTimeout(timeoutRef.current);

// Defer the highlight update
timeoutRef.current = setTimeout(() => {
setHighlightedIndex(item.currentIndex ?? -1);
}, 50); // Wait 50ms before updating highlight
},
drop: (item) => {
if (timeoutRef.current) clearTimeout(timeoutRef.current);
// ... handle drop
},
}),
[tasks]
);

return (
<div ref={drop} className="column">
{tasks.map((task, index) => (
<div
key={task.id}
className={`card ${index === highlightedIndex ? 'highlight' : ''}`}
>
{task.title}
</div>
))}
</div>
);
}

This example debounces the highlight update, reducing state changes from 60+ per second to ~2-3, while still feeling responsive to the user.

Using Immer for Efficient Immutable Updates

Immutable updates can become verbose. Immer simplifies them while maintaining immutability guarantees:

npm install immer
import { produce } from 'immer';

function Board() {
const [tasks, setTasks] = React.useState(initialTasks);

function moveTask(taskId, fromColumn, toColumn) {
setTasks(
produce((draft) => {
// Mutate the draft as if it's mutable; Immer handles immutability
const task = draft[fromColumn].find((t) => t.id === taskId);
const index = draft[fromColumn].indexOf(task);
draft[fromColumn].splice(index, 1);
draft[toColumn].push(task);
})
);
}

return <div className="board">{/* ... */}</div>;
}

Immer's produce function lets you mutate a draft object directly. It then computes the minimal change set and returns a new, immutable object. This avoids nested spread operations and is often faster than manual immutable updates for complex state trees.

Lazy State Updates with useDeferredValue

React 18's useDeferredValue defers expensive re-renders until the UI handles urgent updates. For a drag operation, the drag visual feedback is urgent; the task list update is lower-priority:

function Board() {
const [tasks, setTasks] = React.useState(initialTasks);
const [draggedTaskId, setDraggedTaskId] = React.useState(null);
const deferredTasks = React.useDeferredValue(tasks);

return (
<div className="board">
{/* Dragged visual feedback uses draggedTaskId (urgent) */}
<div className="drag-indicator">
{draggedTaskId && <p>Moving task {draggedTaskId}</p>}
</div>

{/* Task list uses deferred tasks (lower-priority) */}
{deferredTasks.todo.map((task) => (
<Card key={task.id} task={task} isDragging={task.id === draggedTaskId} />
))}
</div>
);
}

When state changes, React prioritizes rendering the drag indicator (which uses the current draggedTaskId) over re-rendering the task list (which uses the deferred deferredTasks). This keeps the drag cursor responsive while batching task list updates.

Comparison: Optimization Techniques and Impact

TechniqueImplementation EffortRe-render ReductionBest For
React.memo + useCallbackLow40-60%All small-to-medium boards
Virtualization (react-window)Medium95% (only visible cards)Large lists (500+ cards per column)
Debounced updatesMedium80-90%Frequent hover/highlight changes
Immer for state updatesLowNo direct reduction, cleaner codeComplex nested state
useDeferredValue (React 18+)LowPerceived 60 fps (deferred re-renders)Drag responsiveness

Key Takeaways

  • Memoize aggressively: Use React.memo, useCallback, and useMemo to prevent unnecessary re-renders.
  • Virtualize long lists: For 500+ cards in a column, use react-window to render only visible items.
  • Debounce events: Defer non-critical updates like visual highlights to reduce state changes during drag.
  • Use Immer: Simplify immutable updates and improve code readability.
  • Profile early: Use React DevTools Profiler to identify bottlenecks before optimizing.

Frequently Asked Questions

How much does React.memo actually help?

For a 100-card board, memoizing cards reduces re-renders by ~40-60% depending on how you handle callbacks. For a 1000-card board, the savings are minimal without virtualization (you're still rendering all cards). Combine memo with virtualization for the best results.

Is virtualization worth the complexity?

Yes, if a single column has 500+ cards. Without virtualization, rendering 500 cards takes 200-300ms. With virtualization, it drops to 20-30ms. For typical kanban boards (50-200 cards per column), it's optional.

What if I use Redux; do these optimizations still apply?

Absolutely. Redux with granular selectors (e.g., useSelector(state => state.kanban.todo)) already optimizes subscriptions. Layer React.memo, useCallback, and virtualization on top for further gains.

How do I know if my optimizations are working?

Use the React DevTools Profiler (Shift+Ctrl+K in Chrome, then Profiler tab). Record a drag operation, then inspect the flame graph. Bars that shrink after optimization indicate success.

Does useDeferredValue have performance overhead?

Minimal. It defers re-renders but doesn't skip them. The trade-off is that the task list may lag slightly behind the drag cursor. For most users, this is imperceptible and well worth the responsive drag experience.

Further Reading