skills/implementing-drag-drop/references/performance-optimization.md

16 KiB

Performance Optimization for Drag-and-Drop

Table of Contents

Virtual Scrolling

Virtual List with Drag-and-Drop

import { VariableSizeList } from 'react-window';
import { DndContext, useSortable } from '@dnd-kit/sortable';

interface VirtualDragListProps {
  items: any[];
  itemHeight: number | ((index: number) => number);
  height: number;
  onReorder: (items: any[]) => void;
}

function VirtualDragList({ items, itemHeight, height, onReorder }: VirtualDragListProps) {
  // Track visible items for drag operations
  const [visibleRange, setVisibleRange] = useState({ start: 0, end: 10 });

  const Row = ({ index, style, data }) => {
    const item = data[index];

    return (
      <div style={style}>
        <VirtualDraggableItem item={item} index={index} />
      </div>
    );
  };

  const handleScroll = ({ visibleStartIndex, visibleStopIndex }) => {
    setVisibleRange({ start: visibleStartIndex, end: visibleStopIndex });
  };

  return (
    <DndContext onDragEnd={handleDragEnd}>
      <VariableSizeList
        height={height}
        itemCount={items.length}
        itemSize={itemHeight}
        width="100%"
        itemData={items}
        onItemsRendered={handleScroll}
        overscanCount={5} // Render extra items for smoother scrolling
      >
        {Row}
      </VariableSizeList>
    </DndContext>
  );
}

// Optimized draggable item for virtual list
const VirtualDraggableItem = memo(({ item, index }) => {
  const {
    attributes,
    listeners,
    setNodeRef,
    transform,
    transition,
    isDragging,
  } = useSortable({
    id: item.id,
    disabled: false,
  });

  const style = {
    transform: CSS.Transform.toString(transform),
    transition,
    opacity: isDragging ? 0.5 : 1,
    // Use transform for positioning, not top/left
    position: 'relative' as const,
  };

  return (
    <div
      ref={setNodeRef}
      style={style}
      {...attributes}
      {...listeners}
      className="virtual-draggable-item"
    >
      {item.content}
    </div>
  );
}, (prevProps, nextProps) => {
  // Custom comparison for memoization
  return prevProps.item.id === nextProps.item.id &&
         prevProps.item.content === nextProps.item.content &&
         prevProps.index === nextProps.index;
});

Intersection Observer for Lazy Loading

function LazyLoadDragList({ items, onLoadMore }) {
  const [visibleItems, setVisibleItems] = useState(items.slice(0, 50));
  const loaderRef = useRef<HTMLDivElement>(null);

  useEffect(() => {
    const observer = new IntersectionObserver(
      (entries) => {
        const [entry] = entries;
        if (entry.isIntersecting && visibleItems.length < items.length) {
          // Load more items
          const nextBatch = items.slice(
            visibleItems.length,
            visibleItems.length + 50
          );
          setVisibleItems(prev => [...prev, ...nextBatch]);
        }
      },
      {
        root: null,
        rootMargin: '100px', // Start loading before reaching the end
        threshold: 0.1,
      }
    );

    if (loaderRef.current) {
      observer.observe(loaderRef.current);
    }

    return () => observer.disconnect();
  }, [items, visibleItems]);

  return (
    <div className="lazy-load-container">
      <DndContext>
        <SortableContext items={visibleItems}>
          {visibleItems.map(item => (
            <SortableItem key={item.id} item={item} />
          ))}
        </SortableContext>
      </DndContext>

      {visibleItems.length < items.length && (
        <div ref={loaderRef} className="loading-indicator">
          Loading more items...
        </div>
      )}
    </div>
  );
}

Throttling and Debouncing

Throttled Drag Updates

function useThrottle<T extends (...args: any[]) => any>(
  callback: T,
  delay: number
): T {
  const lastCall = useRef(0);
  const timeoutRef = useRef<NodeJS.Timeout>();

  return useCallback((...args: Parameters<T>) => {
    const now = Date.now();

    if (now - lastCall.current >= delay) {
      lastCall.current = now;
      callback(...args);
    } else {
      // Schedule for later
      clearTimeout(timeoutRef.current);
      timeoutRef.current = setTimeout(() => {
        lastCall.current = Date.now();
        callback(...args);
      }, delay - (now - lastCall.current));
    }
  }, [callback, delay]) as T;
}

