DEV Community

Mahendranath Reddy
Mahendranath Reddy

Posted on

Drag & Drop Kanban Board — JIRA/Trello Clone - Frontend Interview

Drag & Drop Kanban Board — JIRA/Trello Clone


Problem Statement

Build a JIRA/Trello-style Kanban board where:

  • Cards can be created in any column
  • Cards can be deleted
  • Cards can be dragged and dropped between different lists/columns
  • State persists across page reloads (localStorage)
  • Each column represents a workflow stage (To Do → In Progress → Done)

Why This Question Is Asked

Skill What the interviewer evaluates
Drag & Drop API Do you know native HTML5 DnD events?
State management Can you manage complex nested state?
Immutable updates Do you avoid mutating arrays/objects?
Data model design How do you structure columns + cards?
localStorage Can you persist and rehydrate state?
Performance Do you prevent unnecessary re-renders?

System Design Overview

┌─────────────────────────────────────────────────────────────┐
│                    KanbanBoard                               │
│                                                              │
│  State: { columns: Map<id, Column>, columnOrder: string[] } │
│                                                              │
│  ┌─────────────┐  ┌──────────────┐  ┌──────────────┐       │
│  │  To Do      │  │ In Progress  │  │    Done      │       │
│  │  ─────────  │  │  ─────────   │  │  ─────────   │       │
│  │  [Card A]   │  │  [Card C]    │  │  [Card E]    │       │
│  │  [Card B]   │  │  [Card D]    │  │              │       │
│  │  + Add card │  │  + Add card  │  │  + Add card  │       │
│  └─────────────┘  └──────────────┘  └──────────────┘       │
│        ↑                  ↑                  ↑               │
│   dragover/drop      dragover/drop      dragover/drop       │
│                                                              │
│  Events: onDragStart(card) → onDragOver(col) → onDrop(col) │
└─────────────────────────────────────────────────────────────┘
Enter fullscreen mode Exit fullscreen mode

Design Patterns Used

Pattern Where Why
Command Each card action (add/delete/move) Undo/redo ready
Observer useEffect → localStorage sync Persist state changes
Mediator Board-level drag state Columns don't talk to each other directly
Immutable Update map, filter, spread on state React state correctness
Strategy Different drop targets (column vs between cards) Swap DnD algorithm
Memento localStorage snapshot State restoration on reload

Data Structures Used

1. Map-based normalized state (like Redux / react-beautiful-dnd):
   {
     columns: {
       'col-1': {
         id:       'col-1',
         title:    'To Do',
         cardIds:  ['card-1', 'card-2'],
         color:    '#4285f4'
       },
       'col-2': { ... },
       'col-3': { ... }
     },
     cards: {
       'card-1': { id: 'card-1', title: 'Fix bug', description: '', priority: 'high' },
       'card-2': { ... }
     },
     columnOrder: ['col-1', 'col-2', 'col-3']
   }

   Why normalized?
    O(1) card lookup by ID
    Move card = remove from source.cardIds + add to dest.cardIds
    No deep nested updates

2. String refs for drag state (useRef  no re-render):
   draggingCardId: string | null
   sourceColumnId: string | null
   dragOverColumnId: string | null

3. Array (columnOrder):
    Preserves column display order
    Allows column reordering in future

4. Set (for fast card existence checks in complex scenarios)
Enter fullscreen mode Exit fullscreen mode

Step-by-Step Process

Step 1: Define data model
  → Card: { id, title, description, priority, createdAt }
  → Column: { id, title, cardIds[], color }
  → Board: { cards: {}, columns: {}, columnOrder: [] }

Step 2: Initialize state (from localStorage or defaults)
  → useEffect on mount: try JSON.parse(localStorage.get(...))
  → Fallback to INITIAL_STATE if not found

Step 3: Persist state on every change
  → useEffect([state]) → localStorage.setItem(...)

Step 4: Add card to column
  → Create new card with unique id
  → Add card to cards{} map
  → Push card.id to column.cardIds[]

Step 5: Delete card from column
  → Remove card from cards{} map
  → Filter card.id from column.cardIds[]

