DEV Community

Cover image for ArchScope Engineering Update: Multi-Selection & Undo/Redo Implementation (April 14, 2026)
Arpit Godghate
Arpit Godghate

Posted on

ArchScope Engineering Update: Multi-Selection & Undo/Redo Implementation (April 14, 2026)

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
}
Enter fullscreen mode Exit fullscreen mode

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);
Enter fullscreen mode Exit fullscreen mode

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]);
Enter fullscreen mode Exit fullscreen mode

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;
  }
}
Enter fullscreen mode Exit fullscreen mode

Key Insights:

  1. Deep Cloning: Using JSON.parse(JSON.stringify()) to create true copies
  2. External Storage: Keeping history outside React's state system
  3. Direct State Setting: Setting nodes/edges directly without ReactFlow interference
  4. 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]
);
Enter fullscreen mode Exit fullscreen mode

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)) 
};
Enter fullscreen mode Exit fullscreen mode

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:

  1. SelectionBox Component: Handles visual selection box rendering
  2. SimpleUndoRedo Class: Independent state management for history
  3. Enhanced Simulator Component: Main orchestration with all features
  4. Multi-Selection Logic: Shift+drag and Shift+click interactions
  5. Keyboard Shortcuts: Comprehensive shortcut system
  6. 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)