Skip to content

feat(resizing): Add new performant useResizable hook #91548

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Closed
wants to merge 7 commits into from
Closed
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
158 changes: 158 additions & 0 deletions static/app/utils/useResizable.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,158 @@
import type {RefObject} from 'react';
import {useCallback, useEffect, useRef, useState} from 'react';

interface UseResizableOptions {
/**
* The starting size of the container, and the size that is set in the onDoubleClick handler.
*
* If `sizeStorageKey` is provided and exists in local storage,
* then this will be ignored in favor of the size stored in local storage.
*/
initialSize: number;
/**
* The maximum width the container can be resized to. Defaults to Infinity.
*/
maxWidth: number;
/**
* The minimum width the container can be resized to. Defaults to 100.
*/
minWidth: number;
/**
* The ref to the element to be resized.
*/
ref: RefObject<HTMLElement | null>;
/**
* Triggered when the user finishes dragging the resize handle.
*/
onResizeEnd?: (newWidth: number) => void;
/**
* Triggered when the user starts dragging the resize handle.
*/
onResizeStart?: () => void;
/**
* The local storage key used to persist the size of the container. If not provided,
* the size will not be persisted and the defaultWidth will be used.
*/
sizeStorageKey?: string;
}

interface UseResizableResult {
/**
* Whether the drag handle is held.
*/
isHeld: boolean;
/**
* Apply this to the drag handle element to include 'reset' functionality.
*/
onDoubleClick: () => void;
/**
* Attach this to the drag handle element's onMouseDown handler.
*/
onMouseDown: (e: React.MouseEvent) => void;
Comment on lines +44 to +51
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

react aria kinda does these buttonProps it might make sense to have a handleProps if we think they should be applied in a uniform way

/**
* The current size of the container. This is NOT updated during the drag
* event, only after the user finishes dragging.
*/
size: number;
}

/**
* Performant hook to support draggable container resizing.
*
* Currently only supports resizing width and not height.
*/
const useResizable = ({
ref,
initialSize,
maxWidth,
minWidth,
onResizeEnd,
onResizeStart,
sizeStorageKey,
}: UseResizableOptions): UseResizableResult => {
const [isHeld, setIsHeld] = useState(false);

const isDraggingRef = useRef<boolean>(false);
const startXRef = useRef<number>(0);
const startWidthRef = useRef<number>(0);

useEffect(() => {
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might make sense to be a useLayoutEffect so it doesn't appear as the smaller size then the bigger size

if (ref.current) {
const storedSize = sizeStorageKey
? parseInt(localStorage.getItem(sizeStorageKey) ?? '', 10)
: undefined;

ref.current.style.width = `${storedSize ?? initialSize}px`;
Copy link
Member

@scttcper scttcper May 13, 2025

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

i think whats difficult about using px values here is what if i resize it while i'm on my big monitor then open it again with a smaller window. could it be too large?

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

you might consider storing the localstorage value as a % and computing the px value

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

might depend on what maxValue is for these usecases

}
}, [ref, initialSize, sizeStorageKey]);

const handleMouseMove = useCallback(
(e: MouseEvent) => {
if (!isDraggingRef.current) return;

window.requestAnimationFrame(() => {
const deltaX = e.clientX - startXRef.current;
const newWidth = Math.max(
minWidth,
Math.min(maxWidth, startWidthRef.current + deltaX)
);

if (ref.current) {
ref.current.style.width = `${newWidth}px`;
}
});
},
[ref, minWidth, maxWidth]
);

const handleMouseUp = useCallback(() => {
setIsHeld(false);
const newSize = ref.current?.offsetWidth ?? initialSize;
isDraggingRef.current = false;
document.body.style.cursor = '';
document.body.style.userSelect = '';
onResizeEnd?.(newSize);
document.removeEventListener('mousemove', handleMouseMove);
document.removeEventListener('mouseup', handleMouseUp);
if (sizeStorageKey) {
localStorage.setItem(sizeStorageKey, newSize.toString());
}
}, [handleMouseMove, onResizeEnd, ref, sizeStorageKey, initialSize]);

const handleMouseDown = useCallback(
(e: React.MouseEvent) => {
setIsHeld(true);
e.preventDefault();

const currentWidth = ref.current
? parseInt(getComputedStyle(ref.current).width, 10)
: 0;

isDraggingRef.current = true;
startXRef.current = e.clientX;
startWidthRef.current = currentWidth;

document.body.style.cursor = 'ew-resize';
document.body.style.userSelect = 'none';
document.addEventListener('mousemove', handleMouseMove);
document.addEventListener('mouseup', handleMouseUp);
onResizeStart?.();
},
[ref, onResizeStart, handleMouseMove, handleMouseUp]
);

const onDoubleClick = useCallback(() => {
if (ref.current) {
ref.current.style.width = `${initialSize}px`;
}
}, [ref, initialSize]);

return {
isHeld,
size: ref.current?.offsetWidth ?? initialSize,
onMouseDown: handleMouseDown,
onDoubleClick,
};
};

export default useResizable;
Loading