Step 6: Drag and Drop (HTML5 DnD API)
  → onDragStart (card): store draggingCardId + sourceColumnId
  → onDragOver (column): e.preventDefault() + store dragOverColumnId
  → onDrop (column): move card from source to dest column

Step 7: Move card between columns
  → Remove cardId from source.cardIds
  → Push cardId to dest.cardIds
  → Immutable: create new column objects
Enter fullscreen mode Exit fullscreen mode

HTML5 Drag and Drop API

Key Events:
  onDragStart(e)  → fires when user starts dragging an element
  onDragOver(e)   → fires continuously while dragging over a target
                    MUST call e.preventDefault() to allow drop!
  onDrop(e)       → fires when dragged element is released on target
  onDragEnd(e)    → fires when drag operation ends (cleanup)
  onDragEnter(e)  → fires when dragging enters a new target
  onDragLeave(e)  → fires when dragging leaves a target

Data transfer:
  e.dataTransfer.setData('cardId', card.id)
  e.dataTransfer.getData('cardId')

Drag image:
  e.dataTransfer.setDragImage(element, offsetX, offsetY)
Enter fullscreen mode Exit fullscreen mode

Core Implementation — Full Kanban Board

// KanbanBoard.tsx
import React, {
  useState, useEffect, useRef, useCallback, useMemo
} from 'react';

// ─── Types ────────────────────────────────────────────────────────────────────
type Priority = 'low' | 'medium' | 'high' | 'urgent';

interface Card {
  id:          string;
  title:       string;
  description: string;
  priority:    Priority;
  createdAt:   string;
}

interface Column {
  id:      string;
  title:   string;
  cardIds: string[];
  color:   string;
}

interface BoardState {
  cards:       Record<string, Card>;
  columns:     Record<string, Column>;
  columnOrder: string[];
}

// ─── Initial State ────────────────────────────────────────────────────────────
const INITIAL_STATE: BoardState = {
  cards: {
    'card-1': { id: 'card-1', title: 'Design new landing page', description: 'Figma mockups needed', priority: 'high', createdAt: new Date().toISOString() },
    'card-2': { id: 'card-2', title: 'Fix login bug', description: '', priority: 'urgent', createdAt: new Date().toISOString() },
    'card-3': { id: 'card-3', title: 'Write unit tests', description: 'Coverage > 80%', priority: 'medium', createdAt: new Date().toISOString() },
    'card-4': { id: 'card-4', title: 'Update README', description: '', priority: 'low', createdAt: new Date().toISOString() },
  },
  columns: {
    'col-1': { id: 'col-1', title: '📋 To Do',       cardIds: ['card-1', 'card-2'], color: '#4285f4' },
    'col-2': { id: 'col-2', title: '⚙️ In Progress', cardIds: ['card-3'],           color: '#ff9800' },
    'col-3': { id: 'col-3', title: '✅ Done',         cardIds: ['card-4'],           color: '#4CAF50' },
  },
  columnOrder: ['col-1', 'col-2', 'col-3'],
};

const STORAGE_KEY = 'kanban-board-state';

// ─── Helpers ──────────────────────────────────────────────────────────────────
function genId() { return `id-${Date.now()}-${Math.random().toString(36).slice(2)}`; }

const PRIORITY_CONFIG: Record<Priority, { color: string; label: string }> = {
  urgent: { color: '#ef5350', label: '🔴 Urgent' },
  high:   { color: '#ff9800', label: '🟠 High'   },
  medium: { color: '#fdd835', label: '🟡 Medium' },
  low:    { color: '#66bb6a', label: '🟢 Low'    },
};

