← Back toMaster of React ref

Merge different refs

Written byPhuoc Nguyen
Created
24 Oct, 2023
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.
ts
const [ref1] = useDraggable();
const [ref2] = useResizable();
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:
tsx
<div className="resizable">
<div className="resizer resizer--r" />
<div className="resizer resizer--b" />
</div>
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.
css
.resizable {
position: relative;
}
.resizer {
position: absolute;
}
In this example, we only allow resizing from the right and bottom sides. Here's what the right resizer styles could look like:
css
.resizer--r {
cursor: col-resize;

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

height: 2rem;
width: 0.25rem;
}
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:
css
.resizer--b {
cursor: row-resize;

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

height: 0.25rem;
width: 2rem;
}
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.
css
.resizer {
background: transparent;
}
.resizable:hover .resizer {
background: rgb(99 102 241);
}
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.
ts
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];
};
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.
ts
const [{ w, h }, setSize] = React.useState({
w: 0,
h: 0,
});
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:
ts
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]);
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.
ts
React.useEffect(() => {
if (node && w > 0 && h > 0) {
node.style.width = `${w}px`;
node.style.height = `${h}px`;
}
}, [node, w, h]);
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.
ts
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]);
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.
ts
const [draggableRef] = useDraggable();
const [resizableRef] = useResizable();
Making an element draggable or resizable is a breeze! Just use the corresponding ref as the `ref` attribute.
tsx
{/* Make the element draggable */}
<div ref={draggableRef}>...</div>

{/* Make the element resizable */}
<div ref={resizableRef}>...</div>
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:
ts
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;
}
});
};
};
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.
tsx
const ref = mergeRefs([draggableRef, resizableRef]);

{/* Make the element draggable and resizable */}
<div ref={ref}>...</div>
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:
tsx
const ref = React.useRef();

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

// Render
<div ref={finalRef}>...</div>
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

If you found this post 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!

Questions? 🙋

Do you have any questions about front-end development? If so, feel free to create a new issue on GitHub using the button below. I'm happy to help with any topic you'd like to learn more about, even beyond what's covered in this post.
While I have a long list of upcoming topics, I'm always eager to prioritize your questions and ideas for future content. Let's learn and grow together! Sharing knowledge is the best way to elevate ourselves 🥷.
Ask me questions

Recent posts ⚡

Newsletter 🔔

If you're into front-end technologies and you want to see more of the content I'm creating, then you might want to consider subscribing to my newsletter.
By subscribing, you'll be the first to know about new articles, products, and exclusive promotions.
Don't worry, I won't spam you. And if you ever change your mind, you can unsubscribe at any time.
Phước Nguyễn