clean-up関数を理解する【図解あり】

2021年6月27日

clean-up関数は知識としては理解していたけど、使う場面に出会わなかったのでそれがなんなのかピンと来ていませんでした。

実際、clean-up関数は使う場面はそんなに多くないのでUsecasesを例に解説してみました。

clean-up関数とは

useEffect内のCallbackが呼ばれるにからなず実行されるクリーンアップフェーズ。クラスコンポーネントでのcomponentWillUnmount / componentDidUpdateに相当する。

いつ実行されるのか

基本的には、useEffectのcallback関数が実行される前と理解してもらって問題ないと思います。

細かく言うと、v16とv17でclean-up関数が呼び出されるタイミングが違います。

あとコンポーネントがunmountするか、re-renderするかによっても挙動が異なりますが、ここでは深く触れないので図を見ていただいてuseEffectのcallback関数が実行される前ということを理解してもらったらいいと思います。

*図は「初回render〜初回useEffect~DOMに描画するところまで」を省略しています。

*左の図がunmountされるコンポーネントで、右の図がre-renderされるコンポーネントの挙動です

v17

clean-up-v17.jpg

v16

clean-up-v16.jpg

何のために使うのか

CASE1 : race conditionを回避するため

race conditionとは

二つ以上のリクエストが送られた際、どちらかのリクエストが最初に完了するかによって、アプリケーションが異なる結果を表示する状態を指します。

例えば以下のような、personが更新されると非同期関数が実行されるコードがあるとしましょう。

