Getting Size and Position of an Element in React
Introduction
Getting the size and position of elements in React is not a great story. Each option I researched has at least one gotcha. I’ll share the best options I came up with and explain pros and cons of each. First let’s take a look at the basic way to get an element’s size and position in React.
Getting the Size and Position
You can use Element.getClientRects()
and Element.getBoundingClientRect()
to get the size and position of an element. In React, you’ll first need to get a reference to that element. Here’s an example of how you might do that.
function RectangleComponent() {
return (
<div
ref={el => {
// el can be null - see https://reactjs.org/docs/refs-and-the-dom.html#caveats-with-callback-refs
if (!el) return;
console.log(el.getBoundingClientRect().width); // prints 200px
}}
style={{
display: "inline-block",
width: "200px",
height: "100px",
background: blue
}}
/>
);
}
This will print the element’s width to the console. This is what we expect because we set the width to 200px in style attribute.
The Problem
This basic approach will fail if the size or position of the element is dynamic, such as in the following scenarios.
- The element contains images and other resources which load asynchronously
- Animations
- Dynamic content
- Window resizing
These are all pretty obvious, right? Here’s a more sneaky scenario.
function ComponentWithTextChild() {
return (
<div
ref={el => {
if (!el) return;
console.log(el.getBoundingClientRect().width);
setTimeout(() => {
// usually prints a value that is larger than the first console.log
console.log("later", el.getBoundingClientRect().width);
});
setTimeout(() => {
// usually prints a value that is larger than the second console.log
console.log("way later", el.getBoundingClientRect().width);
}, 1000);
}}
style={{ display: "inline-block" }}
>
<div>Check it out, here is some text in a child element</div>
</div>
);
}
This example renders a simple div with a single text node as its only child. It logs out the width of that element immediately, then again in the next cycle of the event loop and a third time one second later. Since we only have static content, you might expect that the width would be the same at all three times, but it is not. When I ran this example on my computer, the first width was 304.21875
, the second time it was 353.125
and the third it was 358.078
.
Interestingly, this problem does not happen when we perform the same DOM manipulations with vanilla JS.
const div = document.createElement('div')
div.style.display = 'inline-block';
const p = document.createElement('p');
p.innerText = 'Hello world this is some text';
div.appendChild(p);
document.body.appendChild(div);
console.log('width after appending', div.getBoundingClientRect().width);
setTimeout(() => console.log('width after a tick', div.getBoundingClientRect().width));
setTimeout(() => console.log('width after a 100ms', div.getBoundingClientRect().width), 100);
If you paste this into a console, you will see that the initial width value is correct. Therefore our problem is specific to React.
Solution #1: Polling
A natural solution to this is to simply poll for size and position changes.
function ComponentThatPollsForWidth() {
return (
<div
ref={el => {
if (!el) return;
console.log("initial width", el.getBoundingClientRect().width);
let prevValue = JSON.stringify(el.getBoundingClientRect());
const start = Date.now();
const handle = setInterval(() => {
let nextValue = JSON.stringify(el.getBoundingClientRect());
if (nextValue === prevValue) {
clearInterval(handle);
console.log(
`width stopped changing in ${Date.now() - start}ms. final width:`,
el.getBoundingClientRect().width
);
} else {
prevValue = nextValue;
}
}, 100);
}}
style={{ display: "inline-block" }}
>
<div>Check it out, here is some text in a child element</div>
</div>
);
}
Here we can see the values changing over time and about how long it takes to get a final value. In my environment it was somewhere around 150ms on a full page refresh, though I’m rendering it in Storybook which might be adding some overhead.
Pros
- Simple
- Covers all use cases
Cons
- Inefficient - might drain battery on a mobile device
- Updates delayed up to the duration of the polling interval
Solution #2: ResizeObserver
ResizeObserver is a new-ish API that will notify us when the size of element changes.
Pros
- Efficient for browsers that support it
- Automatically get improved performance when other browsers add support
- Nice API
Cons
- Doesn’t provide position updates, only size
- Have to use a polyfill
Resources
Recommendations
- Embrace the fact that size and position will change. Don’t get them once in
componentDidMount
and expect them to be accurate. - Store the element’s size and position on your state. Then check for changes via ResizeObserver or polling depending on your needs.
- If you use polling, remember that updating state causes a render cycle even if your new values are the same as the old values. Therefore, check that the size or position actually changed before updating your state.
A Practical Example With Polling
For my own purposes, I need not only the size, but also the position of the element. Therefore ResizeObserver was ruled out and I had to go with a polling solution. Here is a more pactical example of how you might implement polling.
In this example we’re going to center an element within a container. I’m calling it a tooltip, but it is always visible.
class TooltipContainer extends React.Component {
constructor(props) {
super(props);
const defaultRect = { left: 0, width: 0 };
this.state = {
containerRect: defaultRect,
tooltipRect: defaultRect
};
this.containerRef = React.createRef();
this.tooltipRef = React.createRef();
this.getRectsInterval = undefined;
}
componentDidMount() {
this.getRectsInterval = setInterval(() => {
this.setState(state => {
const containerRect = this.containerRef.current.getBoundingClientRect();
return JSON.stringify(containerRect) !== JSON.stringify(state.containerRect) ? null : { containerRect };
});
this.setState(state => {
const tooltipRect = this.tooltipRef.current.getBoundingClientRect();
return JSON.stringify(tooltipRect) === JSON.stringify(state.tooltipRect) ? null : { tooltipRect };
});
}, 10);
}
componentWillUnmount() {
clearInterval(this.getRectsInterval);
}
render() {
const left = this.state.containerRect.left +
this.state.containerRect.width / 2 -
this.state.tooltipRect.width / 2 +
"px";
return (
<div
ref={this.containerRef}
style={{ display: "inline-block", position: "relative" }}
>
<span>Here is some text that will make the parent expand</span>
<img src="https://www.telegraph.co.uk/content/dam/pets/2017/01/06/1-JS117202740-yana-two-face-cat-news_trans_NvBQzQNjv4BqJNqHJA5DVIMqgv_1zKR2kxRY9bnFVTp4QZlQjJfe6H0.jpg?imwidth=450" />
<div
ref={this.tooltipRef}
style={{
background: "blue",
position: "absolute",
top: 0,
left
}}
>
Tooltip
</div>
</div>
);
}
}
Summary
I wish there was a perfect solution to this problem. I wish ResizeObserver was supported by all browsers and provided position updates. For now, I’m afraid you’re going to have to pick your poison.