How would you design a drag-and-drop Kanban board in React?

Advanced20 min interview
Skills tested:
Drag and Drop ArchitectureNormalized State ManagementOptimistic UI UpdatesAccessibilityPersistence StrategiesConflict Resolution and Rollback Logic

Advertisement

🧩 Scenario

Build a Kanban board with columns (To Do, In Progress, Done) where: - Cards can be dragged between columns and reordered inside a column - State persists to localStorage (simulate backend persistence) - Optimistic UI updates with rollback on failure (simulate error) - Keyboard accessibility for moving cards

🧠 Architecture Walkthrough

Normalized State: The Foundation of Efficient Updates

The board state in the demo separates columns and cards into two flat maps, connected by cardIds arrays. This normalized shape is the single most important structural decision in the implementation. If cards were stored nested inside their column objects, moving a card between columns would require cloning the entire source column, the entire target column, and every card in both.

With normalization, a move operation only needs to filter one cardIds array and append to another the card object itself does not move at all, only the reference to it does. When the board has hundreds of cards across many columns, this distinction becomes significant both for update performance and for the predictability of rollback logic.

The pattern mirrors how Redux Toolkit's createEntityAdapter and databases with foreign keys store relational data, and interviewers who have designed server-side data models recognize it immediately.

Optimistic Updates With Deep-Clone Rollback

Every mutation in the demo move, edit, delete, add follows the same three-step sequence: snapshot the current state with a deep clone, apply the change immediately to the UI, then fire the async request and either confirm or restore the snapshot on failure.

The JSON.parse(JSON.stringify(boardState)) deep clone is intentional rather than lazy: because React state is immutable by convention, a shallow copy of boardState would still share nested object references. If the rollback assigned a shallow copy back, the columns and cards objects would still point to the mutated arrays from the optimistic update, and the UI would not visually revert.

The deep clone guarantees that previousState is a fully independent snapshot. In production you would replace this with a more efficient immutable update library like Immer, but the conceptual contract capture before mutating, restore on failure remains identical.

Preventing dragOver Performance Collapse

The HTML5 drag-and-drop API fires the dragover event on every single pixel of mouse movement while a draggable element hovers over a drop target. Without intervention this fires dozens of times per second and can make the board feel sluggish.

The demo's handleDragOver does one thing: call e.preventDefault(). This is not just for performance it is actually required by the browser to signal that the element accepts drops. Without preventDefault() in dragover, the drop event will never fire.

The demo also sets e.dataTransfer.dropEffect = 'move' to show the correct cursor icon. Notice that handleDragOver is wrapped in useCallback with an empty dependency array, which means the same function reference is reused across renders, preventing unnecessary re-registration of the event listener on every render cycle.

💡 Key Code Explained

const handleDrop = useCallback(
  async (e, targetColumnId) => {
    e.preventDefault();

    let dragData = draggedItem;
    if (!dragData) {
      try {
        dragData = JSON.parse(e.dataTransfer.getData('text/plain'));
      } catch (err) {
        return;
      }
    }

    const { cardId, sourceColumnId } = dragData;
    if (sourceColumnId === targetColumnId) {
      setDraggedItem(null);
      setDraggedOverColumn(null);
      return;
    }

    const previousState = JSON.parse(JSON.stringify(boardState));
    const newSourceCardIds = sourceColumn.cardIds.filter((id) => id !== cardId);
    const newTargetCardIds = [...targetColumn.cardIds, cardId];

    const newState = {
      ...boardState,
      columns: {
        ...boardState.columns,
        [sourceColumnId]: { ...sourceColumn, cardIds: newSourceCardIds },
        [targetColumnId]: { ...targetColumn, cardIds: newTargetCardIds },
      },
    };

    setBoardState(newState);
    setOptimisticChanges((prev) => new Set([...prev, cardId]));

    try {
      await simulateApiCall(800, 0.85);
      setOptimisticChanges((prev) => {
        const next = new Set(prev);
        next.delete(cardId);
        return next;
      });
    } catch (error) {
      setBoardState(previousState);
      setOptimisticChanges((prev) => {
        const next = new Set(prev);
        next.delete(cardId);
        return next;
      });
    }
  },
  [draggedItem, boardState],
);

