I noticed a performance issue with a piece of work a colleague committed the other day. It’s a React component to display a table of data. When scrolling, once the header of the table hits the top of the viewport, the header becomes fixed in place, so it doesn’t scroll off the top of the page. When the table contained a lot of data, switching from fixed to not fixed, or vice versa, caused noticeable lag.
The implementation attached “scroll” and “resize” event listeners to the window on componentDidMount, and removed them on componentWillUnmount. It looked something like this:
class MyFancyComponent extends Component {
constructor() {
super();
this.headerChecks = this.headerChecks.bind(this);
this.state = {
fixed: this.shouldBeFixed(),
};
}
componentDidMount() {
window.addEventListener('scroll', this.headerChecks);
window.addEventListener('resize', this.headerChecks);
}
componentWillUnmount() {
window.removeEventListener('scroll', this.headerChecks);
window.removeEventListener('resize', this.headerChecks);
}
render() {
return (
<Table>
<TableHeader fixed = { this.state.fixed }/>
<TableBody/>
</Table>
);
}
headerChecks() {
this.setState({ fixed: this.shouldBeFixed() });
}
shouldBeFixed() {
// Does some calculations and then returns a boolean
...
}
}
My first suggestion, without really investigating the code, was to make the event handlers passive. It turns out this was completely ineffective as it doesn’t work for scroll/resize events, but it’s still worth knowing about regardless. If you pass a third option to addEventListener/removeEventListener as an Object with “passive” set to “true”, then you’re telling the browser that you will definitely not be calling “preventDefault” on the event. That means that the browser doesn’t need to wait for your handler to finish before completing the action. This is useful for touch events, particularly on mobile, because you often want scrolling via touch to occur immediately, rather than after your handler has run:
element.addEventListener('touchstart', fn, { passive: true });
element.removeEventListener('touchstart', fn, { passive: true });
My second suggestion, was to just use requestAnimationFrame to smooth out the scroll handler so it doesn’t run more frequently than the page is painted:
componentDidMount() {
window.addEventListener('scroll', this.smoothHeaderChecks);
}
componentWillUnmount() {
window.removeEventListener('scroll', this.smoothHeaderChecks);
cancelAnimationFrame(this.animationFrame);
}
smoothHeaderChecks() {
cancelAnimationFrame(this.animationFrame);
this.animationFrame = requestAnimationFrame(this.headerChecks);
}
This didn’t fix the problem either. It probably helped performance a little, but the main bottleneck was still there. This also introduced a short delay between the header hitting the top of the viewport and it becoming fixed, meaning it jumped a bit when the threshold was passed. Nontheless, it is a useful technique to have in your box of tricks: There is no point rendering a component more frequently than the rate at which the page is painted.
After looking at the code a little, I noticed that setState was being called every time the event handler was being run. This is a bad idea. If you call “setState”, the entire component, including children, will be re-rendered (unless you prevent that with the shouldComponentUpdate life cycle method). You only want to call “setState” when the state actually needs changing:
headerchecks() {
const fixed = this.shouldBeFixed();
if (fixed !== this.state.fixed) this.setState({ fixed });
}
Now the table would only be re-rendered when the fixed state actually changed, rather than every time we scrolled slightly: potentially many times a second.
This helped. But there was still a moment of lag when switching between fixed and not fixed, or vice versa, when the table contained a lot of data. Then it occurred to me: Why are we re-rendering the entire table, instead of just the header? The ultimate fix is to just move the whole process, including state and the event listeners, inside of the “TableHeader” component, so it is only that component which is re-rendered when the fixed status changes. Alternatively, we could introduce a wrapper component around the TableHeader component which performs that task. The “shouldBeFixed” function did actually require some information about the Table it’s self in order to make it’s decision, but that information could just be passed down as props to the TableHeader component. Although he’s not done the relevant refactoring yet, I am pretty confident this will fix the problem.
After doing some more investigation, I came across an API I’d never heared of before called IntersectionObserver. What this API allows us to do is detect when an element intersects with the viewport, or another element. This is probably a much better API to use than listening for resize/scroll events, as it will only trigger our handler when the threshold is actually met, rather than whenever we scroll or resize.
“IntersectionObserver” is still a draft at the moment, but it is supported by Chrome, Edge, Opera and Android, is in development for Firefox, and there exists a polyfill for other browsers too:
componentDidMount() {
this.headerObserver = new IntersectionObserver(this.headerChecks);
this.headerObserver.observe(this.headerEl);
}
componentWillUnmount() {
this.headerObserver.disconnect();
}
render() {
return (
<Table>
<div ref = { el => (this.headerEl = el) }>
<TableHeader fixed = { this.state.fixed }/>
</div>
<TableBody/>
</Table>
);
}
Want to leave a tip?You can follow this Blog using RSS or Mastodon. To read more, visit my blog index.