あらすじ
画像を表示するコンポーネントで、非同期通信(Ajax, fetch)で取得したものをstateで管理するようにしたら怒られた。
Warning: Can't call setState (or forceUpdate) 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.
今回は2画面用意しました。
- 問題のコンポーネント
- 雑な画面
問題のコンポーネントのcomponentDidMount
が走った頃合いで、違う画面に行くとエラーが発生します。
そんな実装がこれです。
import React from "react";
import ReactDOM from "react-dom";
import { compose, withStateHandlers, lifecycle } from "recompose";
import { BrowserRouter as Router, Route, Link, Switch } from "react-router-dom";
import "./styles.css";
function getReactImage() {
// 3秒後にReactの画像返してくれる処理
return new Promise(resolve => {
setTimeout(() => {
resolve(
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xMS41IC0xMC4yMzE3NCAyMyAyMC40NjM0OCI+CiAgPHRpdGxlPlJlYWN0IExvZ288L3RpdGxlPgogIDxjaXJjbGUgY3g9IjAiIGN5PSIwIiByPSIyLjA1IiBmaWxsPSIjNjFkYWZiIi8+CiAgPGcgc3Ryb2tlPSIjNjFkYWZiIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiPgogICAgPGVsbGlwc2Ugcng9IjExIiByeT0iNC4yIi8+CiAgICA8ZWxsaXBzZSByeD0iMTEiIHJ5PSI0LjIiIHRyYW5zZm9ybT0icm90YXRlKDYwKSIvPgogICAgPGVsbGlwc2Ugcng9IjExIiByeT0iNC4yIiB0cmFuc2Zvcm09InJvdGF0ZSgxMjApIi8+CiAgPC9nPgo8L3N2Zz4K"
);
}, 3000);
});
}
function App(props) {
const { imageUrl, history } = props;
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<Link to="/sub">go Sub</Link>
<h2>Start editing to see some magic happen!</h2>
{imageUrl ? <img src={imageUrl} /> : <span>NO IMAGE</span>}
</div>
);
}
const enhance = compose(
withStateHandlers(
{
imageUrl: null
},
{
handleImageUrl: (state, props) => imageUrl => {
return { imageUrl };
}
}
),
lifecycle({
async componentDidMount() {
const { handleImageUrl } = this.props;
// 画像取得して、
const url = await getReactImage();
// 画像をstateにセット。
handleImageUrl(url);
}
})
);
const Main = enhance(props => <App {...props} />);
const rootElement = document.getElementById("root");
ReactDOM.render(
<Router basename="/">
<Switch>
<Route exact path="/" component={Main} />
<Route path="/sub" render={() => <Link to="/">go Main</Link>} />
</Switch>
</Router>,
rootElement
);
方針
コンポーネントが「アンマウント」される前に
- 非同期処理を中断する
- stateの更新をしないようにする
ざっくり解説
先ずは、 npm install cancellationtoken
する。
CancellationTokenはTC39のProposal: ECMAScript Cancellationに(おおむね)基づいて実装されたものです。
https://github.com/conradreuter/cancellationtoken
要は、「キャンセル状態」というものをstate管理するのでは無く、propsで購読するようにすれば良さそう。
問題のコンポーネントの上位層で、CancellationToken
を作成し、propsに流し込む。
問題のコンポーネントで、
-
token.isCancelled
を見て、stateの更新を止める。 -
componentWillUnmount
で、cancel()
する。
これで、アンマウントされた後の問題は解決したので、怒られなくなります。
実装
import React from "react";
import ReactDOM from "react-dom";
import { compose, withStateHandlers, lifecycle } from "recompose";
import { BrowserRouter as Router, Route, Link, Switch } from "react-router-dom";
import CancellationToken from "cancellationtoken";
import "./styles.css";
function getReactImage() {
return new Promise(resolve => {
setTimeout(() => {
resolve(
"data:image/svg+xml;base64,PHN2ZyB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9Ii0xMS41IC0xMC4yMzE3NCAyMyAyMC40NjM0OCI+CiAgPHRpdGxlPlJlYWN0IExvZ288L3RpdGxlPgogIDxjaXJjbGUgY3g9IjAiIGN5PSIwIiByPSIyLjA1IiBmaWxsPSIjNjFkYWZiIi8+CiAgPGcgc3Ryb2tlPSIjNjFkYWZiIiBzdHJva2Utd2lkdGg9IjEiIGZpbGw9Im5vbmUiPgogICAgPGVsbGlwc2Ugcng9IjExIiByeT0iNC4yIi8+CiAgICA8ZWxsaXBzZSByeD0iMTEiIHJ5PSI0LjIiIHRyYW5zZm9ybT0icm90YXRlKDYwKSIvPgogICAgPGVsbGlwc2Ugcng9IjExIiByeT0iNC4yIiB0cmFuc2Zvcm09InJvdGF0ZSgxMjApIi8+CiAgPC9nPgo8L3N2Zz4K"
);
}, 3000);
});
}
function App(props) {
const { imageUrl } = props;
return (
<div className="App">
<h1>Hello CodeSandbox</h1>
<Link to="/sub">go Sub</Link>
<h2>Start editing to see some magic happen!</h2>
{imageUrl ? <img src={imageUrl} /> : <span>NO IMAGE</span>}
</div>
);
}
const enhance = compose(
withStateHandlers(
{
imageUrl: null
},
{
handleImageUrl: (state, props) => imageUrl => {
return { imageUrl };
}
}
),
lifecycle({
async componentDidMount() {
const { handleImageUrl, token } = this.props;
const url = await getReactImage();
// `token.isCancelled `を見て、stateの更新を止める。
if (!token.isCancelled) {
handleImageUrl(url);
}
},
componentWillUnmount() {
// `componentWillUnmount`で、`cancel()`する。
this.props.cancel();
}
})
);
const Main = enhance(props => <App {...props} />);
const rootElement = document.getElementById("root");
ReactDOM.render(
<Router>
<Switch>
<Route
exact
path="/"
render={() => {
// 問題のコンポーネントの上位層で、`CancellationToken `を作成し、propsに流し込む。
const { cancel, token } = CancellationToken.create();
const { Provider, Consumer } = React.createContext();
// Provider, Consumerは予習しないとわからないかも。
// React「context API」を一筆書き。
// https://qiita.com/shsssskn/items/a107e98d1af0ea2c8b5c
return (
<Provider value={{ token, cancel }}>
<Consumer>
{({ token, cancel }) => <Main token={token} cancel={cancel} />}
</Consumer>
</Provider>
);
}}
/>
<Route path="/sub" render={() => <Link to="/">go Main</Link>} />
</Switch>
</Router>,
rootElement
);
あとがき
ECMAScript 2015のClassのプロパティにisMounted
を持つことで、今回の問題に対応できるが自分の好みではなかった。
class News extends Component {
isMounted = false;
...
componentDidMount() {
this.isMounted = true;
...
componentWillUnmount() {
this.isMounted = false;