// Usage in drag handler
function OptimizedDragHandler() {
  const [dragPosition, setDragPosition] = useState({ x: 0, y: 0 });

  // Throttle to 60fps (16ms) for smooth animation
  const throttledUpdatePosition = useThrottle((position) => {
    setDragPosition(position);
    // Expensive calculations here
  }, 16);

  const handleDragMove = (event) => {
    const { active } = event;
    throttledUpdatePosition({
      x: active.rect.current.translated.left,
      y: active.rect.current.translated.top,
    });
  };

  return (
    <DndContext onDragMove={handleDragMove}>
      {/* Content */}
    </DndContext>
  );
}

Debounced Auto-Save

function useDebounce<T>(value: T, delay: number): T {
  const [debouncedValue, setDebouncedValue] = useState(value);

  useEffect(() => {
    const timer = setTimeout(() => {
      setDebouncedValue(value);
    }, delay);

    return () => clearTimeout(timer);
  }, [value, delay]);

  return debouncedValue;
}

// Auto-save after drag operations
function AutoSaveDragList({ items, onSave }) {
  const [localItems, setLocalItems] = useState(items);
  const debouncedItems = useDebounce(localItems, 500);

  useEffect(() => {
    if (debouncedItems !== items) {
      onSave(debouncedItems);
    }
  }, [debouncedItems]);

  const handleDragEnd = (event) => {
    // Update local state immediately
    const newItems = reorderItems(localItems, event);
    setLocalItems(newItems);
    // Debounced save will trigger after 500ms of no changes
  };

  return (
    <DndContext onDragEnd={handleDragEnd}>
      {/* Draggable items */}
    </DndContext>
  );
}

CSS Optimization

High-Performance CSS

/* Use transform instead of position for smooth animations */
.draggable-item {
  transform: translate3d(0, 0, 0); /* Enable GPU acceleration */
  will-change: transform; /* Hint browser optimization */
  backface-visibility: hidden; /* Prevent flickering */
  perspective: 1000px; /* Create stacking context */
}

/* Optimize transitions */
.draggable-item-transition {
  transition: transform 200ms cubic-bezier(0.4, 0, 0.2, 1);
  transition-property: transform; /* Only animate what changes */
}

/* Reduce paint areas during drag */
.dragging {
  pointer-events: none; /* Prevent hover effects */
  z-index: 9999; /* Lift above other elements */
  position: fixed; /* Take out of document flow */
}

/* Optimize shadows (expensive to render) */
.draggable-item {
  /* Use box-shadow sparingly */
  box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1);
}

.dragging .draggable-item {
  /* Larger shadow only when dragging */
  box-shadow: 0 8px 16px rgba(0, 0, 0, 0.2);
}

/* Use CSS containment */
.drag-container {
  contain: layout style; /* Isolate layout recalculations */
}

/* Optimize hover states */
@media (hover: hover) {
  .draggable-item:hover {
    /* Only apply hover on devices that support it */
    background-color: var(--hover-color);
  }
}

/* Reduce visual complexity during drag */
.drag-container.is-dragging .draggable-item:not(.dragging) {
  opacity: 0.7; /* Dim non-dragged items */
  filter: grayscale(30%); /* Reduce visual noise */
}

CSS Transform Utilities

// Utility for efficient transforms
export const transformStyles = {
  translate: (x: number, y: number, scale = 1) => ({
    transform: `translate3d(${x}px, ${y}px, 0) scale(${scale})`,
    WebkitTransform: `translate3d(${x}px, ${y}px, 0) scale(${scale})`,
  }),

  rotate: (deg: number) => ({
    transform: `rotate(${deg}deg)`,
    WebkitTransform: `rotate(${deg}deg)`,
  }),

  // Combine transforms efficiently
  combine: (...transforms: string[]) => ({
    transform: transforms.join(' '),
    WebkitTransform: transforms.join(' '),
  }),
};

