Draggable components

Making components performantly draggable

Continuing from the previous post and thinking about interaction, today I worked on a simple custom hook that allows you to make any component draggable. First, here’s the demo. Try dragging the iPod around:

Process

My requirements for this hook were:

It works as follows. We have an element with a starting position. Pressing down, we check if we’re inside the element. If that’s the case, isDragging is set to true, and we calculate the offset between the mouse and element position so we can drag from the pointer’s position.

When isDragging is true, we can update the pointer’s position. Here we also prevent the element from going out of bounds. The updated position is then used to move the element using the translate property in CSS. At this point, we are not using state to update the position, because we want to avoid re-renders on every mouse move. Instead, we use a ref to update the position.

When the pointer is released, we’re no longer dragging, and isDragging is set to false. Here we can update the state with the new position. This will trigger a re-render, but it’s only once per drag.

In this example, it wasn’t necessary to optimize, but I’m assuming that at some point we may want to save the position to a database. In that case, we wouldn’t want to update while we’re dragging—it wouldn’t be useful anyway. We could’ve also used throttling, but it wouldn’t have been significant in this case because we need to update the position frequently enough to avoid latency. Sometimes if we know what is changing beforehand, we don’t need to let it go through React.

We are, however, using state for isDragging because we may need to use it for changing styling (ex. adding shadows or borders) when dragging the element. It only changes once per drag, so it’s not much of a performance hit. You can see how many times the component is re-rendered:

The component is updated twice: at the beginning and end of dragging. Re-renders are highlighted using green borders around the component.

Code

Our point is:

interface Point {
  x: number;
  y: number;
}

Using the hook for a div element located at position (0px, 0px):

function App() {
  const initialPos: Point = {
    x: 0,
    y: 0,
  };

  const { dragRef, pos, isDragging } = useDrag({ initialPos });

  return (
    <main>
      <div ref={dragRef}>
        <Coordinates point={pos} isDragging={isDragging} />
        <Image src={ipodPic} alt="Transparent iPod" />
      </div>
    </main>
  );
}

The hook itself:

interface Props {
  initialPos: Point;
}

function useDrag(props: Props) {
  const { initialPos } = props;
  const posRef = useRef<Point>({ ...floorPoint(initialPos) });
  const [pos, setPos] = useState<Point>({ ...floorPoint(initialPos) });
  const dragRef = useRef<HTMLDivElement>(null);
  const [isDragging, setIsDragging] = useState<boolean>(false);
  const initial = useRef<Point>({ x: 0, y: 0 });
  const offset = useRef<Point>({ x: 0, y: 0 });

  // style the draggable element
  const setupStyling = useCallback((ref: RefObject<HTMLDivElement>) => {
    if (!ref.current) return;
    ref.current.style.cursor = "grab";
    ref.current.style.userSelect = "none";
    ref.current.style.position = "absolute";
    ref.current.style.touchAction = "none";
  }, []);

  const updateTransform = useCallback((point: Point) => {
    if (!dragRef.current) return;
    dragRef.current.style.transform = `translate(${point.x}px, ${point.y}px)`;
  }, []);

  const updateOffset = useCallback((point: Point) => {
    offset.current = { ...point };
  }, []);

  const onDragStart = useCallback((e: PointerEvent) => {
    if (e.target === dragRef.current) {
      const { x: offsetX, y: offsetY } = offset.current;

      // update the pointer's position at the start of the drag
      const initialX = e.clientX - offsetX;
      const initialY = e.clientY - offsetY;

      initial.current = { x: initialX, y: initialY };
      setIsDragging(true);
    }
  }, []);

  const onDragEnd = useCallback(() => {
    initial.current = { ...pos };
    setPos(posRef.current);
    setIsDragging(false);
  }, [pos]);

  const onDrag = useCallback(
    (e: PointerEvent) => {
      if (!isDragging) return;

      const { x: initialX, y: initialY } = initial.current;
      const currentX = e.clientX - initialX;
      const currentY = e.clientY - initialY;

      // get the dimensions of the container
      const containerWidth = dragRef.current?.parentElement?.offsetWidth || 0;
      const containerHeight = dragRef.current?.parentElement?.offsetHeight || 0;

      // calculate the maximum allowed position based on the container dimensions and draggable element size
      const maxX = containerWidth - (dragRef.current?.offsetWidth || 0);
      const maxY = containerHeight - (dragRef.current?.offsetHeight || 0);

      // clamp the current position within the boundaries
      const clampedX = Math.max(0, Math.min(currentX, maxX));
      const clampedY = Math.max(0, Math.min(currentY, maxY));
      const clampedPoint = floorPoint({ x: clampedX, y: clampedY });

      posRef.current = clampedPoint;
      updateOffset(clampedPoint);
      updateTransform(clampedPoint);
    },
    [isDragging, updateOffset, updateTransform]
  );

  // update initial position and styling
  useEffect(() => {
    updateOffset(pos);
    updateTransform(pos);
    setupStyling(dragRef);
  }, [pos, updateOffset, updateTransform, setupStyling]);

  useEffect(() => {
    document.addEventListener("pointerdown", onDragStart);
    document.addEventListener("pointermove", onDrag, {
      passive: true,
    });
    document.addEventListener("pointerup", onDragEnd);

    return () => {
      document.removeEventListener("pointerdown", onDragStart);
      document.removeEventListener("pointermove", onDrag);
      document.removeEventListener("pointerup", onDragEnd);
    };
  }, [onDragStart, onDrag, onDragEnd]);

  return { pos, dragRef, isDragging };
}

Conclusion

I’m also curious to do a stress test and see how this hook will work with a lot of elements on the page. It’s probably better to use something like canvas, but I’d like to know how big of an impact it is to use states for drag operations. I’ll update this post if I get around to doing that.