Draggable cards across columns with persistence of column and order on drop.
Illustration coming soon
HOW TO USE THIS CHALLENGE
1. Read the briefClarify decisions before coding.
2. Build from memoryUse the 55-minute target.
3. Study the guideCompare architecture, tests, and trade-offs.
REQUIREMENTS
Multiple columns (e.g. To do / In progress / Done), each holding an ordered list of cards.
Drag a card within a column to reorder it, and across columns to move it.
Add a new card to a column; edit and delete existing cards.
Visually indicate the drop target position while dragging.
State survives a re-render (lifted to a parent store, not lost on drag end).
EDGE CASES
Dropping a card outside any valid drop zone should cancel the move, not delete the card.
Dragging the last remaining card out of a column should leave that column in a valid empty state.
Fast, repeated reordering shouldn't produce duplicate or dropped cards from stale drag state.
Touch devices need an interaction model since native HTML drag-and-drop has poor touch support.
ACCESSIBILITY
Provide a non-drag fallback: keyboard-accessible 'move to column' action (e.g. a menu) for users who can't drag.
Announce drag start, valid drop targets, and drop result via a live region.
Cards and columns need clear focus indicators independent of the drag interaction.
SUGGESTED APPROACH
Model state as columns: { id, title, cardIds }[] plus a normalized cards map, so reordering only touches arrays of ids.
Use onDragStart/onDragOver/onDrop (or a library) and track the currently-hovered column/index to render a drop indicator.
Compute the new column/index purely from the drop event, then update state in one immutable pass — avoid mutating during dragover.
Treat the keyboard 'move to column' path as the same state-update function the drag path calls, so both stay in sync.
EVALUATION RUBRIC
Drag-and-drop reordering and cross-column moves both update state correctly and immutably.
A working non-drag (keyboard) alternative exists for moving cards.
No duplicate/lost cards under rapid interaction.
Drop-target indicator accurately reflects where the card will land.
01Understand the product before coding
Learning goals
Multiple columns (e.g. To do / In progress / Done), each holding an ordered list of cards.
Drag a card within a column to reorder it, and across columns to move it.
Add a new card to a column; edit and delete existing cards.
Visually indicate the drop target position while dragging.
Decisions to state aloud
Dropping a card outside any valid drop zone should cancel the move, not delete the card.
Dragging the last remaining card out of a column should leave that column in a valid empty state.
Fast, repeated reordering shouldn't produce duplicate or dropped cards from stale drag state.
Touch devices need an interaction model since native HTML drag-and-drop has poor touch support.
02State model and invariants
Keep card records normalized and columns as ordered card-id arrays. Drag state is only a transient preview. One pure move operation removes an id and inserts it once, and both pointer and keyboard interactions call it.
03Card exposes a drag handle and keyboard Move action.
04DragOverlay prevents layout mutation during preview.
05LiveRegion announces move and cancellation results.
04Reference implementation walkthrough
Step 1
Write the pure move command
Validate membership, remove the card, adjust a forward same-column index after removal, clamp the destination, and insert once.
TypeScript
1functionmove(ids:string[], from:number, to:number){2const next =[...ids];3const[id]= next.splice(from,1);4const adjusted = from < to ? to -1: to;5 next.splice(Math.max(0, Math.min(adjusted, next.length)),0, id);6return next;7}
Step 2
Preview without committing
Collision detection converts pointer position into a target used for one drop indicator. Commit once on valid drop and cancel outside. Do not rewrite arrays during every pointer move.
TSX
1functionfinish(target:Target|null){2if(!drag ||!target)returncancelDrag();3setBoard((board)=>moveCard(board, drag.cardId, target));4announce(`Moved to position ${target.index +1}`);5setDrag(null);6}
Step 3
Provide an equivalent keyboard path
A Move menu or lift mode must choose column and position, call the same move command, announce the result, and restore focus to the moved card.
Step 4
Persist optimistically with versions
Send the operation and base version. Roll back only if the failed mutation is still current; otherwise rebase or refetch so an old failure cannot erase newer moves.
SOLComplete solution, explained simply
Build it yourself first. This is one correct implementation, not the only one — read it top to bottom, then compare the shape of your version.
We are building a board with columns of cards you can reorder by dragging or with the keyboard. The one big idea: store cards by id and each column as an ordered list of ids, then do every move with one pure function that says 'put this card before that card'. Anchoring to a card id instead of a number avoids the off-by-one bug that breaks most kanban attempts.
Kanban.tsx
1import{ useRef, useState }from"react";23type Card={id: string; title: string };4type Column={id: string; title: string; cardIds: string[]};5type Board={6columnOrder: string[];7columns:Record<string,Column>;8cards:Record<string,Card>;9};1011// Pure. Remove cardId from wherever it is, then insert it into toColumn12// directly before beforeId (or at the end when beforeId is null).13functionmoveCard(board:Board,cardId: string,toColumn: string,beforeId: string |null):Board{14constcolumns:Record<string,Column>={};15for(const[id, column]ofObject.entries(board.columns)){16 columns[id]={...column,cardIds: column.cardIds.filter((c)=> c !== cardId)};17}18const target = columns[toColumn].cardIds;19const at = beforeId ? target.indexOf(beforeId): target.length;20 target.splice(at ===-1? target.length: at,0, cardId);21return{...board, columns };22}2324// Keyboard step: move one place up or down within the same column.25functionnudge(board:Board,cardId: string,columnId: string,direction:1|-1):Board{26const ids = board.columns[columnId].cardIds;27constfrom= ids.indexOf(cardId);28const to =from+ direction;29if(to <0|| to >= ids.length)return board;30const beforeId = direction >0? ids[to +1]??null: ids[to];31returnmoveCard(board, cardId, columnId, beforeId);32}3334exportfunctionKanban({ initial }:{initial:Board}){35const[board, setBoard]=useState(initial);36const[dragId, setDragId]= useState<string |null>(null);37const liveRef = useRef<HTMLParagraphElement>(null);3839constannounce=(message: string)=>{40if(liveRef.current) liveRef.current.textContent= message;41};4243functiondrop(toColumn: string,beforeId: string |null){44if(!dragId)return;45setBoard((b)=>moveCard(b, dragId, toColumn, beforeId));46announce(board.cards[dragId].title+" moved to "+ board.columns[toColumn].title);47setDragId(null);48}4950functiononCardKeyDown(event:React.KeyboardEvent,cardId: string,columnId: string){51const colIndex = board.columnOrder.indexOf(columnId);52if(event.key==="ArrowUp"|| event.key==="ArrowDown"){53 event.preventDefault();54setBoard((b)=>nudge(b, cardId, columnId, event.key==="ArrowDown"?1:-1));55announce("Moved "+(event.key==="ArrowDown"?"down":"up"));56}elseif(event.key==="ArrowRight"&& colIndex < board.columnOrder.length-1){57 event.preventDefault();58const to = board.columnOrder[colIndex +1];59setBoard((b)=>moveCard(b, cardId, to,null));60announce("Moved to "+ board.columns[to].title);61}elseif(event.key==="ArrowLeft"&& colIndex >0){62 event.preventDefault();63const to = board.columnOrder[colIndex -1];64setBoard((b)=>moveCard(b, cardId, to,null));65announce("Moved to "+ board.columns[to].title);66}67}6869return(70<div className="board">71{board.columnOrder.map((columnId)=>{72const column = board.columns[columnId];73return(74<section75 key={columnId}76 aria-label={column.title}77 onDragOver={(event)=> event.preventDefault()}78 onDrop={()=>drop(columnId,null)}79>80<h2>{column.title}</h2>81<ul>82{column.cardIds.map((cardId)=>(83<li84 key={cardId}85 draggable86 tabIndex={0}87 aria-roledescription="Draggable card"88 onDragStart={()=>setDragId(cardId)}89 onDragEnd={()=>setDragId(null)}90 onDragOver={(event)=> event.preventDefault()}91 onDrop={(event)=>{ event.stopPropagation();drop(columnId, cardId);}}92 onKeyDown={(event)=>onCardKeyDown(event, cardId, columnId)}93>94{board.cards[cardId].title}95</li>96))}97</ul>98</section>99);100})}101<p ref={liveRef} role="status" aria-live="polite" className="sr-only"/>102</div>103);104}
How each part works
Cards by id, columns as id lists
board.cards is { id -> card } and each column has cardIds, an ordered array of those ids. Reordering only ever rewrites small arrays of strings; the card objects themselves keep the same identity, which keeps React's rendering cheap and the data easy to reason about.
moveCard anchors to a card, not a number
It first removes the moving card from every column, then inserts it before a specific beforeId. Because indexOf(beforeId) is looked up after the removal, the position is always correct — there is no 'if moving forward, subtract one' adjustment to get wrong. beforeId null means append to the end.
nudge is the keyboard version
It finds the card's current index, computes the neighbor index, and picks the right anchor: moving down, insert before the card two positions ahead (or the end); moving up, insert before the card at the target index. It reuses moveCard so pointer and keyboard go through the exact same tested code path.
Drag state is just one id
dragId holds the card being dragged and nothing else. The board data does not change during the drag. Only on drop does one moveCard run and commit. Dropping onto a card passes that card's id as beforeId; dropping onto column whitespace passes null.
stopPropagation on the card's onDrop
A card sits inside its column's drop zone. Without stopPropagation, dropping on a card would fire both the card's handler and the column's, running two moves. Stopping propagation lets the more specific target win.
The live region announces every move
The role="status" paragraph is visually hidden. Setting its text after each move makes a screen reader say 'Card moved to In progress', so a keyboard-only user gets the same feedback a sighted user gets from seeing the card jump.
Why this is correct
Normalized cards plus ordered id lists mean a reorder touches tiny string arrays, not deep object trees.
Anchoring a move to 'before this card id' instead of 'at index N' removes the off-by-one that breaks same-column forward drags.
Pointer drag and keyboard move both call the one pure move function, so they can never drift apart.
During a drag, only a single id changes; the board commits exactly once, on drop.
A hidden live region gives keyboard users the feedback that sighted users get for free from the animation.
05Testing strategy
Critical behavior
Drag-and-drop reordering and cross-column moves both update state correctly and immutably.
A working non-drag (keyboard) alternative exists for moving cards.
No duplicate/lost cards under rapid interaction.
Drop-target indicator accurately reflects where the card will land.
Failure and boundary cases
Dropping a card outside any valid drop zone should cancel the move, not delete the card.
Dragging the last remaining card out of a column should leave that column in a valid empty state.
Fast, repeated reordering shouldn't produce duplicate or dropped cards from stale drag state.
Touch devices need an interaction model since native HTML drag-and-drop has poor touch support.
Accessibility
Provide a non-drag fallback: keyboard-accessible 'move to column' action (e.g. a menu) for users who can't drag.
Announce drag start, valid drop targets, and drop result via a live region.
Cards and columns need clear focus indicators independent of the drag interaction.
06Performance and production hardening
Clone only source and target id arrays.
Run collision measurement at most once per frame when necessary.
Use compact move operations and server versions.
Plan virtualization around an overlay and logical destinations.
QAInterview questions and model answers
Answer aloud first. Then open the model answer and compare state ownership, failure handling, accessibility, and trade-offs—not exact wording.
Model answer
Reordering touches small id arrays while card objects retain stable identity, which simplifies updates, invariants, and render memoization.