// ─── KanbanCard Component ──────────────────────────────────────────────────────
function KanbanCard({
  card,
  onDelete,
  onDragStart,
  isDragging,
}: {
  card:        Card;
  onDelete:    (id: string) => void;
  onDragStart: (e: React.DragEvent, cardId: string) => void;
  isDragging:  boolean;
}) {
  const [isHovered, setIsHovered] = useState(false);
  const p = PRIORITY_CONFIG[card.priority];

  return (
    <div
      draggable
      onDragStart={e => onDragStart(e, card.id)}
      onMouseEnter={() => setIsHovered(true)}
      onMouseLeave={() => setIsHovered(false)}
      style={{
        background:   isDragging ? '#e3f2fd' : 'white',
        borderRadius: 8,
        padding:      '12px 14px',
        marginBottom: 8,
        border:       `1px solid ${isDragging ? '#90caf9' : '#e0e0e0'}`,
        cursor:       'grab',
        opacity:      isDragging ? 0.5 : 1,
        boxShadow:    isHovered && !isDragging ? '0 4px 12px rgba(0,0,0,.12)' : '0 1px 3px rgba(0,0,0,.06)',
        transform:    isHovered && !isDragging ? 'translateY(-1px)' : 'none',
        transition:   'all .15s ease',
        userSelect:   'none',
      }}
    >
      {/* Priority badge */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'flex-start', marginBottom: 6 }}>
        <span style={{
          fontSize: 10, fontWeight: 700, color: p.color,
          background: `${p.color}18`, padding: '2px 6px', borderRadius: 4,
        }}>
          {p.label}
        </span>

        {/* Delete (shows on hover) */}
        {isHovered && (
          <button
            onClick={e => { e.stopPropagation(); onDelete(card.id); }}
            style={{
              background: 'none', border: 'none', cursor: 'pointer',
              color: '#bbb', fontSize: 14, padding: 0, lineHeight: 1,
            }}
            title="Delete card"
          ></button>
        )}
      </div>

      {/* Title */}
      <p style={{ margin: '0 0 4px', fontSize: 14, fontWeight: 600, color: '#1a1a1a', lineHeight: 1.4 }}>
        {card.title}
      </p>

      {/* Description */}
      {card.description && (
        <p style={{ margin: '0 0 6px', fontSize: 12, color: '#888', lineHeight: 1.4 }}>
          {card.description}
        </p>
      )}

      {/* Footer */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
        <span style={{ fontSize: 10, color: '#ccc' }}>
          {new Date(card.createdAt).toLocaleDateString()}
        </span>
        <span style={{ fontSize: 12, color: '#ccc' }}></span>
      </div>
    </div>
  );
}

// ─── Add Card Form ─────────────────────────────────────────────────────────────
function AddCardForm({
  columnId,
  onAdd,
  onCancel,
}: {
  columnId: string;
  onAdd:    (columnId: string, title: string, description: string, priority: Priority) => void;
  onCancel: () => void;
}) {
  const [title,       setTitle]       = useState('');
  const [description, setDescription] = useState('');
  const [priority,    setPriority]    = useState<Priority>('medium');

  const handleSubmit = (e: React.FormEvent) => {
    e.preventDefault();
    if (!title.trim()) return;
    onAdd(columnId, title, description, priority);
    onCancel();
  };

  return (
    <form onSubmit={handleSubmit} style={{
      background: '#f8f9ff', border: '1px solid #c5d5ff',
      borderRadius: 8, padding: 12, marginBottom: 8,
    }}>
      <input
        autoFocus
        value={title}
        onChange={e => setTitle(e.target.value)}
        placeholder="Card title..."
        style={{ width: '100%', padding: '8px 10px', border: '1px solid #ddd', borderRadius: 6,
          fontSize: 13, marginBottom: 8, boxSizing: 'border-box', outline: 'none' }}
      />
      <textarea
        value={description}
        onChange={e => setDescription(e.target.value)}
        placeholder="Description (optional)"
        rows={2}
        style={{ width: '100%', padding: '8px 10px', border: '1px solid #ddd', borderRadius: 6,
          fontSize: 12, marginBottom: 8, boxSizing: 'border-box', resize: 'none', outline: 'none' }}
      />
      <select
        value={priority}
        onChange={e => setPriority(e.target.value as Priority)}
        style={{ width: '100%', padding: '6px 8px', border: '1px solid #ddd', borderRadius: 6,
          fontSize: 12, marginBottom: 10, outline: 'none' }}
      >
        <option value="low">🟢 Low</option>
        <option value="medium">🟡 Medium</option>
        <option value="high">🟠 High</option>
        <option value="urgent">🔴 Urgent</option>
      </select>

      <div style={{ display: 'flex', gap: 6 }}>
        <button type="submit" style={{ flex: 1, padding: '7px', background: '#4285f4',
          color: 'white', border: 'none', borderRadius: 6, cursor: 'pointer', fontSize: 13, fontWeight: 600 }}>
          Add Card
        </button>
        <button type="button" onClick={onCancel} style={{ padding: '7px 12px', background: 'none',
          border: '1px solid #ddd', borderRadius: 6, cursor: 'pointer', fontSize: 13 }}>
          Cancel
        </button>
      </div>
    </form>
  );
}

