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-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)
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, withonDragStart/dragOver/dropevents coordinating via refs to avoid re-renders during the drag."
Top comments (0)