DEV Community

Cover image for Merge different refs
Phuoc Nguyen
Phuoc Nguyen

Posted on • Originally published at phuoc.ng

Merge different refs

Remember when we talked about that awesome pattern for creating a custom hook that returns a callback ref? You can use this ref to attach to any element with the ref attribute. We even showed you how to use this pattern in real-life examples, like detecting clicks outside and creating draggable elements.

But here's the catch: when you use multiple hooks that return refs, things can get tricky. If you try to use the same ref for multiple elements or different refs for the same element, it can cause unexpected behavior and issues in your app.

For example, let's say you have two hooks: useDraggable and useResizable. The useDraggable hook makes an element draggable, while useResizable makes it resizable. Both hooks return a reference that you can attach to any element. But if you try to use both hooks on the same element with the same ref, you're asking for trouble.

const [ref1] = useDraggable();
const [ref2] = useResizable();
Enter fullscreen mode Exit fullscreen mode

The problem we're facing is how to create an element using all the different functionalities provided by various refs. We can't use the refs we've already returned for the same ref attribute of the target element.

Thankfully, we have a solution: merging refs. By merging multiple refs into a single callback function, we can pass it as a ref to our elements. This ensures that all of our hooks work together seamlessly and eliminates potential issues from using multiple refs.

To demonstrate the problem and solution, we'll create another hook to make an element resizable. Then, we'll use the previously mentioned useDraggable hook to make an element both draggable and resizable.

HTML markup

To make our element resizable, let's begin by organizing the markup. When it comes to resizing an element, we usually drag its corners or sides. To make it simple, we'll allow users to only drag the right and bottom sides of the element.

Here's how we can picture the element's layout:

<div className="resizable">
    <div className="resizer resizer--r" />
    <div className="resizer resizer--b" />
</div>
Enter fullscreen mode Exit fullscreen mode

We use the .resizable class to create a resizable container with a position: relative property. This allows the resizer elements to be positioned absolutely within it. The .resizer class creates the handles that let the user resize the element. It has a position: absolute property, which positions it inside the resizable container.

.resizable {
    position: relative;
}
.resizer {
    position: absolute;
}
Enter fullscreen mode Exit fullscreen mode

In this example, we only allow resizing from the right and bottom sides. Here's what the right resizer styles could look like:

.resizer--r {
    cursor: col-resize;

    right: 0;
    top: 50%;
    transform: translate(50%, -50%);

    height: 2rem;
    width: 0.25rem;
}
Enter fullscreen mode Exit fullscreen mode

The resizer--r class handles resizing the element from its right side. It sets the cursor to col-resize, which changes the mouse pointer to a horizontal line with arrows pointing left and right. This lets the user resize the element horizontally. The right property is set to 0, positioning it at the far right of the resizable element. The top property is set to 50%, and transform: translate(50%, -50%) centers it vertically. This ensures that it always appears in the middle of the right edge, regardless of the resizable element's height. Finally, its width is set to 0.25rem, making it thin enough that it doesn't take up too much space. Its height is set to 2rem, providing enough surface area for users to click and drag on.

Similarly, we can use the following CSS class to position another resizer indicator at the center of the bottom side:

.resizer--b {
    cursor: row-resize;

    bottom: 0;
    left: 50%;
    transform: translate(-50%, 50%);

    height: 0.25rem;
    width: 2rem;
}
Enter fullscreen mode Exit fullscreen mode

You can easily modify these classes by changing the CSS properties to support other resizer placements.

To enhance the user experience, we can add a cool hover effect to the resizable element. When the user hovers over the resizable element, we can make the resizer handles stand out by changing their background color. We can achieve this effect using CSS by setting the background of the .resizer class to transparent. Then, using the :hover pseudo-class on the .resizable container, we can change the background color of all .resizer elements within it to a nice shade of blue that pops against most backgrounds.

.resizer {
    background: transparent;
}
.resizable:hover .resizer {
    background: rgb(99 102 241);
}
Enter fullscreen mode Exit fullscreen mode