// ─── Kanban Column ─────────────────────────────────────────────────────────────
function KanbanColumn({
  column,
  cards,
  onAddCard,
  onDeleteCard,
  onDragStart,
  onDragOver,
  onDrop,
  onDragLeave,
  isDragOver,
  draggingCardId,
}: {
  column:         Column;
  cards:          Card[];
  onAddCard:      (colId: string, title: string, desc: string, priority: Priority) => void;
  onDeleteCard:   (cardId: string) => void;
  onDragStart:    (e: React.DragEvent, cardId: string) => void;
  onDragOver:     (e: React.DragEvent, colId: string) => void;
  onDrop:         (e: React.DragEvent, colId: string) => void;
  onDragLeave:    (e: React.DragEvent) => void;
  isDragOver:     boolean;
  draggingCardId: string | null;
}) {
  const [showForm, setShowForm] = useState(false);

  return (
    <div
      onDragOver={e => onDragOver(e, column.id)}
      onDrop={e => onDrop(e, column.id)}
      onDragLeave={onDragLeave}
      style={{
        flex:         '0 0 300px',
        width:        300,
        background:   isDragOver ? `${column.color}12` : '#f4f5f7',
        borderRadius: 12,
        border:       `2px dashed ${isDragOver ? column.color : 'transparent'}`,
        transition:   'border-color .15s, background .15s',
        display:      'flex',
        flexDirection: 'column',
        maxHeight:    'calc(100vh - 160px)',
      }}
    >
      {/* Column Header */}
      <div style={{
        padding:      '14px 16px 10px',
        borderBottom: '1px solid #e8eaed',
        flexShrink:   0,
      }}>
        <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
          <div style={{ display: 'flex', alignItems: 'center', gap: 8 }}>
            <div style={{ width: 12, height: 12, borderRadius: '50%', background: column.color }} />
            <h3 style={{ margin: 0, fontSize: 14, fontWeight: 700, color: '#1a1a1a' }}>
              {column.title}
            </h3>
          </div>
          <span style={{
            fontSize: 12, fontWeight: 700,
            background: '#e0e0e0', color: '#666',
            padding: '2px 8px', borderRadius: 12,
          }}>
            {cards.length}
          </span>
        </div>
      </div>

      {/* Cards */}
      <div style={{ padding: '10px 10px 4px', overflowY: 'auto', flex: 1 }}>
        {cards.map(card => (
          <KanbanCard
            key={card.id}
            card={card}
            onDelete={onDeleteCard}
            onDragStart={onDragStart}
            isDragging={draggingCardId === card.id}
          />
        ))}

        {/* Drop zone indicator */}
        {isDragOver && (
          <div style={{
            height: 60, borderRadius: 8, border: `2px dashed ${column.color}`,
            background: `${column.color}08`, display: 'flex', alignItems: 'center',
            justifyContent: 'center', color: column.color, fontSize: 12, fontWeight: 600,
            marginBottom: 8,
          }}>
            Drop here →
          </div>
        )}

        {/* Add card form / button */}
        {showForm ? (
          <AddCardForm
            columnId={column.id}
            onAdd={onAddCard}
            onCancel={() => setShowForm(false)}
          />
        ) : (
          <button
            onClick={() => setShowForm(true)}
            style={{
              width:        '100%', padding: '8px', background: 'transparent',
              border:       '1px dashed #ddd', borderRadius: 8, cursor: 'pointer',
              color:        '#aaa', fontSize: 13, marginBottom: 8,
              transition:   'all .15s',
            }}
            onMouseEnter={e => { e.currentTarget.style.background = column.color + '18'; e.currentTarget.style.color = column.color; e.currentTarget.style.borderColor = column.color; }}
            onMouseLeave={e => { e.currentTarget.style.background = 'transparent'; e.currentTarget.style.color = '#aaa'; e.currentTarget.style.borderColor = '#ddd'; }}
          >
            + Add a card
          </button>
        )}
      </div>
    </div>
  );
}

