VOOZH about

URL: https://dev.to/mahendranath_reddy_bandi/drag-drop-kanban-board-jiratrello-clone-frontend-interview-3c2g

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


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) │
└─────────────────────────────────────────────────────────────┘

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-basednormalizedstate(likeRedux/react-beautiful-dnd):{columns:{'col-1':{id:'col-1',title:'ToDo',cardIds:['card-1','card-2'],color:'#4285f4'},'col-2':{...},'col-3':{...}},cards:{'card-1':{id:'card-1',title:'Fixbug',description:'',priority:'high'},'card-2':{...}},columnOrder:['col-1','col-2','col-3']}Whynormalized?O(1)cardlookupbyIDMovecard=removefromsource.cardIds+addtodest.cardIdsNodeepnestedupdates2.Stringrefsfordragstate(useRefnore-render):draggingCardId:string|nullsourceColumnId:string|nulldragOverColumnId:string|null3.Array(columnOrder):PreservescolumndisplayorderAllowscolumnreorderinginfuture4.Set(forfastcardexistencechecksincomplexscenarios)

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

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)

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>
 );
}

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)

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 │ │ │
 │◀─────────────────────────────────────────────│

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]);

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

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."