perf-drag-resize-patterns

Performance Patterns: Drag, Resize & Continuous Interactions in React

Lessons learned from optimizing Hudson's drag, resize, pan, and scrub interactions.

The Core Problem

React re-renders components when state changes. During drag/resize operations, if you call setState on every mousemove event (~60-120 times/second), React reconciles and re-renders the entire subtree on every frame. With a 16ms frame budget (60fps), even a few milliseconds of reconciliation overhead creates visible jank.

The Pattern: Refs During Interaction, State on Commit

mousemove (hot path)     ->  Write to refs / DOM directly
mouseup   (commit)       ->  Flush final value to React state

This is the single most impactful optimization for interaction performance in React. React never needs to know about intermediate values.


Pattern 1: Direct DOM Manipulation for Position/Size

When to use: Moving, resizing, or repositioning an element during drag.

Before (bad)

const [bounds, setBounds] = useState({ x: 0, y: 0, w: 400, h: 300 });

const onMouseMove = (ev: MouseEvent) => {
  setBounds({ ...startBounds, x: startBounds.x + dx, y: startBounds.y + dy });
  // React re-renders the entire window + children every frame
};

After (good)

const windowRef = useRef<HTMLDivElement>(null);
const [bounds, setBounds] = useState({ x: 0, y: 0, w: 400, h: 300 });

const applyBoundsToDOM = (b: Bounds) => {
  const el = windowRef.current;
  if (!el) return;
  el.style.left = `${b.x}px`;
  el.style.top = `${b.y}px`;
  el.style.width = `${b.w}px`;
  el.style.height = `${b.h}px`;
};

const onMouseMove = (ev: MouseEvent) => {
  // Direct DOM — zero React overhead
  applyBoundsToDOM({ ...startBounds, x: startBounds.x + dx, y: startBounds.y + dy });
};

const onMouseUp = (ev: MouseEvent) => {
  // Flush to React state once
  setBounds({ ...startBounds, x: startBounds.x + dx, y: startBounds.y + dy });
};

Applied to: AppWindow drag, AppWindow resize, sidebar panel resize.


Pattern 2: Closure-Based Drag Handlers

When to use: Any drag interaction where you need start values.

Instead of storing startX, startWidth etc. in state and reading them back on every move, capture them in a closure at mousedown time.

Before (bad)

const [isDragging, setIsDragging] = useState(false);
const [startX, setStartX] = useState(0);

const onMouseDown = (e) => {
  setIsDragging(true);
  setStartX(e.clientX);
};

// This useEffect re-runs every time isDragging changes
useEffect(() => {
  if (isDragging) {
    const onMouseMove = (ev) => { /* uses startX from state */ };
    const onMouseUp = () => { setIsDragging(false); };
    window.addEventListener('mousemove', onMouseMove);
    window.addEventListener('mouseup', onMouseUp);
    return () => { /* cleanup */ };
  }
}, [isDragging, startX]);

After (good)

const onMouseDown = (e: React.MouseEvent) => {
  const startX = e.clientX;           // Captured in closure
  const startWidth = currentWidth;     // Captured in closure

  const onMouseMove = (ev: MouseEvent) => {
    const delta = ev.clientX - startX;
    const newWidth = Math.max(MIN, Math.min(MAX, startWidth + delta));
    element.style.width = `${newWidth}px`;  // DOM direct
  };

  const onMouseUp = (ev: MouseEvent) => {
    const delta = ev.clientX - startX;
    setWidth(Math.max(MIN, Math.min(MAX, startWidth + delta)));  // State flush
    document.removeEventListener('mousemove', onMouseMove);
    document.removeEventListener('mouseup', onMouseUp);
  };

  document.addEventListener('mousemove', onMouseMove);
  document.addEventListener('mouseup', onMouseUp);
};

Benefits:

  • No state for drag tracking (isDragging, startX etc.)
  • No useEffect for listener management
  • Closure captures exactly what you need
  • Cleanup is local to the handler

Applied to: Sidebar resize, anchor list resize, animation scrubber.


Pattern 3: Ref Mirrors for Hot-Path Reads

When to use: When state needs to be readable in a hot path without triggering re-renders.

const [isPanning, setIsPanning] = useState(false);
const isPanningRef = useRef(false);