// ─── Main Kanban Board ─────────────────────────────────────────────────────────
export default function KanbanBoard() {
  // Load from localStorage or use initial state
  const [board, setBoard] = useState<BoardState>(() => {
    try {
      const saved = localStorage.getItem(STORAGE_KEY);
      return saved ? JSON.parse(saved) : INITIAL_STATE;
    } catch {
      return INITIAL_STATE;
    }
  });

  // Drag state — useRef (not useState) to avoid re-renders during drag
  const draggingCardId    = useRef<string | null>(null);
  const sourceColumnId    = useRef<string | null>(null);
  const [dragOverColId, setDragOverColId] = useState<string | null>(null);

  // Persist to localStorage on every state change
  useEffect(() => {
    try {
      localStorage.setItem(STORAGE_KEY, JSON.stringify(board));
    } catch (err) {
      console.warn('localStorage write failed:', err);
    }
  }, [board]);

  // ─── Add Card ──────────────────────────────────────────────────────────────
  const handleAddCard = useCallback((
    columnId: string, title: string, description: string, priority: Priority
  ) => {
    const newCard: Card = {
      id:          genId(),
      title:       title.trim(),
      description: description.trim(),
      priority,
      createdAt:   new Date().toISOString(),
    };

    setBoard(prev => ({
      ...prev,
      cards: { ...prev.cards, [newCard.id]: newCard },
      columns: {
        ...prev.columns,
        [columnId]: {
          ...prev.columns[columnId],
          cardIds: [...prev.columns[columnId].cardIds, newCard.id],
        },
      },
    }));
  }, []);

  // ─── Delete Card ───────────────────────────────────────────────────────────
  const handleDeleteCard = useCallback((cardId: string) => {
    setBoard(prev => {
      const { [cardId]: _, ...remainingCards } = prev.cards;

      const updatedColumns = Object.fromEntries(
        Object.entries(prev.columns).map(([colId, col]) => [
          colId,
          { ...col, cardIds: col.cardIds.filter(id => id !== cardId) },
        ])
      );

      return { ...prev, cards: remainingCards, columns: updatedColumns };
    });
  }, []);

  // ─── Drag Handlers ─────────────────────────────────────────────────────────

  const handleDragStart = useCallback((e: React.DragEvent, cardId: string) => {
    draggingCardId.current = cardId;

    // Find which column contains this card
    const board_snapshot = board; // Close over current board
    const srcColId = Object.values(board_snapshot.columns).find(col =>
      col.cardIds.includes(cardId)
    )?.id ?? null;
    sourceColumnId.current = srcColId;

    // Store cardId in dataTransfer (needed for drop event)
    e.dataTransfer.setData('cardId', cardId);
    e.dataTransfer.effectAllowed = 'move';
  }, [board]);

  const handleDragOver = useCallback((e: React.DragEvent, colId: string) => {
    e.preventDefault(); // MUST preventDefault to allow drop
    e.dataTransfer.dropEffect = 'move';
    setDragOverColId(colId);
  }, []);

  const handleDragLeave = useCallback((e: React.DragEvent) => {
    // Only clear if leaving the column entirely (not entering a child element)
    if (!e.currentTarget.contains(e.relatedTarget as Node)) {
      setDragOverColId(null);
    }
  }, []);

  const handleDrop = useCallback((e: React.DragEvent, destColumnId: string) => {
    e.preventDefault();
    const cardId  = e.dataTransfer.getData('cardId') || draggingCardId.current;
    const srcColId = sourceColumnId.current;

    if (!cardId || !srcColId || srcColId === destColumnId) {
      setDragOverColId(null);
      return;
    }

    // Move card: remove from source, add to destination
    setBoard(prev => {
      const srcCol  = prev.columns[srcColId];
      const destCol = prev.columns[destColumnId];

      return {
        ...prev,
        columns: {
          ...prev.columns,
          [srcColId]:  { ...srcCol,  cardIds: srcCol.cardIds.filter(id => id !== cardId) },
          [destColumnId]: { ...destCol, cardIds: [...destCol.cardIds, cardId] },
        },
      };
    });

    // Reset drag state
    draggingCardId.current = null;
    sourceColumnId.current = null;
    setDragOverColId(null);
  }, []);

  const handleDragEnd = useCallback(() => {
    draggingCardId.current = null;
    sourceColumnId.current = null;
    setDragOverColId(null);
  }, []);

  // ─── Board stats ───────────────────────────────────────────────────────────
  const totalCards = Object.keys(board.cards).length;

  const handleReset = () => {
    if (window.confirm('Reset board to initial state?')) {
      setBoard(INITIAL_STATE);
    }
  };

  return (
    <div
      onDragEnd={handleDragEnd}
      style={{
        minHeight:    '100vh',
        background:   'linear-gradient(135deg, #667eea 0%, #764ba2 100%)',
        padding:      '20px 24px',
        fontFamily:   '-apple-system, BlinkMacSystemFont, sans-serif',
      }}
    >
      {/* Header */}
      <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center', marginBottom: 24 }}>
        <div>
          <h1 style={{ color: 'white', margin: '0 0 2px', fontSize: 24, fontWeight: 700 }}>
            🗂 Kanban Board
          </h1>
          <p style={{ color: 'rgba(255,255,255,.7)', margin: 0, fontSize: 13 }}>
            {totalCards} cards across {board.columnOrder.length} columns · State saved in localStorage
          </p>
        </div>
        <button onClick={handleReset}
          style={{ padding: '8px 16px', background: 'rgba(255,255,255,.2)', color: 'white',
            border: '1px solid rgba(255,255,255,.3)', borderRadius: 8, cursor: 'pointer', fontSize: 13 }}>
          Reset Board
        </button>
      </div>

      {/* Columns */}
      <div style={{ display: 'flex', gap: 16, overflowX: 'auto', paddingBottom: 8 }}>
        {board.columnOrder.map(colId => {
          const column = board.columns[colId];
          const cards  = column.cardIds.map(id => board.cards[id]).filter(Boolean);

          return (
            <KanbanColumn
              key={colId}
              column={column}
              cards={cards}
              onAddCard={handleAddCard}
              onDeleteCard={handleDeleteCard}
              onDragStart={handleDragStart}
              onDragOver={handleDragOver}
              onDrop={handleDrop}
              onDragLeave={handleDragLeave}
              isDragOver={dragOverColId === colId}
              draggingCardId={draggingCardId.current}
            />
          );
        })}
      </div>
    </div>
  );
}
Enter fullscreen mode Exit fullscreen mode