With these changes, users will know exactly which parts of the resizable element they can interact with and resize. To give you an idea of what it will look like, here's a preview of the layout without the actual resize functionality.

Making an element resizable

Before we dive into the details, it's recommended that you take a look at this post to learn how to create a custom hook that makes an element draggable.

To make an element resizable, we'll be using a similar approach. But first, let's review the common snippet that demonstrates how to develop a custom hook that returns a callback ref.

const useResizable = () => {
    const [node, setNode] = React.useState<HTMLElement>(null);

    const ref = React.useCallback((nodeEle) => {
        setNode(nodeEle);
    }, []);

    React.useEffect(() => {
        if (!node) {
            return;
        }
        // Do something with node ...
    }, [node]);

    return [ref];
};
Enter fullscreen mode Exit fullscreen mode

Next, we'll store the dimensions of the element using an internal state that has two properties: w for width and h for height. Initially, both properties are set to zero.

const [{ w, h }, setSize] = React.useState({
    w: 0,
    h: 0,
});
Enter fullscreen mode Exit fullscreen mode

When users click on a resizer, it triggers a mousedown event. We can handle this event and update the dimensions accordingly. Here's how we can handle the event:

const handleMouseDown = React.useCallback((e) => {
    const startX = e.clientX;
    const startY = e.clientY;

    const styles = window.getComputedStyle(node);
    const w = parseInt(styles.width, 10);
    const h = parseInt(styles.height, 10);

    const handleMouseMove = (e) => {
        const newDx = e.clientX - startX;
        const newDy = e.clientY - startY;
        setSize({
            w: w + newDx,
            h: h + newDy,
        });
    };
    const handleMouseUp = () => {
        document.removeEventListener("mousemove", handleMouseMove);
        document.removeEventListener("mouseup", handleMouseUp);
    };

    document.addEventListener("mousemove", handleMouseMove);
    document.addEventListener("mouseup", handleMouseUp);
}, [node]);
Enter fullscreen mode Exit fullscreen mode

When users click on a resizer element, the handleMouseDown function captures the current mouse coordinates using e.clientX and e.clientY. It then calculates the current width and height of the resizable element using window.getComputedStyle(node), where node is the target HTML element.

Next, we create two event listeners: one for when users move the mouse and another for when they release the mouse button.

When the user starts dragging a resizer, the handleMouseMove function is called. It calculates the new horizontal and vertical distances from the initial click position using e.clientX - startX and e.clientY - startY, respectively. These values represent how much the mouse has moved since the initial click.

Next, we add the distances we've calculated to the width or height of the resizable element. This gives us the new dimensions of the element. To apply this new size, we simply pass it to setSize, which updates our internal state with the new size.

The mouseup listener is crucial because it puts an end to the resizing of the element once we've finished dragging it. When the user releases the mouse button, we remove both event listeners by using document.removeEventListener. This restores normal scrolling and prevents any further resizing until the user clicks on the element again.

To update the width and height of the element, we can use the useEffect hook. This hook will be triggered when the state of our resizable element changes, allowing us to update its size.

To accomplish this, we can create a new effect that listens for changes in node, w, and h. Once all three values are set, we can set the width and height properties of the element using its style object.

React.useEffect(() => {
    if (node && w > 0 && h > 0) {
        node.style.width = `${w}px`;
        node.style.height = `${h}px`;
    }
}, [node, w, h]);
Enter fullscreen mode Exit fullscreen mode

This effect ensures that when a user resizes an element by dragging its handle, the element's size updates automatically. By keeping an eye on these values, we can keep our UI in sync with changes to our internal state.

To enable multiple resizers in a resizable container, we need to query the resizer elements and attach a mousedown event handler to each of them.

We can do this by using node.querySelectorAll(".resizer") inside the effect hook that gets called when the ref is set. This will give us an array-like object containing all elements with a class of .resizer. We then iterate over this array and attach a mousedown event listener to each element. Finally, it removes the event listeners when the user releases the mouse button.

