19 KiB
19 KiB
State Management for Drag-and-Drop
Table of Contents
- React State Patterns
- Optimistic Updates
- Undo/Redo Functionality
- Persistence Strategies
- Multi-User Synchronization
- Error Recovery
React State Patterns
Basic Drag State Management
interface DragState {
isDragging: boolean;
draggedItem: any | null;
draggedFrom: string | null;
draggedOver: string | null;
initialPosition: { x: number; y: number } | null;
currentPosition: { x: number; y: number } | null;
}
function useDragState() {
const [dragState, setDragState] = useState<DragState>({
isDragging: false,
draggedItem: null,
draggedFrom: null,
draggedOver: null,
initialPosition: null,
currentPosition: null,
});
const startDrag = useCallback((item: any, from: string, position: { x: number; y: number }) => {
setDragState({
isDragging: true,
draggedItem: item,
draggedFrom: from,
draggedOver: null,
initialPosition: position,
currentPosition: position,
});
}, []);
const updateDrag = useCallback((over: string | null, position: { x: number; y: number }) => {
setDragState(prev => ({
...prev,
draggedOver: over,
currentPosition: position,
}));
}, []);
const endDrag = useCallback(() => {
setDragState({
isDragging: false,
draggedItem: null,
draggedFrom: null,
draggedOver: null,
initialPosition: null,
currentPosition: null,
});
}, []);
return {
dragState,
startDrag,
updateDrag,
endDrag,
};
}
Context-Based State Management
// DragDropContext.tsx
interface DragDropContextValue {
items: Record<string, Item[]>;
moveItem: (item: Item, fromContainer: string, toContainer: string, toIndex: number) => void;
reorderItems: (container: string, fromIndex: number, toIndex: number) => void;
isDragging: boolean;
draggedItem: Item | null;
canDrop: (container: string) => boolean;
}
const DragDropContext = createContext<DragDropContextValue | null>(null);
export function DragDropProvider({ children, initialItems }) {
const [items, setItems] = useState(initialItems);
const [draggedItem, setDraggedItem] = useState<Item | null>(null);
const moveItem = useCallback((
item: Item,
fromContainer: string,
toContainer: string,
toIndex: number
) => {
setItems(prev => {
const newItems = { ...prev };
// Remove from source
newItems[fromContainer] = newItems[fromContainer].filter(i => i.id !== item.id);
// Add to destination
newItems[toContainer] = [
...newItems[toContainer].slice(0, toIndex),
item,
...newItems[toContainer].slice(toIndex),
];
return newItems;
});
}, []);
const reorderItems = useCallback((
container: string,
fromIndex: number,
toIndex: number
) => {
setItems(prev => {
const newItems = { ...prev };
const containerItems = [...newItems[container]];
const [movedItem] = containerItems.splice(fromIndex, 1);
containerItems.splice(toIndex, 0, movedItem);
newItems[container] = containerItems;
return newItems;
});
}, []);
const canDrop = useCallback((container: string) => {
// Custom drop rules
const maxItems = 10;
return items[container]?.length < maxItems;
}, [items]);
const value = useMemo(
() => ({
items,
moveItem,
reorderItems,
isDragging: draggedItem !== null,
draggedItem,
canDrop,
}),
[items, moveItem, reorderItems, draggedItem, canDrop]
);
return (
<DragDropContext.Provider value={value}>
<DndContext
onDragStart={(event) => setDraggedItem(event.active.data.current?.item)}
onDragEnd={() => setDraggedItem(null)}
>
{children}
</DndContext>
</DragDropContext.Provider>
);
}
export function useDragDrop() {
const context = useContext(DragDropContext);
if (!context) {
throw new Error('useDragDrop must be used within DragDropProvider');
}
return context;
}
Reducer Pattern for Complex State
type DragAction =
| { type: 'START_DRAG'; payload: { item: Item; source: string } }
| { type: 'UPDATE_DRAG'; payload: { over: string; position: Position } }
| { type: 'END_DRAG'; payload: { target: string; index: number } }
| { type: 'CANCEL_DRAG' }
| { type: 'REORDER'; payload: { container: string; from: number; to: number } }
| { type: 'MOVE'; payload: { item: Item; from: string; to: string; index: number } };
interface DragDropState {
containers: Record<string, Item[]>;
dragState: {
isDragging: boolean;
item: Item | null;
source: string | null;
target: string | null;
};
history: DragDropState[];
historyIndex: number;
}
function dragDropReducer(state: DragDropState, action: DragAction): DragDropState {
switch (action.type) {
case 'START_DRAG':
return {
...state,
dragState: {
isDragging: true,
item: action.payload.item,
source: action.payload.source,
target: null,
},
};
case 'UPDATE_DRAG':
return {
...state,
dragState: {
...state.dragState,
target: action.payload.over,
},
};
case 'END_DRAG': {
if (!state.dragState.item || !state.dragState.source) {
return state;
}
const newContainers = { ...state.containers };
const { item, source } = state.dragState;
const { target, index } = action.payload;
// Remove from source
newContainers[source] = newContainers[source].filter(i => i.id !== item.id);
// Add to target
newContainers[target] = [
...newContainers[target].slice(0, index),
item,
...newContainers[target].slice(index),
];
// Add to history for undo
const newState = {
containers: newContainers,
dragState: {
isDragging: false,
item: null,
source: null,
target: null,
},
history: [...state.history.slice(0, state.historyIndex + 1), state],
historyIndex: state.historyIndex + 1,
};
return newState;
}
case 'CANCEL_DRAG':
return {
...state,
dragState: {
isDragging: false,
item: null,
source: null,
target: null,
},
};
default:
return state;
}
}
Optimistic Updates
Optimistic UI Pattern
interface OptimisticUpdate<T> {
id: string;
action: () => Promise<T>;
optimistic: T;
rollback: T;
timestamp: number;
}
function useOptimisticUpdates<T>() {
const [pending, setPending] = useState<Map<string, OptimisticUpdate<T>>>(new Map());
const [failed, setFailed] = useState<Map<string, Error>>(new Map());
const executeOptimistic = async (
id: string,
action: () => Promise<T>,
optimistic: T,
rollback: T
) => {
// Apply optimistic update immediately
setPending(prev => new Map(prev).set(id, {
id,
action,
optimistic,
rollback,
timestamp: Date.now(),
}));
try {
// Execute the actual action
const result = await action();
// Remove from pending on success
setPending(prev => {
const next = new Map(prev);
next.delete(id);
return next;
});
return result;
} catch (error) {
// Mark as failed and rollback
setFailed(prev => new Map(prev).set(id, error as Error));
// Apply rollback
setPending(prev => {
const next = new Map(prev);
const update = next.get(id);
if (update) {
next.set(id, { ...update, optimistic: rollback });
}
return next;
});
throw error;
}
};
const retry = async (id: string) => {
const update = pending.get(id);
if (!update) return;
setFailed(prev => {
const next = new Map(prev);
next.delete(id);
return next;
});
await executeOptimistic(id, update.action, update.optimistic, update.rollback);
};
return { pending, failed, executeOptimistic, retry };
}
// Usage in drag-drop
function OptimisticDragDrop({ items, onSave }) {
const [localItems, setLocalItems] = useState(items);
const { executeOptimistic, failed } = useOptimisticUpdates();
const handleDragEnd = async (event) => {
const { active, over } = event;
if (!over || active.id === over.id) return;
const oldItems = [...localItems];
const newItems = reorderItems(localItems, active.id, over.id);
// Apply optimistically
setLocalItems(newItems);
// Save to server with rollback
await executeOptimistic(
`reorder-${Date.now()}`,
() => onSave(newItems),
newItems,
oldItems
).catch(() => {
// Rollback handled automatically
setLocalItems(oldItems);
toast.error('Failed to save. Please try again.');
});
};
return (
<DndContext onDragEnd={handleDragEnd}>
{failed.size > 0 && (
<div className="error-banner">
Some changes failed to save. Retrying...
</div>
)}
{/* Render items */}
</DndContext>
);
}
Undo/Redo Functionality
History Management
interface HistoryState<T> {
past: T[];
present: T;
future: T[];
}
function useHistory<T>(initialState: T) {
const [history, setHistory] = useState<HistoryState<T>>({
past: [],
present: initialState,
future: [],
});
const canUndo = history.past.length > 0;
const canRedo = history.future.length > 0;
const push = useCallback((newState: T) => {
setHistory(prev => ({
past: [...prev.past, prev.present],
present: newState,
future: [], // Clear future on new action
}));
}, []);
const undo = useCallback(() => {
setHistory(prev => {
if (prev.past.length === 0) return prev;
const previous = prev.past[prev.past.length - 1];
const newPast = prev.past.slice(0, prev.past.length - 1);
return {
past: newPast,
present: previous,
future: [prev.present, ...prev.future],
};
});
}, []);
const redo = useCallback(() => {
setHistory(prev => {
if (prev.future.length === 0) return prev;
const next = prev.future[0];
const newFuture = prev.future.slice(1);
return {
past: [...prev.past, prev.present],
present: next,
future: newFuture,
};
});
}, []);
const reset = useCallback(() => {
setHistory({
past: [],
present: initialState,
future: [],
});
}, [initialState]);
// Keyboard shortcuts
useEffect(() => {
const handleKeyDown = (e: KeyboardEvent) => {
if ((e.metaKey || e.ctrlKey) && e.key === 'z' && !e.shiftKey) {
e.preventDefault();
if (canUndo) undo();
}
if ((e.metaKey || e.ctrlKey) && (e.key === 'y' || (e.key === 'z' && e.shiftKey))) {
e.preventDefault();
if (canRedo) redo();
}
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [canUndo, canRedo, undo, redo]);
return {
state: history.present,
push,
undo,
redo,
reset,
canUndo,
canRedo,
history,
};
}
// Usage with drag-drop
function DragDropWithHistory() {
const { state: items, push, undo, redo, canUndo, canRedo } = useHistory(initialItems);
const handleDragEnd = (event) => {
const newItems = reorderItems(items, event);
push(newItems); // Save to history
};
return (
<>
<div className="controls">
<button onClick={undo} disabled={!canUndo}>
Undo (Ctrl+Z)
</button>
<button onClick={redo} disabled={!canRedo}>
Redo (Ctrl+Y)
</button>
</div>
<DndContext onDragEnd={handleDragEnd}>
<SortableContext items={items}>
{items.map(item => (
<SortableItem key={item.id} item={item} />
))}
</SortableContext>
</DndContext>
</>
);
}
Persistence Strategies
Local Storage Persistence
function useLocalStorageState<T>(key: string, defaultValue: T) {
const [state, setState] = useState<T>(() => {
try {
const item = localStorage.getItem(key);
return item ? JSON.parse(item) : defaultValue;
} catch {
return defaultValue;
}
});
const setValue = useCallback((value: T | ((prev: T) => T)) => {
setState(prev => {
const nextState = value instanceof Function ? value(prev) : value;
try {
localStorage.setItem(key, JSON.stringify(nextState));
} catch (error) {
console.error('Failed to save to localStorage:', error);
}
return nextState;
});
}, [key]);
// Sync between tabs
useEffect(() => {
const handleStorageChange = (e: StorageEvent) => {
if (e.key === key && e.newValue) {
try {
setState(JSON.parse(e.newValue));
} catch {}
}
};
window.addEventListener('storage', handleStorageChange);
return () => window.removeEventListener('storage', handleStorageChange);
}, [key]);
return [state, setValue] as const;
}
// Auto-save with debouncing
function AutoSaveDragDrop() {
const [items, setItems] = useLocalStorageState('drag-drop-items', initialItems);
const [lastSaved, setLastSaved] = useState<Date | null>(null);
const debouncedItems = useDebounce(items, 1000);
useEffect(() => {
if (debouncedItems !== items) {
setLastSaved(new Date());
}
}, [debouncedItems]);
return (
<div>
{lastSaved && (
<div className="save-indicator">
Last saved: {lastSaved.toLocaleTimeString()}
</div>
)}
<DndContext onDragEnd={(event) => {
const newItems = reorderItems(items, event);
setItems(newItems);
}}>
{/* Content */}
</DndContext>
</div>
);
}
IndexedDB for Large Datasets
class DragDropStore {
private db: IDBDatabase | null = null;
private dbName = 'DragDropDB';
private storeName = 'items';
async init() {
return new Promise<void>((resolve, reject) => {
const request = indexedDB.open(this.dbName, 1);
request.onupgradeneeded = (event) => {
const db = (event.target as IDBOpenDBRequest).result;
if (!db.objectStoreNames.contains(this.storeName)) {
db.createObjectStore(this.storeName, { keyPath: 'id' });
}
};
request.onsuccess = (event) => {
this.db = (event.target as IDBOpenDBRequest).result;
resolve();
};
request.onerror = () => reject(request.error);
});
}
async saveItems(items: Item[]) {
if (!this.db) await this.init();
const transaction = this.db!.transaction([this.storeName], 'readwrite');
const store = transaction.objectStore(this.storeName);
// Clear existing items
await new Promise((resolve, reject) => {
const clearRequest = store.clear();
clearRequest.onsuccess = () => resolve(true);
clearRequest.onerror = () => reject(clearRequest.error);
});
// Add new items
for (const item of items) {
store.add(item);
}
return new Promise<void>((resolve, reject) => {
transaction.oncomplete = () => resolve();
transaction.onerror = () => reject(transaction.error);
});
}
async loadItems(): Promise<Item[]> {
if (!this.db) await this.init();
const transaction = this.db!.transaction([this.storeName], 'readonly');
const store = transaction.objectStore(this.storeName);
return new Promise((resolve, reject) => {
const request = store.getAll();
request.onsuccess = () => resolve(request.result);
request.onerror = () => reject(request.error);
});
}
}
// Hook for IndexedDB persistence
function useIndexedDBState(initialItems: Item[]) {
const [items, setItems] = useState(initialItems);
const [loading, setLoading] = useState(true);
const store = useRef(new DragDropStore());
// Load from IndexedDB on mount
useEffect(() => {
store.current.loadItems()
.then(savedItems => {
if (savedItems.length > 0) {
setItems(savedItems);
}
})
.finally(() => setLoading(false));
}, []);
// Save to IndexedDB on change
const saveItems = useCallback(async (newItems: Item[]) => {
setItems(newItems);
await store.current.saveItems(newItems);
}, []);
return { items, saveItems, loading };
}
Multi-User Synchronization
WebSocket Real-Time Sync
function useRealtimeDragDrop(roomId: string) {
const [items, setItems] = useState<Item[]>([]);
const [otherUsers, setOtherUsers] = useState<Map<string, UserCursor>>(new Map());
const ws = useRef<WebSocket>();
useEffect(() => {
ws.current = new WebSocket(`wss://api.example.com/rooms/${roomId}`);
ws.current.onmessage = (event) => {
const message = JSON.parse(event.data);
switch (message.type) {
case 'ITEMS_UPDATE':
setItems(message.items);
break;
case 'USER_DRAGGING':
setOtherUsers(prev => new Map(prev).set(message.userId, {
item: message.item,
position: message.position,
color: message.color,
}));
break;
case 'USER_DROPPED':
setOtherUsers(prev => {
const next = new Map(prev);
next.delete(message.userId);
return next;
});
break;
}
};
return () => ws.current?.close();
}, [roomId]);
const handleDragStart = (event) => {
ws.current?.send(JSON.stringify({
type: 'DRAG_START',
item: event.active.data.current?.item,
position: { x: event.active.rect.current.translated.left, y: event.active.rect.current.translated.top },
}));
};
const handleDragEnd = (event) => {
const newItems = reorderItems(items, event);
// Optimistic update
setItems(newItems);
// Broadcast change
ws.current?.send(JSON.stringify({
type: 'DRAG_END',
items: newItems,
}));
};
return {
items,
otherUsers,
handleDragStart,
handleDragEnd,
};
}
Error Recovery
Error Boundary for Drag-Drop
class DragDropErrorBoundary extends Component<
{ children: React.ReactNode; onError?: (error: Error) => void },
{ hasError: boolean; error: Error | null }
> {
constructor(props) {
super(props);
this.state = { hasError: false, error: null };
}
static getDerivedStateFromError(error: Error) {
return { hasError: true, error };
}
componentDidCatch(error: Error, errorInfo: ErrorInfo) {
console.error('Drag-drop error:', error, errorInfo);
this.props.onError?.(error);
}
render() {
if (this.state.hasError) {
return (
<div className="error-fallback">
<h2>Something went wrong with drag-and-drop</h2>
<button onClick={() => this.setState({ hasError: false, error: null })}>
Reset
</button>
<details>
<summary>Error details</summary>
<pre>{this.state.error?.stack}</pre>
</details>
</div>
);
}
return this.props.children;
}
}
// Wrap drag-drop components
function SafeDragDrop({ children }) {
return (
<DragDropErrorBoundary
onError={(error) => {
// Log to error tracking service
logError(error, { context: 'drag-drop' });
}}
>
{children}
</DragDropErrorBoundary>
);
}