Stoyan (@stoyanstefanov) is a former Facebook and Yahoo! engineer, writer ("JavaScript Patterns", "React: Up and Running"), speaker (JSConf, Velocity, Fronteers), toolmaker (Smush.it, YSlow 2.0) and a guitar hero wannabe.
Memory can leak in various ways. In any reasonably sized app, you can bet memory is leaking somewhere. The effects of these leaks can be benign or they can be annoying (sluggish app) or they can be devastating causing the browser to crash the tab on your app. That’s why it’s important to keep an eye on memory consumption and to adopt patterns that prevent leaks.
In this article we’ll explore two types: leaks cased by event listeners and by intervals/timeouts. I’ll use React as an example, but the basic concepts are applicable to any framework that deals with adding and removing DOM nodes from a page.
The basic app
Say you have a basic app that simply adds and removes child components, in this case the component called Snoopy
:
class App extends React.Component { constructor() { super(); this.state = { snoopy: null, }; } render() { return ( <div> <h1>This is an app</h1> {this.state.snoopy ? ( <div> <Snoopy /> <button onClick={() => this.setState({snoopy: false})}> Remove the Snoopy component </button> </div> ) : ( <button onClick={() => this.setState({snoopy: true})}> Add a Snoopy component </button> )} </div> ); } }
This is the class component syntax in React, but worry not, function components and hooks are coming up in a second.
So this app is simple enough, you click, Snoopy
is added to the app. You click again and it’s gone.
Snoopy
And what does Snoopy
do? It keeps track of keystrokes.
class Snoopy extends React.Component { constructor() { super(); this.state = { keys: [], }; } componentDidMount() { document.addEventListener('keydown', (e) => { const keys = [...this.state.keys]; keys.push(e.keyCode); console.log(e.keyCode); }); } render() { return ( <p> I am Snoopy. I have been snooping your keystrokes. <br />I am delighted in inform you that your console has a list of the key codes you pressed </p> ); } }
Nothing special, right? But what happens after Snoopy
is removed from the app? It keeps on keeping track of keystrokes. Even though Snoopy
is not in the app and there are no traces of it in the DOM tree, the even listener function is still in memory and still… snooping.
Big deal, eh? You add another Snoopy
instance and it still works as expected. Keep adding and removing it enough times and now you have a problem on your hands.
BTW, you can try all the code in this article here. And (what a concept!) view-source works too.
This was an example of a DOM event listener left unchecked. You just caused a memory leak. And there was no indication that anything went wrong, no error, not even a console warning.
What if the snooping also includes a time interval? Like this:
class Snoopy extends React.Component { constructor() { super(); this.state = { seconds: 0, keys: [], }; } componentDidMount() { // task 1 const i = setInterval(() => { const seconds = this.state.seconds + 1; this.setState({seconds}); console.info(seconds); }, 1000); // task 2 document.addEventListener('keydown', (e) => { const keys = [...this.state.keys]; keys.push(e.keyCode); this.setState({keys}); console.log(e.keyCode); }); } render() { return ( <p> I am Snoopy. I have been snooping your keystrokes for{' '} {this.state.seconds} seconds. <br />I am delighted in inform you that so far you have pressed keys with the following codes: <br /> {this.state.keys.join(', ')} </p> ); } }
Now when Snoopy is removed from the DOM, the setInterval
keeps on clicking and doing things. The function called by the setInterval is still alive and kicking in memory. Oh no, not another leak!
This example though touches the state in both leaked functions (note the calls to this.setState()
). As a result, when Snoopy is removed from the DOM (unmounted), React will spit a warning in the console. And hopefully the developer is looking at these warning, as they only show in development, not in production. So these warning may be easily missed in a reasonably complicated app that prints a lot to the console. And that’s assuming that the warning even show up, because, remember, if the leaked functions where not touching state, there will be no warning.
Fixing the leaks
The solution to these leaks it similar, it involves cleaning up after yourself. If you addEventListener
, you should removeEventListener
. If you setInterval/setTimeout
, you should clearInterval/clearTimeout
.
In the case of React and class components, this means using the componentWillUnmount()
lifecycle method. This will also require that the event listener is no longer an inline function. It will also require that the interval ID is stored somewhere where it can be retrieved to clear the interval.
Something like this:
class Snoopy extends React.Component { constructor() { super(); this.state = { seconds: 0, keys: [], }; this.keydownHandler = this.keydownHandler.bind(this); this.intervalID = null; } keydownHandler(e) { const keys = [...this.state.keys]; keys.push(e.keyCode); this.setState({keys}); console.log(e.keyCode); } componentDidMount() { // task 1 this.intervalID = setInterval(() => { const seconds = this.state.seconds + 1; this.setState({seconds}); console.info(seconds); }, 1000); // task 2 document.addEventListener('keydown', this.keydownHandler); } componentWillUnmount() { // task 1 cleanup clearInterval(this.intervalID); // task 2 cleanup document.removeEventListener('keydown', this.keydownHandler); } render() { return ( <p> I am Snoopy. I have been snooping your keystrokes for{' '} {this.state.seconds} seconds. <br />I am delighted in inform you that so far you have pressed keys with the following codes: <br /> {this.state.keys.join(', ')} </p> ); } }
So you have two taks setup in the mounting and two corresponding tasks cleaned up before unmounting. If the component is large-ish there could be a bunch of code in between the two separate tasks and their cleanup, so watch out. The other thing that is mildly annoying is that these tasks are unrelated but they still need to be bunched together in the same lifecycle methods. Hooks fix these two annoyances.
Same but with hooks
When using hooks, you need useEffect()
to setup these “effect” tasks as well as cleanup the mess the tasks create. The word “effect” means these are side effects of rendering the component. The main tasks of a component is to show something on the screen. Keeping time and keeping track of clicks are side effects of that main task. But I digress.
The patterns looks like so:
useEffect(() => { // set stuff up, like `componentDidMount()` return () => { // clean things up, like `componentWillUnmount()` }; }, []);
So here’s our Snoopy, now with hooks:
function Snoopy() { const [seconds, setSeconds] = useState(0); const [keys, setKeys] = useState([]); // task 1 useEffect(() => { const intervalID = setInterval(() => { setSeconds(seconds + 1); console.info(seconds); }, 1000); return () => { clearInterval(intervalID); }; }, [seconds, setSeconds]); // task 2 useEffect(() => { function keydownHandler(e) { const newkeys = [...keys]; newkeys.push(e.keyCode); setKeys(newkeys); console.log(e.keyCode); } document.addEventListener('keydown', keydownHandler); return () => { document.removeEventListener('keydown', keydownHandler); }; }, [keys, setKeys]); return ( <p> I am Snoopy. I have been snooping your keystrokes for {seconds}{' '} seconds. <br />I am delighted in inform you that so far you have pressed keys with the following codes: <br /> {keys.join(', ')} </p> ); }
The good things in terms of code readability are that unrelated tasks can live in their own worlds (a.k.a. useEffect()
calls) and also that the setup and tear down code lives right next to each other.
Parting words
Take care of memory leaks by adopting patterns that encourage cleaning up after yourself.
Memory leaks tend to build over time, both time the user spends on the page and the time you app is being developed and maintained. Don’t let the app rust. Don’t be like this big app (that shall rename nameless) that realized that there’s a problem with memory but finds it hard to plug the leaks. The solution? Even though all interactions are “Ajax-y”, an extra timeout refreshes the page every once in a while to reset all that’s going on.
Another tip: if you have a React app with class components, check the spelling of your componentWillUnmount
. Make sure it’s not componentWillUnMount
(worst offender in my experience), nor Wil (with one “l”), nor any of the creative ways “componet” can be misspelled. Because if the method is misspelled, there will be no warning and all the cleanup code is just dead weight. And, believe me, that happens to the best of us. So check now, facepalm later.
Parts of this article are lifted (with permission) from the book React: Up and Running, second edition, out December 7th, 2021.