Post category: Articles

Aaron Beppu •

Adventures in React Performance Debugging

Recently I read Benchling’s 2-part series in debugging performance issues in React, and it really echoed the issues and solutions that I’ve been working through on the Sift Science Console. So I was inspired to chime in with some of my own React performance debugging experiences in what may become a short series itself. The first theme that I’d like to touch on is that although we love using React for its one-way data flow, its easy architecture, and its ever-enlarging community, the browser really doesn’t give a &^@# what you use. You still need to play nicely within it, even if that means not doing things the ‘React way’ in your components. In this post, I’ll highlight how a previous version one of our components that was very clean and concise introduced some subtle performance issues, and how an uglier solution became, in fact, the better one. Because browsers DGAF.

Slidable

Our slidable component is responsible for taking content of unknown (auto) height and revealing it with a slide-down animation. As it gets new content, it slides to the new content’s measured, but still auto, height. Here’s a simplified version:

import _ from 'underscore' ; import React from 'react' ; const propTypes = { onChangeHeight : React . PropTypes . func , updateTriggerCondition : React . PropTypes . any , transitionDuration : React . PropTypes . number }; export default class Slidable extends React . Component { constructor () { super (); this . state = { height : null , prevChildren : null , prevHeight : 0 , transitioning : false }; } componentWillReceiveProps ( nextProps ) { if ( this . props . updateTriggerCondition !== nextProps . updateTriggerCondition ) { this . setState ({ prevChildren : React . cloneWithProps ( this . props . children ), prevHeight : React . findDOMNode ( this . refs . content ). offsetHeight , transitioning : false }); } } componentDidUpdate () { var contentHeight = React . findDOMNode ( this . refs . content ). offsetHeight ; if ( contentHeight !== this . state . prevHeight && ! this . state . transitioning ) { this . setState ({ height : this . state . prevHeight , transitioning : true }, () => { window . requestAnimationFrame (() => { window . setTimeout ( this . onTransitionEnd , this . props . transitionDuration ); this . setState ({ height : contentHeight }) }); }); } } onTransitionEnd () { this . setState ({ height : null , prevChildren : null }, this . props . onChangeHeight ); } render () { var contentStyle = { position : ( this . state . height === null ) ? 'relative' : 'absolute' }, containerStyle = { height : ( this . state . height === null ) ? 'auto' : this . state . height , transition : `height ${ this . props . transitionDuration } ms` }; return ( < div className = 'Slidable' > < div ref = 'container' style = { containerStyle } className = 'slidable-container' > < div ref = 'content' style = { contentStyle } className = 'slidable-content' > { this . props . children } < div className = 'previous-children' > { this . state . prevChildren } < /div> < /div> < /div> < /div> ); } } Slidable . propTypes = propTypes ;

Okay, that’s not so bad! A couple notes:

It’s really tough to determine when a component’s children have changed, so we use the updateTriggerCondition prop to explicitly tell our Slidable component that it should re-calculate the height of its new children. So every time a new updateTriggerCondition is changed, we set the current children as our Slidable state’s prevChildren so that the previous children are still visible during the animation to the new children’s height. The real magic then happens in componentDidUpdate : we calculate the new children’s height, set the height state back to the previous children’s height, wait a frame, and then set the new height state, triggering our sliding animation. In our production component, Slidable optionally uses a CSS animation (default), a requestAnimationFrame javascript animation, or a FLIP-style animation, but for simplicity, we’ll just consider the CSS animation here.

And here it is in action as the core of our Accordion component, revealing a list of ML signals for a user:



Okay, it’s a gif, so…not the best quality and probably not at 60fps.

This feels pretty React-y—we’re cycling through new children as they are passed in and are updating our height accordingly as state. Previous children are also kept ephemerally as state while the height adjusts. It seems to perform pretty well…or does it?

A timeline of closing the accordion above looks like this:

Huh. That’s a lot of work going on after the initial click. For our users to have a good experience, we should be able to finish all our javascript within 100ms of the click (that’s the R in RAIL), and it looks like we’re not close to meeting that right now. Part of the issue is that this is in development, and we’re spending a lot of time validating propTypes – something we don’t do in production. But what about that animation frame stack trace after the click? And what’s with those forced layouts?

Well, the problem actually lies within our render method:

< div className = 'Slidable' > < div ref = 'container' style = { containerStyle } className = 'slidable-container' > < div ref = 'content' style = { contentStyle } className = 'slidable-content' > { this . props . children } < div className = 'previous-children' > { this . state . prevChildren } < /div> < /div> < /div> < /div>

What I didn’t realize was that by rendering this.props.children and this.state.prevChildren in different parts of the DOM, we actually unmount and then remount them as one moves to the other—even though prevChildren is fleeting and is the exact same as the children being unmounted. For simple children with no or light work in componentWillUnmount/WillMount/DidMount , this isn’t very noticeable. But in our example above, each ML signal in the list can be drag-and-dropped and thus is absolutely positioned by running a series of .getBoundingClientRect() s after mounting. And now that work is being unnecessarily repeated.