const [data, setData] = React.useState<string | undefined>(undefined); const [person, setPerson] = React.useState<string | null>(null); const mountRef = React.useRef(false); React.useEffect(() => { if (mountRef.current) { const fakeFetch = () => { return new Promise<string>((res) => { setTimeout(() => res(`${person}'s data`), Math.random() * 5000); }); }; fakeFetch().then((data) => setData(data)); } else { mountRef.current = true; } }, [person]); return ( <div className="App"> <button onClick={() => setPerson("John")}>Request John's profile</button> <button onClick={() => setPerson("Ken")}>Request Ken's profile</button> <button onClick={() => setPerson("Michel")}> Request Michel's profile </button> <h1>{person}</h1> <h2>{JSON.stringify(data)}</h2> </div> );

Request {name}'s profile を短い間隔で2回以上クリックしてみます。

普通はrace conditionsはそう起きえないので、今回はリクエストごとに、ランダムな待ち時間を設定します。

clean-up.gif

最後にMichel's profileをリクエストしたにも関わらず、John's Dataが表示されてることが確認できます。

このようにリスポンンスが返される順序によってユーザーに表示される結果が変わることはuserにとって好ましくありません。

解決方法

主に二つあります。

  1. 複数のリクエストを許容してもいい場合、booleanを使ってuserが最後にリクエストしたprofileのみsetStateするように制御する。
  2. Internet Explorerをサポートする必要がない場合は、AbortControllerを使用してリクエストをキャンセルします

boolean値を使った方法

次のコードを見てみましょう。

クリーンアップ関数の実行タイミングを思い出しながら、リクエストが短い間隔で2度行われた場合の挙動を確認してみましょう。

const App = () => { const [data, setData] = React.useState<string | undefined>(undefined); const [person, setPerson] = React.useState<string | null>(null); React.useEffect(() => { let active = true; const fakeFetch = () => { return new Promise<string>((res) => { setTimeout(() => res(`${person}'s data`), Math.random() * 5000); }); }; fakeFetch().then((fetchData) => { if (active) { // activeがtrueの時のみsetDataできる console.log("set fetch data"); setData(fetchData); } }); return () => { // clean-up関数 active = false; }; }, [person]); return ( <div className="App"> <button onClick={() => setPerson("John")}>Request John's profile</button> <button onClick={() => setPerson("Ken")}>Request Ken's profile</button> <button onClick={() => setPerson("Michel")}> Request Michel's profile </button> <h1>{person}</h1> <h2>{JSON.stringify(data)}</h2> </div> ); };
  1. person stateが変更される
  2. useEffect[1]が実行されてfakeFetch()[1]が実行される
  3. fakeFetch()[1]が完了する前に、person stateが変更される
  4. useEffect[1]のclean-up関数が実行される
  5. useEffect[2]が実行されて fakeFetch()[2]が実行される
  6. useEffect[2]の結果がsetData()され、re-renderが実行、DOMに反映され、画面に表示されます。

activeのboolean値をステップごとに確認してみましよう

1〜2の間は、trueです。なのでuseEffect[1]のfakeFetch() 完了後setData()できます。

しかし、3によって再びrender関数が実行され、useEffect[1]のfakeFetch() 完了を待つにclean-up関数が実行され、falseが代入されます。これにより、useEffect[1]のfakeFetch()setData()を実行することができなくります。

5でuseEffect[2]のfakeFetch()が実行され、trueなので結果を setData()することができます。

これにより、最後のリクエストのみ画面に表示することができました。

AbortControllerを使った方法

次のコードを見てみましょう

useEffect(() => { const abortController = new AbortController(); const fetchData = async () => { setTimeout(async () => { try { const response = await fetch(`https://swapi.dev/api/people/${id}/`, { signal: abortController.signal, }); const newData = await response.json(); setFetchedId(id); setData(newData); } catch (error) { if (error.name === 'AbortError') { // Aborting a fetch throws an error // So we can't update state afterwards } // Handle other request errors here } }, Math.round(Math.random() * 12000)); }; fetchData(); return () => { abortController.abort(); }; }, [id]);

Boolean値を使った方法とやっていることはほぼ同じです。

異なる点は、リクエストをキャンセルしているのでこちらの方がユーザーにとってはエコですね。Internet Explorerを対応していないのであればこちらを使いたいですね。

Axiosを使っているのであれば、Cancel Tokenを使うこともできます

CASE2 : サブスクリプションやタイマーを解除する

こちらの方が、Usecaseとしては馴染みがあるのではないでしょうか。

次のような、友達のオンランステータスをsubscriptionするコンポーネントがあります

import React, { useState, useEffect } from 'react'; function FriendStatus(props) { const [isOnline, setIsOnline] = useState(null); useEffect(() => { function handleStatusChange(status) { setIsOnline(status.isOnline); } ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); }); if (isOnline === null) { return 'Loading...'; } return isOnline ? 'Online' : 'Offline'; }

このコードのままだと、コンポーネントがアンマウントした際におそらく次のようなwarningが表示されます

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 a useEffect cleanup function.

要するに、「購読や非同期処理はアンマウント時に全てキャンセルしてください、じゃないとメモリリークします」ということです。

では、言われた通り実装してみましょう。

import React, { useState, useEffect } from 'react'; function FriendStatus(props) { const [isOnline, setIsOnline] = useState(null); useEffect(() => { function handleStatusChange(status) { setIsOnline(status.isOnline); } ChatAPI.subscribeToFriendStatus(props.friend.id, handleStatusChange); // Specify how to clean up after this effect: return function cleanup() {//追加↓ ChatAPI.unsubscribeFromFriendStatus(props.friend.id, handleStatusChange); };//追加↑ }); if (isOnline === null) { return 'Loading...'; } return isOnline ? 'Online' : 'Offline'; }

Clean-up関数内で、購読を解除する処理を追加しました。

これでアンマウントしたコンポーネントのstateの更新を中止し、メモリリークせずにすみました。

次は、タイマーを解除しましょう。 やることは、先程のサブスクリプションと同様clean-up関数内で解除の処理を記載します。 

import React, { useState, useEffect } from "react"; export default function App() { const [count, setCount] = useState(0); useEffect(() => { const timeout = setTimeout(() => { setCount(1); }, 3000); return () => clearTimeout(timeout); },[]); return ( <div className="App"> <h1>{count}</h1> <button onClick={() => setCount(count + 1)}>Increment</button> </div> ); }

こちらも解除しないと、先程のwariningが表示されます。

終わり。

まとめ

ライフサイクルの図を理解して作るのに、半日かかった。

英語版も書きます。

Refs