Move Card Logic — Step-by-Step

// The core: move card from source column to destination column

// BEFORE:
// col-1.cardIds = ['card-1', 'card-2', 'card-3']
// col-2.cardIds = ['card-4']
// Dragging: card-2 from col-1 to col-2

// OPERATION:
setBoard(prev => ({
  ...prev,
  columns: {
    ...prev.columns,
    'col-1': {
      ...prev.columns['col-1'],
      cardIds: prev.columns['col-1'].cardIds.filter(id => id !== 'card-2')
      //       → ['card-1', 'card-3']  (removed card-2)
    },
    'col-2': {
      ...prev.columns['col-2'],
      cardIds: [...prev.columns['col-2'].cardIds, 'card-2']
      //       → ['card-4', 'card-2']  (added card-2)
    }
  }
}));

// AFTER:
// col-1.cardIds = ['card-1', 'card-3']
// col-2.cardIds = ['card-4', 'card-2']
// card-2 object in cards{} is UNCHANGED (just moved reference)
Enter fullscreen mode Exit fullscreen mode

UML Sequence — Drag & Drop Flow

User         KanbanCard    KanbanColumn     Board State
  │               │              │               │
  │ mousedown     │              │               │
  │──────────────▶│              │               │
  │ dragstart     │              │               │
  │──────────────▶│              │               │
  │               │ onDragStart  │               │
  │               │─────────────▶│               │
  │               │   store cardId, sourceColId  │
  │               │              │               │
  │ drag over     │              │               │
  │──────────────────────────────▶               │
  │               │   onDragOver: preventDefault │
  │               │   setDragOverColId = col-2   │
  │               │              │               │
  │ mouseup       │              │               │
  │──────────────────────────────▶               │
  │               │   onDrop(e, 'col-2')         │
  │               │              │ setBoard(prev │
  │               │              │  → remove from│
  │               │              │    col-1      │
  │               │              │  → add to     │
  │               │              │    col-2)     │
  │               │              │───────────────▶
  │               │              │               │
  │   re-render   │              │               │
  │◀─────────────────────────────────────────────│
