In the past few days I was investigating a really tricky issue in one of our React components. I decided to cover it here because I've found the problem interesting and, surprisingly, easy to fix after finding out the root cause.
First, it's nice to outline a little bit about the component itself. It's a really simple react component that does the following things:
<script>
tag on the page to load anexternal dependency 2. When this script is loaded, instantiate a new instance of this external dependency 3. Renders correctly both when script is loaded and when it isn't 4. When removing the component, destroys the external dependency instance
So, that said, lets jump into the parts that make it happen.
First, we have the script loader function, which is pretty straightforward:
function addScriptToPage(callback, src) {
const script = document.createElement('script');
script.defer = true;
script.onload = callback;
script.src = src;
document.body.appendChild(script);
}
The component itself is also pretty straightforward:
class TheComponent extends Component {
constructor(props) {
super(props);
this.state = {
externalDependency: null,
};
}
componentWillMount() {
addScriptToPage(() => {
const externalDependency = new ExternalDependency();
externalDependency.create();
this.setState({
externalDependency,
});
}, 'external.js');
}
componentWillUnmount() {
const { externalDependency } = this.state;
externalDependency && externalDependency.destroy();
}
render() {
const { externalDependency } = this.state;
const value = externalDependency
? externalDependency.doStuff()
: 'NOT LOADED YET';
return <h1>External value: {value}</h1>;
}
}
class App extends Component {
constructor(props) {
super(props);
this.state = {
hide: false,
};
}
componentWillMount() {
setTimeout(() => {
this.setState(() => ({
hide: true,
}));
}, 0);
}
render() {
if (this.state.hide) {
return null;
}
return <TheComponent />;
}
}
The external dependency, for all the purposes, is just a library. In our case, the real one listens to some events and some other stuff. For this post, consider the following external dependency:
function ExternalDependency() {
return {
create: () => {
window.EXTERNAL = 'EXTERNAL';
},
doStuff: () => {
return 10;
},
destroy: () => {
delete window.EXTERNAL;
},
};
}
The problem given was the following:
When we include the component on the page and, right afterwards remove it from the page, the component throws an error and the page stops working.
Alongside with the error description, we also had the following log:
TypeError: this.state.externalDependency is null
So, in order to test this problem, we need to simulate this "right afterwards" behavior. To do so, we'll render the component on the page, set a timeout (a short one) and remove the component from the screen.
Consider the following code:
class App extends Component {
constructor(props) {
super(props);
this.state = {
hide: false,
};
}
componentWillMount() {
setTimeout(() => {
this.setState(() => ({
hide: true,
}));
}, 0);
}
render() {
if (this.state.hide) {
return null;
}
return <TheComponent />;
}
}
Disclaimer: now that I'm explaining this in a blog post, the problem seems obvious to me. I think this is because the code shown here is really simplified. The real code has some messy parts, some reducers and lots of code that only made the investigation progress harder.
Looking at the code, we can see that the only place where
this.state.externalDependency
can cause this error is inside
componentWillUnmount
, because it starts as null
. So, what happens is that
the script hasn't finished loading and we're trying to destroy the instance
(that doesn't exist yet).
So, it might be tempting to do the following:
const { externalDependency } = this.state;
if (externalDependency) {
externalDependency.destroy();
}
It will make the page stop breaking, as now we don't have a TypeError
,
however, we will start having this error:
Warning: Can't perform a React state update on an unmounted component. This is a no-op, but it indicates a memory leak in your application. To fix, cancel all subscriptions and asynchronous tasks in the componentWillUnmount method.
You might think that it's only a Warning
and we're good to go, however, that's
not how things work. Now, we never call externalDependency.destroy()
, so,
we never clear the changes done by externalDependency
. In fact, if we now
check window.EXTERNAL
, we'll have it with the value "EXTERNAL"
.
window.EXTERNAL; // "EXTERNAL"
So, let's recap what's going on, before exploring the solution:
external.js
is created on the page, a callback is
registered to, when the script loads, instantiate an instance of
ExternalDependency
componentWillUnmount
is triggered (not calling
externalDependency.destroy()
, as it wasn't instantiated so far)2.
is executedsetState
in an unmounted component, which explains
the error beforeWhile it can be tempting to put something on componentWillUnmount
, like a
setState
or so, it is not correct and also doesn't make sense.
componentWillUnmount
is called when the component is just about to be removed,
there's no "hey React, wait a little bit so I can make some stuff here
and I let you know when you can remove me". Also, setting a state on a component
to be unmounted doesn't make sense, as this component is being removed, it will
not render again nor go to any other lifecycle
methods.
One solution that I particularly don't like is to have an instance property in your component that allows you to know if the component is being unmounted. Something like that:
componentWillUnmount() {
this.isUnmounted = true;
/* ... remaining code ... */
}
Then, add a guard condition in your code to avoid the callback to run in this case:
addScriptToPage(() => {
if (this.isUnmounted) return
/* ... remaining code ... */
})
This works, however, imagine that in some new update, the React team decides to
clear all instance properties (our isUnmounted
included). We now have our
memory leak again. Basically, with this solution, we're relying on React to set
a property for us and set it in the correct moment, which gives us less control
of our code flow.
onload
CallbackOur final solution and, in my opinion, a most elegant one, is to have an option
to cancel the execution of our script loader. Our problem is that the onload
callback is being called after the component is unmounted, so, if we never call
this callback, we'll not have this problem.
In this case, we can do something like that:
function addScriptToPage(callback, src) {
const script = document.createElement('script');
script.src = src;
script.defer = true;
script.onload = callback;
document.body.appendChild(script);
return {
cancelOnLoad: () => script.onload = null,
};
}
Now, in our component, we need to hold a reference to this returned object when
calling addScriptToPage
:
componentWillMount() {
const scriptLoader = addScriptToPage(() => {/* remaining code */});
this.setState({ scriptLoader });
}
Then, when unmounting, we just call cancelOnLoad
:
componentWillUnmount() {
/* remaining code */
this.state.scriptLoader.cancelOnLoad();
}
This is a lot better, as now we manage this flow. For example, it's also possible to remove the previously defined script (and save some bytes of our users data):
return {
cancelOnLoad: () => {
script.onload = null;
document.body.removeChild(script);
},
};
I think this problem was really interesting to show me how a simple script loader can become a hard problem to debug if we don't think correctly in the flow which our code is executed.
Also, always remember to cancel/clear stuff, specially the async ones, to avoid having those kind of problems.