Implementing Multi-Selection and Undo/Redo in ReactFlow: A Technical Deep Dive
Introduction
Today, we are implementing a comprehensive multi-selection system with undo/redo functionality. This was a technical challenge that tested our understanding of ReactFlow's internal state management.
The requirement was to make a box to select and move multiple components, the kind present in file systems, where, when you click and drag, a box appears and whatever is inside the box is selected and moves like a single unit.
These design requirements were as follows:
- Visual selection box with Shift+drag interaction
- Multi-component selection with visual feedback
- Group movement capabilities
- Keyboard shortcuts for productivity
- Undo/redo functionality for all operations
- Copy/paste support for selected components
Step 1: The Selection Box Implementation
Initial Challenges
Our first hurdle was implementing the selection box without interfering with ReactFlow's built-in interactions. ReactFlow has its own panning, node selection, and event handling systems that can conflict with custom implementations.
Problem: The selection box wasn't appearing or was triggering ReactFlow's pan instead.
Root Cause: ReactFlow's event system captures mouse events at multiple levels, making it difficult to inject custom selection logic without breaking existing functionality.
Solution Approach:
// We needed precise event target detection
const isReactFlowPane = target.classList.contains('react-flow__pane');
const isNode = target.closest('.react-flow__node');
const isHandle = target.closest('.react-flow__handle');
const isControl = target.closest('.react-flow__controls');
const isMiniMap = target.closest('.react-flow__minimap');
// Only start selection on empty canvas with Shift key
if (event.button === 0 && isReactFlowPane && !isNode && !isHandle && !isControl && !isMiniMap && event.shiftKey) {
// Start selection logic
}
Coordinate Transformation Hell
The second major challenge was coordinate transformation. ReactFlow uses its own coordinate system with viewport transformations (zoom and pan), but mouse events provide screen coordinates.
Problem: Selection bounds weren't matching actual component positions.
Solution: We implemented manual coordinate transformation:
const viewport = reactFlowRef.current?.getViewport();
const nodeScreenX = node.position.x * (viewport?.zoom || 1) + (viewport?.x || 0);
const nodeScreenY = node.position.y * (viewport?.zoom || 1) + (viewport?.y || 0);
Step 2: The Undo/Redo Nightmare
This became the most challenging part of the implementation. Our initial attempts at implementing undo/redo were plagued by state management issues.
Attempt 1: Simple State Snapshots
Approach: Store snapshots of nodes and edges in a history array.
Problem: ReactFlow's internal state management interfered with our manual state setting, causing race conditions and inconsistent state.
Code That Failed:
const saveToHistory = useCallback(() => {
const currentState = { nodes: [...nodes], edges: [...edges] };
setHistory(prev => [...prev, currentState]);
}, [nodes, edges]);
const undo = useCallback(() => {
const prevState = history[historyIndex - 1];
setNodes(prevState.nodes); // This caused conflicts with ReactFlow
setEdges(prevState.edges);
}, [history, historyIndex]);
Attempt 2: Debounced State Saving
Approach: Use setTimeout to delay state saves and avoid conflicts.
Problem: This created timing issues and still didn't solve the core problem of ReactFlow's state interference.
Attempt 3: ReactFlow Instance Access
Approach: Use ReactFlow's instance methods to get current state.
Problem: Still faced race conditions and state inconsistency.
The Breakthrough: External State Management
After multiple failed attempts, we realized the fundamental issue was trying to work within ReactFlow's state management system. The solution was to implement a completely external state management system.
Final Solution:
// SimpleUndoRedo class - completely independent
export class SimpleUndoRedo {
private history: HistoryState[] = [];
private currentIndex: number = -1;
saveState(nodes: any[], edges: any[]) {
const currentState: HistoryState = {
nodes: JSON.parse(JSON.stringify(nodes)),
edges: JSON.parse(JSON.stringify(edges))
};
// ... history management logic
}
undo(): HistoryState | null {
if (this.currentIndex > 0) {
this.currentIndex--;
const state = this.history[this.currentIndex];
return {
nodes: JSON.parse(JSON.stringify(state.nodes)),
edges: JSON.parse(JSON.stringify(state.edges))
};
}
return null;
}
}
Key Insights:
-
Deep Cloning: Using
JSON.parse(JSON.stringify())to create true copies - External Storage: Keeping history outside React's state system
- Direct State Setting: Setting nodes/edges directly without ReactFlow interference
- Timing Control: Using setTimeout to ensure state changes complete
Step 3: Performance Optimization
The Infinite Loop Crisis
Problem: Our implementation caused "Maximum update depth exceeded" errors due to infinite re-renders.
Root Cause: The nodes array was being recreated on every render, causing ReactFlow to continuously update.
Solution: Memoization
const memoizedNodes = useMemo(() =>
nodes.map((n) => ({
...n,
data: {
...n.data,
highlighted: n.id === highlightedNodeId,
isMultiSelected: selectedNodes.includes(n.id)
},
selected: selectedNodes.includes(n.id) || n.id === selectedNode?.id,
})),
[nodes, highlightedNodeId, selectedNodes, selectedNode]
);
Technical Lessons Learned
1. State Management Is Harder Than It Looks
ReactFlow's internal state management is complex and not easily extensible. Sometimes the best solution is to work around it rather than with it.
2. Deep Cloning Is Essential
JavaScript object references can cause subtle bugs. Deep cloning ensures true state independence:
// Bad: Shallow copy
const bad = { nodes: nodes, edges: edges };
// Good: Deep copy
const good = {
nodes: JSON.parse(JSON.stringify(nodes)),
edges: JSON.parse(JSON.stringify(edges))
};
3. Performance Requires Memoization
In React applications with complex state, memoization isn't optional - it's essential for preventing infinite loops and performance issues.
4. User Experience Matters More Than Perfect Code
Sometimes a "hacky" solution that works well for users is better than a "perfect" solution that's brittle.
The Final Architecture
Our final implementation consists of:
- SelectionBox Component: Handles visual selection box rendering
- SimpleUndoRedo Class: Independent state management for history
- Enhanced Simulator Component: Main orchestration with all features
- Multi-Selection Logic: Shift+drag and Shift+click interactions
- Keyboard Shortcuts: Comprehensive shortcut system
- Visual Indicators: CSS-based feedback for selected components
Performance Metrics
- Memory Usage: ~50 history states (configurable)
- Rendering Performance: Optimized with memoization
- Event Response Time: <16ms for all interactions
Check out ArchScope - https://archscope-app.vercel.app/
Its an architecture analysis tool that can be used to evaluate your High level designs under varying loads.
Top comments (0)