Enter fullscreen mode Exit fullscreen mode

localStorage Persistence Pattern

// Step 1: Initialize state from localStorage (lazy initializer)
const [board, setBoard] = useState<BoardState>(() => {
  try {
    const saved = localStorage.getItem('kanban-board-state');
    if (saved) {
      const parsed = JSON.parse(saved);
      // Validate structure before using
      if (parsed.cards && parsed.columns && parsed.columnOrder) {
        return parsed;
      }
    }
  } catch (err) {
    console.warn('Failed to load from localStorage:', err);
  }
  return INITIAL_STATE; // Fallback
});

// Step 2: Sync to localStorage whenever state changes
useEffect(() => {
  try {
    localStorage.setItem('kanban-board-state', JSON.stringify(board));
  } catch (err) {
    // Handle QuotaExceededError gracefully
    console.warn('localStorage quota exceeded:', err);
  }
}, [board]);
Enter fullscreen mode Exit fullscreen mode

Summary — What to Say in the Interview

1. Data model (normalized state):
   → cards: { [id]: Card } — O(1) lookup, never duplicate
   → columns: { [id]: Column { cardIds[] } } — order preserved
   → columnOrder: string[] — controls column rendering order
   → Moving card = just update cardIds[] arrays

2. HTML5 DnD — three key events:
   → onDragStart: store cardId + sourceColumnId (in useRef)
   → onDragOver: e.preventDefault() (required to allow drop)
   → onDrop: read cardId, move it, reset drag state

3. Why useRef for drag state:
   → drag state changes rapidly (many dragover events per second)
   → useRef = no re-render on update (vs useState which re-renders)
   → Only setDragOverColId (visual indicator) uses useState

4. Immutable state updates:
   → Move card: spread existing state, create new column objects
   → Never mutate cardIds[] directly — always filter/push with spread
   → setBoard(prev => ...) ensures latest state

5. localStorage:
   → Initialize with lazy initializer: useState(() => loadFromStorage())
   → Persist with useEffect([board]) → localStorage.setItem()
   → Validate parsed data before using (guard against bad JSON)

6. Delete card:
   → Object destructuring to remove from cards map
   → .filter() to remove id from all column.cardIds arrays

7. Drop indicator UX:
   → dragOverColId state → shows dashed border + drop hint
   → Clear on dragLeave (only when leaving column, not child)
   → Clear on drop, dragEnd
Enter fullscreen mode Exit fullscreen mode

The One-Line Mental Model

"Normalized state separates cards from column order — dragging just moves a cardId from one column's cardIds[] to another's, with onDragStart/dragOver/drop events coordinating via refs to avoid re-renders during the drag."

Top comments (0)