// Use requestAnimationFrame for smooth animations
export function animateTransform(
  element: HTMLElement,
  from: { x: number; y: number },
  to: { x: number; y: number },
  duration: number,
  onComplete?: () => void
) {
  const startTime = performance.now();

  const animate = (currentTime: number) => {
    const elapsed = currentTime - startTime;
    const progress = Math.min(elapsed / duration, 1);

    // Easing function
    const easeProgress = 1 - Math.pow(1 - progress, 3);

    const x = from.x + (to.x - from.x) * easeProgress;
    const y = from.y + (to.y - from.y) * easeProgress;

    element.style.transform = `translate3d(${x}px, ${y}px, 0)`;

    if (progress < 1) {
      requestAnimationFrame(animate);
    } else {
      onComplete?.();
    }
  };

  requestAnimationFrame(animate);
}

React Optimization

Memoization Strategies

// Memoize expensive components
const MemoizedDraggableItem = memo(
  ({ item, index, onDragEnd }) => {
    const {
      attributes,
      listeners,
      setNodeRef,
      transform,
      transition,
    } = useSortable({ id: item.id });

    return (
      <div
        ref={setNodeRef}
        style={{
          transform: CSS.Transform.toString(transform),
          transition,
        }}
        {...attributes}
        {...listeners}
      >
        {item.content}
      </div>
    );
  },
  (prevProps, nextProps) => {
    // Custom equality check
    return (
      prevProps.item.id === nextProps.item.id &&
      prevProps.item.content === nextProps.item.content &&
      prevProps.index === nextProps.index
    );
  }
);

// Memoize callbacks
function DragListContainer({ items }) {
  const handleDragEnd = useCallback((event) => {
    const { active, over } = event;
    if (active.id !== over.id) {
      // Reorder logic
    }
  }, []); // Empty deps if function doesn't depend on props

  const sensors = useMemo(
    () => [
      useSensor(PointerSensor),
      useSensor(KeyboardSensor),
    ],
    []
  );

  return (
    <DndContext sensors={sensors} onDragEnd={handleDragEnd}>
      {/* Content */}
    </DndContext>
  );
}

State Management Optimization

// Use local state for drag preview, global for final position
function OptimizedDragState({ initialItems }) {
  const [items, setItems] = useState(initialItems);
  const [dragPreview, setDragPreview] = useState(null);
  const [activeId, setActiveId] = useState(null);

  const handleDragStart = (event) => {
    setActiveId(event.active.id);
    // Don't update main state yet
    setDragPreview({
      id: event.active.id,
      position: { x: 0, y: 0 },
    });
  };

  const handleDragMove = (event) => {
    // Only update preview position (cheap)
    setDragPreview(prev => ({
      ...prev,
      position: {
        x: event.delta.x,
        y: event.delta.y,
      },
    }));
  };

  const handleDragEnd = (event) => {
    // Update main state once (expensive)
    if (event.over && event.active.id !== event.over.id) {
      setItems(prevItems => {
        const oldIndex = prevItems.findIndex(i => i.id === event.active.id);
        const newIndex = prevItems.findIndex(i => i.id === event.over.id);
        return arrayMove(prevItems, oldIndex, newIndex);
      });
    }

    // Clean up
    setDragPreview(null);
    setActiveId(null);
  };

  return (
    <DndContext
      onDragStart={handleDragStart}
      onDragMove={handleDragMove}
      onDragEnd={handleDragEnd}
    >
      {/* Render items */}
      {/* Render preview overlay if dragging */}
    </DndContext>
  );
}

Large Dataset Handling

Chunked Rendering