// Set both when changing
const startPan = () => {
  setIsPanning(true);
  isPanningRef.current = true;
};

// Read ref in hot path (no re-render dependency)
const onMouseMove = (ev: MouseEvent) => {
  if (isPanningRef.current) {
    // fast path — no React involvement
  }
};

// State still drives UI (cursor changes, conditional rendering)
<div className={isPanning ? 'cursor-grabbing' : 'cursor-grab'}>

Applied to: Canvas pan (isPanningRef, panStartRef) and window drag (dragStartRef).


Pattern 4: rAF Batching for Decorative Updates

When to use: Visual feedback that doesn't need to be pixel-perfect every frame (crosshairs, coordinate readouts, progress indicators).

const rafRef = useRef<number | null>(null);

const onMouseMove = (ev: MouseEvent) => {
  // Cancel previous pending update
  if (rafRef.current) cancelAnimationFrame(rafRef.current);

  rafRef.current = requestAnimationFrame(() => {
    setMousePos({ x: ev.clientX, y: ev.clientY });
    rafRef.current = null;
  });
};

This coalesces multiple mousemove events into a single state update per frame. If the browser fires 3 mousemove events before the next paint, only the last one triggers a React render.

Applied to: Canvas guide coordinates, resize overlays, and animation scrubber progress.


Pattern 5: Targeted Cloning Instead of Deep Copy

When to use: Updating nested immutable state during drag.

Before (bad)

setBezierData((prev) => {
  const newData = JSON.parse(JSON.stringify(prev));  // Deep clone EVERYTHING
  newData.strokes[i][j].p0[0] += dx;                // Modify one point
  return newData;
});

JSON.parse(JSON.stringify()) is O(n) on the entire data structure. For bezier data with hundreds of segments, this is significant per-frame overhead.

After (good)

setBezierData((prev) => {
  const newStrokes = prev.strokes.map((stroke, si) => {
    if (si !== targetStroke) return stroke;  // Reuse unchanged strokes
    return stroke.map((seg, sei) => {
      if (sei !== targetSegment) return seg;  // Reuse unchanged segments
      // Only clone the one segment we're modifying
      return { p0: [...seg.p0], c1: [...seg.c1], c2: [...seg.c2], p3: [...seg.p3] };
    });
  });
  const seg = newStrokes[targetStroke][targetSegment];
  seg.p0[0] += dx;
  return { strokes: newStrokes };
});

Benefits:

  • O(1) cloning instead of O(n)
  • Unchanged array references let React.memo / useMemo skip re-renders downstream
  • No JSON serialization overhead

Applied to: Window bounds updates and document/visual editor node mutation.


Pattern 6: DOM Query for Element Targeting

When to use: When you need to manipulate an element you don't have a ref to (e.g., it's in a child component).

// Add a data attribute to the target element
<div data-frame-panel="manifest" style={{ width: `${leftWidth}px` }}>

// In the drag handler, query for it
const panelEl = document.querySelector('[data-frame-panel="manifest"]') as HTMLElement;
if (panelEl) panelEl.style.width = `${newWidth}px`;

This bridges the gap when the drag handler is in a parent but the visual element is in a child. It's simpler than threading refs through props for a one-off interaction.

Applied to: Sidebar resize (parent handler, child panel element).


Decision Checklist

Ask yourself these questions when implementing any continuous interaction:

  1. Is setState called inside onMouseMove/onPointerMove? If yes, it's a hot path — apply Pattern 1 or 3.
  2. Am I deep-cloning data every frame? Use targeted cloning (Pattern 5) or accumulate deltas in refs and apply once.
  3. Is the visual update purely decorative? (crosshairs, guides, tooltips) — use rAF batching (Pattern 4) or direct DOM (Pattern 1).
  4. Am I using useEffect to manage drag listeners? Switch to closure-based handlers (Pattern 2) for simpler, faster code.
  5. Does my parent re-render children during drag? The parent's setState re-renders everything below it — push the DOM manipulation as close to the leaf element as possible.

Where to find these patterns in the SDK

The Canvas, AppWindow, and AnimationTimeline components in hudsonkit use the same techniques internally — refs and direct DOM during drag/resize, state only on commit. Reading their source is the fastest way to see the patterns applied end-to-end.

For AI agents