Setting The State Based On Rendered DOM Elements In ReactJS
The other day, I ran into a really interesting situation in ReactJS. Normally, when I think about React, I think about the state of the component driving the virtual DOM (Document Object Model) and, subsequently, the physical DOM. But, I had situation in which I actually needed to update the state of the component based on the state of the rendered document. Solving this problem was not immediately obvious; and, it sent me into a few infinite loops and "Maximum call stack size exceeded" errors. But, eventually, I figured out how to leverage the setState() method as means to safely hook into the post-rendering phase of the component life-cycle.
Run this demo in my JavaScript Demos project on GitHub.
In this particular case, I had a container that needed to render a list of items. The number of items that I could render wasn't hard coded but, instead, driven by the physical dimensions of the container. As the container shrank, I had to reduce the number of rendered items; and, as the container expanded, I had to increase the number of rendered items.
I knew that I had to keep the "visual count" in the component state so that I could .slice() my items within the render() function (which can't know anything about the physical DOM). But, I didn't know how to actually obtain and set the visual count. My first thought was to put this calculation inside of the componentDidUpdate() method so that I would recalculate it every time the virtual DOM was flushed to the physical DOM.
This idea ended up leading to an infinite loop and "Maximum call stack size exceeded" errors. The problem was that componentDidUpdate() queried the DOM, ran the calculation, and called setState(). Once setState() was called, ReactJS went to re-render the DOM and then called componentDidUpdate(). With ran the calculation and caused a re-rendering, ad infinitum until the JavaScript failed.
I probably could have jimmied the shouldComponentUpdate() method to help prevent the infinite loop; but, this felt like a hack on top of a hack. Ultimately, what I figured out was that the setState() method takes an optional second argument which is a callback that will be invoked after the state changes have been flushed to the DOM. This callback is very much like componentDidUpdate(); but, it only gets called once for a specific call to setState(). Now, instead of trying query the DOM after any arbitrary rendering, such as I was doing in componentDidUpdate(), I can query the DOM only after I know that it has been affected by relevant changes.
To see this in action, I've created a demo in which I have a horizontal container that renders a list of items. The container can be resized via CSS (full width vs. half width); or, it can be resized as the browser window is resized. In each case, I am providing a setState() callback that will allow me to hook into the post-render life-cycle where I can recalculate the number of elements that will fit in the container:
<!doctype html>
<html>
<head>
<meta charset="utf-8" />
<title>
Setting The State Based On Rendered DOM Elements In ReactJS
</title>
<link rel="stylesheet" type="text/css" href="./demo.css"></link>
</head>
<body>
<h1>
Setting The State Based On Rendered DOM Elements In ReactJS
</h1>
<div id="content">
<!-- App will be rendered here. -->
</div>
<!-- Load scripts. -->
<script type="text/javascript" src="../../vendor/reactjs/react-0.13.3.min.js"></script>
<script type="text/javascript" src="../../vendor/reactjs/JSXTransformer-0.13.3.js"></script>
<script type="text/jsx">
// I manage the root component.
var Demo = React.createClass({
// I return the initial state of the component.
getInitialState: function() {
for ( var items = [], i = 1 ; i <= 50 ; i++ ) {
items.push( i );
}
return({
items: items,
width: "full",
visibleCount: 0
});
},
// ---
// PUBLIC METHODS.
// ---
// Once the list element has been rendered, we can calculate how many items
// we can render in the room allotted.
calculateVisibleCount: function() {
// Get the available width of the rendered element.
var containerWidth = this.refs.items.getDOMNode().clientWidth;
// For the sake of simplicity, we're going to hard-code the width required
// to render one of the items (including the inter-item margin).
var itemWidth = ( 50 + 10 );
// Set the state that will be used in the render() method to determine
// how many items we can fit and how many items will have to be left in
// the "+N" teaser.
this.setState({
visibleCount: Math.floor( containerWidth / itemWidth )
});
},
// I get called once, on the client, when the component has been rendered
// in the DOM.
componentDidMount: function() {
// Keep track of window-resize event since we'll have to recalculate the
// number of items we can fit in the new window dimensions.
window.addEventListener( "resize", this.handleWindowResize );
// Now that the DOM has been rendered, we can inspect the dimensions.
this.calculateVisibleCount();
},
// I get call after changes to the virtual DOM are flushed to the physical DOM.
componentDidUpdate: function() {
// CAUTION: Do not try to call .setState() in this method. You will
// quickly find yourself in an infinite loop.
},
// I get called once right before the component is removed from the DOM.
componentWillUnmount: function() {
window.removeEventListener( "resize", this.handleWindowResize );
},
// I handle a click on the "set full width" link.
handleFullWidth: function( event ) {
this.setState(
{
width: "full"
},
// NOTE: The second argument is a callback that will be invoked when
// these state changes have been flushed to the DOM. This gives an
// opportunity to update the state based on the DOM changes without
// getting into an infinite loop (although, this will cause another
// call to render(), which is what we want).
this.calculateVisibleCount
);
},
// I handle a click on the "set half width" link.
handleHalfWidth: function( event ) {
this.setState(
{
width: "half"
},
// NOTE: The second argument is a callback that will be invoked when
// these state changes have been flushed to the DOM. This gives an
// opportunity to update the state based on the DOM changes without
// getting into an infinite loop (although, this will cause another
// call to render(), which is what we want).
this.calculateVisibleCount
);
},
// I handle window resize event.
handleWindowResize: function( event ) {
// Once the window is resized, it means the items container may have
// changed dimensions. As such, we may have to recalculate the number
// of items that can be rendered.
this.calculateVisibleCount();
},
// I return the virtual DOM based on the current state.
render: function() {
// Determine how many items we can render. If we don't have enough
// space to render all of the items, we have to account of the space
// requirements of the "teaser" as well.
if ( this.state.visibleCount < this.state.items.length ) {
var renderCount = ( this.state.visibleCount - 1 );
var overflowCount = ( this.state.items.length - renderCount );
var teaser = (
<span key={ "teaser" } className="item teaser">
+{ overflowCount }
</span>
);
} else {
var renderCount = this.visibleCount;
var teaser = null;
}
// Map the items onto react elements.
var items = this.state.items
.slice( 0, renderCount )
.map(
function operator( id ) {
return( <span key={ id } className="item">{ id }</span> );
}
)
;
return(
<div>
<p>
{ "Items: " }
<a onClick={ this.handleFullWidth }>Full width</a>
{ " or " }
<a onClick={ this.handleHalfWidth }>Half width</a>
</p>
<div ref="list" className={ ( "list " + this.state.width ) }>
<div ref="items" className="items">
{ items }
{ teaser }
</div>
</div>
<p>
Showing { renderCount } of { this.state.items.length } items.
</p>
</div>
);
}
});
// --------------------------------------------------------------------------- //
// --------------------------------------------------------------------------- //
// Render the root Demo and mount it inside the given element.
React.render( <Demo />, document.getElementById( "content" ) );
</script>
</body>
</html>
As you can see, I am providing the calculateVisibleCount() method as the setState() callback. This way, whenever I set state values that I know will affect the container dimensions, I know that I'll be able to query those container dimensions once they have been updated. When I run this page, the items fit nicely within the container:
In all cases, the state of the ReactJS component is what drives the rendering of the physical DOM. But, in some cases, that relationship becomes a bit bi-directional in that the state of the component must be, in part, defined by the state of the physical DOM. In such cases, it's awesome that the setState() method provides a hook into the post-rendering life-cycle of the virtual DOM reconciliation.
Want to use code from this post? Check out the license.
Reader Comments
Some days, someone just perfectly solves your problem for you. I needed to figure out how to set a class on an element if its child exceeded its height (if it overflowed, on other words), and this was a perfectly clear explanation of how to do exactly what I needed. Thanks for taking the time to share it.