function ChunkedDragList({ items, chunkSize = 20 }) {
  const [renderedChunks, setRenderedChunks] = useState(1);

  useEffect(() => {
    // Progressively render chunks
    const timer = setTimeout(() => {
      if (renderedChunks * chunkSize < items.length) {
        setRenderedChunks(prev => prev + 1);
      }
    }, 0);

    return () => clearTimeout(timer);
  }, [renderedChunks, items.length, chunkSize]);

  const visibleItems = items.slice(0, renderedChunks * chunkSize);

  return (
    <div className="chunked-list">
      {visibleItems.map(item => (
        <DraggableItem key={item.id} item={item} />
      ))}

      {visibleItems.length < items.length && (
        <div className="loading-more">
          Rendering {visibleItems.length} of {items.length} items...
        </div>
      )}
    </div>
  );
}

Indexed Data Structure

// Use Map for O(1) lookups instead of Array.find
function useIndexedItems(initialItems: Item[]) {
  const [itemsMap, setItemsMap] = useState(() => {
    const map = new Map<string, Item>();
    initialItems.forEach(item => map.set(item.id, item));
    return map;
  });

  const [itemOrder, setItemOrder] = useState(() =>
    initialItems.map(item => item.id)
  );

  const items = useMemo(
    () => itemOrder.map(id => itemsMap.get(id)!).filter(Boolean),
    [itemOrder, itemsMap]
  );

  const moveItem = useCallback((fromId: string, toId: string) => {
    setItemOrder(prev => {
      const fromIndex = prev.indexOf(fromId);
      const toIndex = prev.indexOf(toId);
      if (fromIndex === -1 || toIndex === -1) return prev;

      const newOrder = [...prev];
      const [removed] = newOrder.splice(fromIndex, 1);
      newOrder.splice(toIndex, 0, removed);
      return newOrder;
    });
  }, []);

  const updateItem = useCallback((id: string, updates: Partial<Item>) => {
    setItemsMap(prev => {
      const newMap = new Map(prev);
      const item = newMap.get(id);
      if (item) {
        newMap.set(id, { ...item, ...updates });
      }
      return newMap;
    });
  }, []);

  return { items, moveItem, updateItem };
}

Memory Management

Cleanup and Prevention

function useDragDropCleanup() {
  const activeRefs = useRef<Set<HTMLElement>>(new Set());
  const observers = useRef<Set<IntersectionObserver>>(new Set());

  const registerElement = useCallback((element: HTMLElement) => {
    activeRefs.current.add(element);

    return () => {
      activeRefs.current.delete(element);
      // Clean up any attached data
      delete (element as any)._dragData;
    };
  }, []);

  const createObserver = useCallback((callback: IntersectionObserverCallback) => {
    const observer = new IntersectionObserver(callback);
    observers.current.add(observer);

    return observer;
  }, []);

  useEffect(() => {
    return () => {
      // Cleanup all refs
      activeRefs.current.forEach(element => {
        delete (element as any)._dragData;
      });
      activeRefs.current.clear();

      // Disconnect all observers
      observers.current.forEach(observer => observer.disconnect());
      observers.current.clear();
    };
  }, []);

  return { registerElement, createObserver };
}

Memory-Efficient File Handling

function useFilePreview(file: File | null) {
  const [preview, setPreview] = useState<string | null>(null);

  useEffect(() => {
    if (!file || !file.type.startsWith('image/')) {
      setPreview(null);
      return;
    }

    // Create preview URL
    const objectUrl = URL.createObjectURL(file);
    setPreview(objectUrl);

    // Cleanup function
    return () => {
      URL.revokeObjectURL(objectUrl);
    };
  }, [file]);

  return preview;
}

// Batch cleanup for multiple files
function useBatchFileCleanup() {
  const urlsRef = useRef<Set<string>>(new Set());

  const createPreview = (file: File): string => {
    const url = URL.createObjectURL(file);
    urlsRef.current.add(url);
    return url;
  };

  const cleanupPreview = (url: string) => {
    URL.revokeObjectURL(url);
    urlsRef.current.delete(url);
  };

  const cleanupAll = () => {
    urlsRef.current.forEach(url => URL.revokeObjectURL(url));
    urlsRef.current.clear();
  };

  useEffect(() => {
    return cleanupAll;
  }, []);

  return { createPreview, cleanupPreview, cleanupAll };
}