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
v16
何のために使うのか
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はそう起きえないので、今回はリクエストごとに、ランダムな待ち時間を設定します。
最後にMichel's profile
をリクエストしたにも関わらず、John's Data
が表示されてることが確認できます。
このようにリスポンンスが返される順序によってユーザーに表示される結果が変わることはuserにとって好ましくありません。
解決方法
主に二つあります。
- 複数のリクエストを許容してもいい場合、booleanを使ってuserが最後にリクエストしたprofileのみsetStateするように制御する。
- 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> ); };
person state
が変更される- useEffect[1]が実行されて
fakeFetch()[1]
が実行される fakeFetch()[1]
が完了する前に、person state
が変更される- useEffect[1]のclean-up関数が実行される
- useEffect[2]が実行されて
fakeFetch()[2]
が実行される - 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
- useEffectに非同期関数を設定する方法
- https://qiita.com/daishi/items/4423878a1cd7a0ab69eb
- useEffect 内の非同期処理で local state を変更するときの注意点 | hbsnow.dev
- https://takamints.hatenablog.jp/entry/cleanup-an-async-use-effect-hook-of-react-function-componet
- React useEffect cleanup: How and when to use it - DEV Community
- Demystifying useEffect’s clean-up function — Max Rozen
- Fixing Race Conditions in React with useEffect — Max Rozen
- React初心者がReact Hooksの機能についてまとめてみた - ひろろの思うがままに。
- 副作用フックの利用法 – React
- Aborting/Cancelling requests with Fetch or Axios | by Abhimanyu Chauhan | DataDrivenInvestor