This is an extremely interesting question, very chicken and egg, how can you measure the height of something that needs to be rendered in order to measure it, without the user seeing it flash up first. Poor solutions to this may involve rendering something off screen etc and measuring it, but that doesn't sit well with the concepts React promotes.
It can however be solved by understanding not only the component lifecycle, but how that fits in respect to the browser lifecycle.
Key to this are componentDidMount
and componentDidUpdate
. A common misconception is that these occur after the component has been rendered to the browser, not true, it fires just before that happens
render
will first reconcile the virtual DOM with anything you have asked to render (another misconception here - the virtual DOM is not actually a real or even shadow DOM, it is just a tree of JS objects representating the components and their DOM that the algorithm can derive diffs from - you cannot ascertain dynamic heights or interact with real DOM elements at this point).
If it senses that there are actual DOM mutations to be done then componentDidMount
/componentDidUpdate
now fires BEFORE actual browser rendering.
It will then perform the derived updates to the real DOM (effectively rendering
it to the browser DOM). Crucially though the browser lifecycle means that this rendered DOM does not yet get painted to the screen (ie it is not rendered
from the user's perspective), until the next tick when requestAnimationFrame
has had a chance to intercept it...
componentWillReceiveProps
|
V
shouldComponentUpdate
|
V
componentWillUpdate
|
V
render
|
V
componentDidUpdate
|
V
[BROWSER DOM rendered - can be interacted with programatically]
|
V
requestAnimationFrame callback
|
V
[BROWSER screen painted with updated DOM - can be seen and interacted by user]
So at this point in componentDidX
we have the opportunity to tell it to intercept the browser BEFORE it paints and yet we can interact with the real updated DOM and perform measurements! To do this we need to use window.requestAnimationFrame
which takes a callback function in which we can perform a measurement. As this is an async function it returns an id that you can use to cancel it much like setTimeout does (and we should remember to clean up any pending requests so from componentWillUnmount
).
We will also need a measurable parent element that does not get styles set on it, with a ref
stored so we can get at the DOM node to measure it. And a wrapper that will be animated in height.
Simplified without react-motion (but the outer div
can be wrapped in a Motion
and can plug the interpolation in to the dynamic style prop)...
// added type info for better clarity
constructor(props: SomeProps) {
super(props)
this.state = {
height: undefined
}
}
listRef: HTMLElement
bindListRef = (el: HTMLElement): void => {
this.listRef = el
}
rafId: number
interceptAndMeasure = (): void {
// use request animation frame to intercept and measure new DOM before the user sees it
this.rafId = window.requestAnimationFrame(this.onRaf)
}
onRaf = (): void => {
const height: number = this.listRef.getBoundingClientRect().height
// if we store this in the state - the screen will not get painted, as a new complete render lifecycle will ensue
// BEWARE you must have a guard condition against setting state unnecessarily here
// as it is triggered from within `componentDidX` which can if unchecked cause infinite re-render loops!
if (height !== this.state.height) this.setState({height})
}
render() {
const {data} = this.props
const {height} = this.state.height
// the ul will be pushed out to the height of its children, and we store a ref that we can measure it by
// the wrapper div can have its height dynamically set to the measured value (or an interpolated interim animated value!)
return (
<div style={height !== undefined ? {height: `${height}px`} : {}}>
<ul ref={this.bindListRef}>
{data.map((_, i) =>
<li key={i}>
{_.someMassiveText}
</li>
)}
</ul>
</div>
)
}
componentDidMount() {
this.interceptAndMeasure()
}
componentDidUpdate() {
this.interceptAndMeasure()
}
componentWillUnmount() {
// clean up in case unmounted while still pending
window.cancelAnimationFrame(this.rafId)
}
Of course this has been encountered before and as luck would have it you can skip having to worry about implementing it yourself and use the excellent react-collapse
package (https://github.com/nkbt/react-collapse) which uses react-motion
beneath the surface to do exactly this, and also handles many edge cases like nesting dynamic height etc
But before you do, I really urge you to at least give this a try yourself as it opens up a much deeper understanding of both the React and browser lifecycle, and will help with deeply interactive experiences you would have a hard time achieving otherwise