関数コンポーネントのライフサイクル
Reactの関数コンポーネントにも、mount / unmount の概念が存在します。
useState など (公式の) hooksの戻り値は、react内部の配列のような構造で管理・配分された値です。もし本当にunmountされた後で使うと、Reactのdebug buildは↓のように警告してくれます。production版は試していないが、もし問題になると、このようなミスを探すのが難しいと予想します。
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.
async/await とリソース解放
無駄な計算とメモリリークを防止するために、コンポーネントによって配分された各種リソース (開始した非同期タスクなど) は、unmount以降早く解放すべき。
effectの中で割り当てられて、dispose()
などAPIを設けてくれたリソースならいいが、async/awaitの処理フローはそうではなく、Promiseオブジェクトが生成された時点からcancelもpauseもできない。
さらに、1箇所にawait使うと、伝染のように以降の処理のタイミング (とリソース配分) もpromiseに依存するようになる。
async/awaitを使わないで、非同期処理のフローをuseEffect()に合わせることはできなくはないが、awaitの回数によってロジック上連続する処理を複数のuseEffectに分けるようになってしまうため、私はそう書きたくない。
あるコード例
こんなコンポーネント作ることとします:
- あるボタンをクリックすると、signIn() API でログインして (このAPIはPromiseを返す)、返されたユーザ名をUIに表示する
- ↑ signIn() Promiseがfulfillした時点からの秒数をUIに表示する
version 1
const SignInTimerV1: React.FC = () => {
const [username, setUsername] = useState(null);
const [timeSinceLogin, setTimeSinceLogin] = useState(NaN);
const onClick = async () => {
const user = await signIn();
setUsername(user.name);
const timer = setInterval(() => {
setTimeSinceLogin(t => t+1000);
}, 1000);
};
return (
<div>
<button onClick={onClick} > signIn() </button>
<p>current user: { username } </p>
<p>time since sign in: {timeSinceLogin} </p>
</div>
);
}
もしsignIn() がfulfillした時点で すでにunmountされてると、setUsernameを呼ぶべきではない。そして、unmountされるときtimerを解放しないとメモリリークになる。
こんなhooks書いた
export function useLifeCycle() {
const l = useMemo(() => {
const mounted /* boolean */ = false;
const unmountCallback /* Function[] */ = [];
const mountCallback /* Function[] */ = [];
const unmounted /* Promise<void> */ = new Promise(fulfill => unmountCallback.push(fulfill));
return {
mounted,
unmountCallback,
mountCallback,
unmounted,
};
}, []);
useEffect(() => {
l.mounted = true;
for (const f of l.mountCallback) f();
return () => {
for (const f of l.unmountCallback) f();
l.mounted = false;
};
}, []);
return l;
}
- 呼び出し元のコンポーネントのライフサイクル状態 (mounted) を返す
- classコンポーネントのように、mount時点 (componentDidMount)、unmount時点 (componentWillUnmount) にコールバックを登録できる
version 2
const SignInTimerV2: React.FC = () => {
const lifecycle = useLifeCycle(); // changed
const [username, setUsername] = useState(null);
const [timeSinceLogin, setTimeSinceLogin] = useState(NaN);
const onClick = async () => {
const user = await signIn();
if (!lifecycle.mounted) return; // changed
setUsername(user.name);
const timer = setInterval(() => {
if (lifecycle.mounted) setTimeSinceLogin(t => t+1000); // changed
}, 1000);
lifecycle.unmounted.then(() => clearInterval(timer)); // changed
};
return (
<div>
<button onClick={onClick} > signIn() </button>
<p>current user: { username } </p>
<p>time since sign in: {timeSinceLogin} </p>
</div>
);
}