The most apparent fix is to keep this.props.children in the same DOM position even as it becomes this.state.prevChildren , and simply alter the class of their containers (to adjust z-index and positioning). But to do that, we have to keep two sets of children in state as well as keep track of which one is current or which one is previous:

export default class Slidable extends React . Component { constructor ( props ) { this . state = { height : null , children0 : props . children , children1 : null , previousChildrenPosition : 1 , prevHeight : 0 , transitioning : false }; } componentWillReceiveProps ( nextProps ) { var newChildrenNumber = ( this . _arePreviousChildrenPositionOne ()) ? 1 : 0 , newPreviousChildrenPosition = ( this . _arePreviousChildrenPositionOne ()) ? 0 : 1 ; if ( this . props . updateTriggerCondition !== nextProps . updateTriggerCondition ) { this . setState ({ [ `children${ newChildrenNumber } ` ]: nextProps . children , previousChildrenPosition : newPreviousChildrenPosition , prevHeight : React . findDOMNode ( this . refs . content ). offsetHeight , transitioning : false }); } else { this . setState ({ [ `children${ newPreviousChildrenPosition } ` ]: nextProps . children }); } } onTransitionEnd () { var childrenNumberToRemove = ( this . _arePreviousChildrenPositionOne ()) ? 1 : 0 ; this . setState ({ [ `children${ childrenNumberToRemove } ` ]: null , height : null }, this . props . onChangeHeight ); } _arePreviousChildrenPositionOne () { return this . state . previousChildrenPosition === 1 ; } render () { return ( < div className = 'Slidable' > < div ref = 'container' style = { containerStyle } className = 'slidable-container' > < div ref = 'content' style = { contentStyle } className = 'slidable-content' > { } < div className = { ! this . _arePreviousChildrenPositionOne () ? 'previous-children' : '' } > { this . state . children0 } < /div> < div className = { this . _arePreviousChildrenPositionOne () ? 'previous-children' : '' } > { this . state . children1 } < /div> < /div> < /div> < /div> ); } }

Not only is this component tougher to read, it feels much further from the React philosophy of minimizing state, since we are never actually using props.children as props—-we immediately set them as state. Additionally, and quite confusingly, children0 and children1 have no semantic notion about which is the current or previous set of children. But Browser DGAF that we want to use React best practices in our components.

Browser DGAF.

Here is our new timeline:

No animation frame following the click handler. Beautiful!

Those red triangles, tho

Okay, now how do we also get rid of those forced layouts (denoted by the blocks with the red triangles in the upper right)? This one is easier to debug, because we can see by zooming in that it’s coming directly from the Slidable component itself:

And that points to the first line in our original componentDidUpdate :

var contentHeight = React . findDOMNode ( this . refs . content ). offsetHeight ;

which queries the height of the new content div on every update, causing a reflow. This brings me to my second piece of DGAF advice:

Browser DGAF about the DOM manipulation you want to put in your reusable components’ componentDidMount/Update methods.

So be careful! You’re never totally sure of all the ways these components will be used, and they can cause significant lags in performance. This one above was only 5ms, but there are two of them, and we actually have 10 of these ML-signal accordions per page. All of a sudden that’s an extra 100ms. Oops. What happens when the layout takes 20ms instead?

Wrapping that offsetHeight call in a conditional that only executes when updateTriggerCondition changes takes care of the second forced layout, and wrapping it in a requestAnimationFrame takes care of the first. Our very pyramid-y componentDidUpdate now looks like this:

componentDidUpdate ( prevProps ) { var contentHeight ; if ( this . props . updateTriggerCondition !== prevProps . updateTriggerCondition ) { window . requestAnimationFrame (() => { contentHeight = React . findDOMNode ( this . refs . content ). offsetHeight ; if ( contentHeight !== this . state . prevHeight && ! this . state . transitioning ) { this . setState ({ height : this . state . prevHeight , transitioning : true }, () => { window . requestAnimationFrame (() => { window . setTimeout ( this . onTransitionEnd , this . props . transitionDuration ); this . setState ({ height : contentHeight }) }); }); } }); } }

Here’s our final timeline—in a production build, just to prove what a difference leaving out prop validation makes:

That click work takes less than 20ms!

By the way, astute readers may see that our frame rate during the animation is well under 60fps. This is partly because, as mentioned above, we’re animating height with CSS, which means heavy repainting (and apparently style updating?) of everything below the expanding div. While not the point of this post, FLIP-ing your height animations would easily make this run 60fps. FLIP-ing is also pretty messy in React, and would make a great follow-up post in this series, so stay tuned!

And remember: Browser DGAF.

