Draggable components
Making components performantly draggableContinuing 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 should be confined to a parent element
- It should be performant (in this case, less reactive)
- It should support mouse and touch events
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:
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.