Here's a sample code to give you an idea.

React.useEffect(() => {
    if (!node) {
        return;
    }
    const resizerElements = [...node.querySelectorAll(".resizer")];
    resizerElements.forEach((resizerEle) => {
        resizerEle.addEventListener("mousedown", handleMouseDown);
    });

    return () => {
        resizerElements.forEach((resizerEle) => {
            resizerEle.removeEventListener("mousedown", handleMouseDown);
        });
    };
}, [node]);
Enter fullscreen mode Exit fullscreen mode

Check out the demo below to see it in action: when you move the mouse to the left or bottom side of the element, a resize indicator will appear. Simply drag it to adjust the width or height of the element.

Making an element resizable and draggable

We now have two hooks that can make an element draggable or resizable. The useDraggable hook makes an element draggable, while the useResizable hook makes an element resizable.

Both hooks return a callback that you can use to implement these features on your element.

const [draggableRef] = useDraggable();
const [resizableRef] = useResizable();
Enter fullscreen mode Exit fullscreen mode

Making an element draggable or resizable is a breeze! Just use the corresponding ref as the ref attribute.

{/* Make the element draggable */}
<div ref={draggableRef}>...</div>

{/* Make the element resizable */}
<div ref={resizableRef}>...</div>
Enter fullscreen mode Exit fullscreen mode

If we want the element to have both functionalities, we need a way to merge these references together. Here's the function that will merge different references for us:

const mergeRefs = <T>(refs: Array<React.MutableRefObject<T> | React.LegacyRef<T> | null>): React.RefCallback<T> => {
    return (value) => {
        refs.forEach((ref) => {
            if (typeof ref === "function") {
                ref(value);
            } else if (ref != null) {
                (ref as React.MutableRefObject<T | null>).current = value;
            }
        });
    };
};
Enter fullscreen mode Exit fullscreen mode

The mergeRefs function is really handy when you want to combine multiple refs into one callback ref that you can use with an element. It's easy to use: just pass in an array of refs and it'll give you back a new callback ref function.

Here's how it works: when you call the returned function, it loops through each ref in the array and checks its type. If it's a callback ref of type function, it calls it with the value passed to the callback ref. Otherwise, if it's not null, it sets its current value (by accessing the current property) to the passed value.

By merging different refs together using mergeRefs, you can create a single ref that lets you drag and resize elements with ease.

const ref = mergeRefs([draggableRef, resizableRef]);

{/* Make the element draggable and resizable */}
<div ref={ref}>...</div>
Enter fullscreen mode Exit fullscreen mode

It's important to note that the mergeRefs function accepts both the callback refs and the refs created by the useRef() hook. This means that if you want to do more with the target element, you can merge all of the refs together like this:

const ref = React.useRef();

const finalRef = mergeRefs([ref, draggableRef, resizableRef]);

// Render
<div ref={finalRef}>...</div>
Enter fullscreen mode Exit fullscreen mode

Check out the final demo:

Conclusion

Merging different refs is a powerful technique that simplifies our code and makes it more efficient. With mergeRefs, we can combine multiple refs into a single callback ref that can be used with an element. This means we can add features like drag-and-drop and resizing to our UI components without managing separate refs for each feature.

Not only does this simplify our code, but it also helps us avoid potential bugs that can happen when using multiple refs at once. With a single callback ref, we ensure all the features we've added to an element work seamlessly together.

Merging different refs is a great way to improve the performance and reliability of our React applications. Whether you're building a simple app or a complex UI component library, this technique is worth considering for your next project.

See also


It's highly recommended that you visit the original post to play with the interactive demos.

If you found this series helpful, please consider giving the repository a star on GitHub or sharing the post on your favorite social networks 😍. Your support would mean a lot to me!

If you want more helpful content like this, feel free to follow me:

Top comments (0)