# Investigating a JavaScript/React Performance Issue
I noticed a performance issue with a piece of work a colleague committed the
other day. It's a [React](https://react.dev/) component to display a table of
data. When scrolling, once the header of the table hits the top of the
[viewport](https://www.w3schools.com/css/css_rwd_viewport.asp), 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](https://facebook.github.io/react/docs/react-component.html#componentdidmount),
and removed them on
[componentWillUnmount](https://facebook.github.io/react/docs/react-component.html#componentwillunmount).
It looked something like this:
```javascript
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 (
);
}
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](https://developers.google.com/web/updates/2016/06/passive-event-listeners).
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:
```javascript
element.addEventListener('touchstart', fn, { passive: true });
element.removeEventListener('touchstart', fn, { passive: true });
```
My second suggestion, was to just use
[requestAnimationFrame](https://developer.mozilla.org/en-US/docs/Web/API/window/requestAnimationFrame)
to smooth out the scroll handler so it doesn't run more frequently than the page
is painted:
```javascript
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](https://facebook.github.io/react/docs/react-component.html#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](https://facebook.github.io/react/docs/react-component.html#shouldcomponentupdate)
life cycle method). You only want to call "setState" when the state actually
needs changing:
```javascript
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](https://developer.mozilla.org/en-US/docs/Web/API/Intersection_Observer_API).
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](https://caniuse.com/#search=intersectionobserver) by Chrome, Edge,
Opera and Android, is in development for Firefox, and there exists [a
polyfill](https://github.com/GoogleChromeLabs/intersection-observer) for other
browsers too:
```javascript
componentDidMount() {
this.headerObserver = new IntersectionObserver(this.headerChecks);
this.headerObserver.observe(this.headerEl);
}
componentWillUnmount() {
this.headerObserver.disconnect();
}
render() {
return (
);
}
```