The dual source of drag data first checking draggedItem state and falling back to e.dataTransfer.getData is a defensive pattern for cross-browser reliability. Some browsers clear dataTransfer before the drop handler fires asynchronously, so storing the drag context in React state acts as a backup.

The early return when sourceColumnId === targetColumnId prevents a wasted API call and an unnecessary state update. Critically, the optimistic state is constructed with spread operators that create new object references at each level ...boardState, ...boardState.columns, ...sourceColumn because React's reconciler relies on reference inequality to detect changes. Mutating the existing arrays in place would leave React unable to detect that anything changed.

const handleDragStart = useCallback(
  (e, cardId, sourceColumnId) => {
    setDraggedItem({ cardId, sourceColumnId });
    e.dataTransfer.effectAllowed = 'move';
    e.dataTransfer.setData(
      'text/plain',
      JSON.stringify({ cardId, sourceColumnId }),
    );
    addLog(`Started dragging "${boardState.cards[cardId]?.title}"`);
  },
  [boardState.cards],
);

Setting dataTransfer.setData with serialized JSON is required for two reasons: it enables drops onto elements outside the React tree (such as a browser-native drop target), and it provides the cross-browser fallback used in handleDrop.

The effectAllowed = 'move' property tells the browser not to show a "copy" cursor, which would mislead the user into thinking the original card stays in place. useCallback with [boardState.cards] as the dependency ensures the log message reflects the latest card titles without recreating the function on every render a tradeoff between closure freshness and reference stability.

⚖️ Tradeoffs

ApproachProCon
Native HTML5 drag-and-dropZero dependencies, works in all modern browsersNo mobile touch support, limited animation control, browser quirks with ghost images
dnd-kitTouch support, accessible, composable sensors, smooth animationsAdditional bundle size (~15kb), learning curve for sensor configuration
react-beautiful-dndPolished animations, widely understood APIMaintenance mode, not well-supported in React 18 strict mode

🎯 What Interviewers Actually Check

  • Explains why the rollback uses a deep clone rather than a shallow copy — and what breaks if you use { ...boardState } instead
  • Knows that e.preventDefault() in dragover is required to enable drops, not just a performance optimization
  • Mentions that useCallback on drag handlers prevents re-registration on every render without being prompted
  • Recognizes that the optimisticChanges Set tracks pending mutations per card-ID, not a single boolean flag, so concurrent moves can be tracked independently
  • Can describe what happens to in-flight optimistic updates if the user navigates away before the API resolves

❓ Follow-Up Questions

  1. Two users move the same card to different columns simultaneously. The server receives both requests and the second one wins. How does your client handle the conflict when the first user's response comes back?
  2. The demo's rollback uses JSON.parse(JSON.stringify(boardState)). What types of values does this fail to serialize correctly, and what would you use instead?
  3. How would you write a test that verifies the rollback behavior — specifically that the board state returns to previousState after a failed simulateApiCall?
  4. The board currently has 4 columns with 8 cards total. At 50 columns and 500 cards, what rendering bottlenecks appear and how do you address them?
  5. Your manager wants undo/redo for card moves. Given the current optimistic-update pattern, where does undo history live and how does it interact with in-flight API calls?

🎮 Live Demo

📝 Summary

The two non-obvious decisions that define a production-quality Kanban board are normalized state and deep-clone rollback. Normalized state separating cards from columns and connecting them with cardIds references means a move operation touches only two arrays rather than cloning entire subtrees.

The deep-clone snapshot before every mutation guarantees that a failed API call can fully restore the prior board state even if subsequent mutations have already started.

Native HTML5 drag-and-drop works well for desktop but requires e.preventDefault() in dragover to enable drop registration, a detail that trips up many candidates. In production these patterns remain valid regardless of whether you use a library like dnd-kit the library handles gesture detection and animation, but you still own the state shape and the optimistic update contract.

Frequently Asked Questions

Should I use a library or native drag-and-drop?

For complex apps use a battle-tested library (dnd-kit, react-beautiful-dnd). Native DnD is fine for simple cases but has quirks on mobile.

How to handle optimistic updates?

Apply the UI change immediately, send the request in background, and rollback on failure.

Advertisement


Stay Updated

Get the latest frontend challenges, interview questions and tutorials delivered to your inbox.

Advertisement