はじめに
- 最近、周囲から「useEffectでバグった」という声を聞くことが増えました。そこで、今までなんとなくで使っていた
useEffect
とは何かを正しく理解したいと思い記事を書くに至りました。 - この記事はReactの再レンダリングの仕組み(propsやstateが変更される度に再レンダリングされる)を理解している方向けです。
環境
- TypeScript: 4.9.3
- React: 18.2.0
そもそもuseEffectって?
公式ドキュメントには以下のように記載されていました
https://beta.reactjs.org/learn/synchronizing-with-effects
一部のコンポーネントは、外部システムと同期する必要があります。たとえば、React の状態に基づいて非 React コンポーネントを制御したり、サーバー接続をセットアップしたり、コンポーネントが画面に表示されたときに分析ログを送信したりすることができます。エフェクトを使用すると、レンダリング後にコードを実行できるため、コンポーネントを React 外部のシステムと同期させることができます。
正確には useEffect
で取り扱う「エフェクト」という概念についての説明のようですが、要は「レンダリング後に外部システムとの同期などを行うことが出来るもの」のようです。
おそらく、この記事を読んでいる方は一度は「レンダリング時にデータをフェッチして表示する」的なことをやったことがあると思いますので、そこまで違和感はないかなと思います。
しかし、私はこう思いました。「レンダリング時にデータをフェッチして表示する」だけであれば useEffect
なんて使わなくてもベタ書きすればいいのでは?と。
const wait = (t: number) =>
new Promise<number>((r) => setTimeout(() => r(t), t));
const fetchData = async () => {
await wait(1000);
const data = { test: 'test' };
return data;
};
const App = () => {
const [data, setData] = useState<unknown>(null);
fetchData().then((r) => {
setData(r);
});
console.log('render!', data);
return <div>{JSON.stringify(data, null, 2)}</div>;
};
こんな感じにデータをフェッチして、取得できたらstateに入れるだけです。実際に動かしてみましょう。
はい。まぁなんとなくそんな気がしていましたが無限ループになりました。Reactコンポーネントが再レンダリングされるタイミングは「propsやstateが変更された時」なので「フェッチ→setData→stateの変更を検知して再レンダリング→フェッチ…」の無限ループが発生してしまいました。
そこで useEffect
の出番です。 「フェッチ→setData」の部分を useEffect
の中でやるようにします。
const App = () => {
const [data, setData] = useState<unknown>(null);
console.log('render!', data);
useEffect(() => {
fetchData().then((r) => {
setData(r);
});
}, []);
return <div>{JSON.stringify(data, null, 2)}</div>;
};
はい。 StrictMode
の影響で2回実行されていますが、それ以上は再レンダリングされていないですね。
このことから useEffect
の本質は「レンダリング時にコードを実行するもの」ではなく「再レンダリング時に不要なコードの実行をスキップするもの」であると理解しました。
スキップするだけならuseEffectいらなくね?
前章の結果から useEffect
は「再レンダリング時に不要なコードの実行をスキップするもの」であるとしました。
で、ふと思いました。それだけなら別に useMemo
でよくね?と。 useMemo
は「再レンダリング時にコードの実行結果をできるだけキャッシュするもの」ですが本質的には useEffect
と同じはずです。
const App = () => {
const [data, setData] = useState<unknown>(null);
console.log('render!', data);
useMemo(() => {
fetchData().then((r) => {
setData(r);
});
}, []);
return <div>{JSON.stringify(data, null, 2)}</div>;
};
はい。なんかうまく動いていそうですね…。というわけで「再レンダリング時に不要なコードの実行をスキップする」だけであれば useEffect
は useMemo
で代用可能であることが分かりました。
それに、第2引数に依存のある値をセットすることで依存関係にある変数が変更された時には再実行させることが出来る(キャッシュ・スキップしない)点も同じです。
では useEffect
は何のために存在しているのかというと「クリーンアップ処理を指定できるか否か」にありそうです。
例として、以下は 「現在オンラインかどうか」を判定して表示するロジックです。(こちらの記事を参考にさせていただきました)
const App = () => {
const [isOnline, setIsOnline] = useState(true);
console.log('render!', isOnline);
useEffect(() => {
console.log('useEffect start!');
const listener = () => setIsOnline(navigator.onLine);
window.addEventListener('online', listener);
window.addEventListener('offline', listener);
// クリーンアップ処理
return () => {
console.log('cleanup!');
window.removeEventListener('online', listener);
window.removeEventListener('offline', listener);
};
}, []);
return <div>{isOnline.toString()}</div>;
};
クリーンアップ処理って何?という話になりますが「useEffectの実行前とコンポーネントのアンマウント時に実行されるもの」です。要は購読が不要になった時点でリスナーを破棄する処理などが必要な場合は処理(コールバック)をreturnすることでセットしておくことが出来るわけです。これは useMemo
には無い特性のため、ここが差別化ポイントになっていると理解しました。
クリーンアップ処理について
クリーンアップ処理が何故必要かは、以下の例(公式ドキュメントから一部コピー)を見ていただけると良いと思います。
この例は、入力 text
が変化するたびにランダムな時間にその時の入力値を出力するタイマーをセットするというものです。これによって、データフェッチにかかる時間にばらつきがある場合を再現しています。
const getRandomInt = (max: number) => Math.floor(Math.random() * max);
const App = () => {
const [text, setText] = useState('a');
useEffect(() => {
function onTimeout() {
console.log('⏰ ' + text);
}
console.log('🔵 Schedule "' + text + '" log');
const timeoutId = setTimeout(onTimeout, getRandomInt(3000));
return () => {
console.log('🟡 Cancel "' + text + '" log');
clearTimeout(timeoutId);
};
}, [text]);
return (
<>
<label>
What to log:{' '}
<input value={text} onChange={(e) => setText(e.target.value)} />
</label>
<h1>{text}</h1>
</>
);
};
試しに何度か文字を打ってみると、入力が変わった際にクリーンアップ処理が発火してタイマーをキャンセルして、最後の入力の結果のみが出力されていることが確認できると思います。
しかし、クリーンアップ処理をコメントアウトするとどうのようになるでしょうか?
最後に入力されたのは abc
であるのにも関わらず、最後に出力されたのは ab
でした。クリーンアップ処理が正しく無いと、ユーザーの最新の入力と結果がズレてしまうというバグが発生する恐れがあることが分かりますね。
外部のstoreを購読する場合
スキップするだけならuseEffectいらなくね? で登場した「オンラインかどうかを判定する例」で useEffect
を使っていましたが、どうやら専用のhooks useSyncExternalStore
が用意されており、そちらを使うことが推奨されているようです(こちらも例の参考にさせていただいた記事からの情報です)
const subscribeOnline = (onStoreChange: () => void) => {
console.log('subscribe start!');
window.addEventListener('online', onStoreChange);
window.addEventListener('offline', onStoreChange);
return () => {
console.log('cleanup!');
window.removeEventListener('online', onStoreChange);
window.removeEventListener('offline', onStoreChange);
};
};
const useOnline = () =>
useSyncExternalStore(subscribeOnline, () => navigator.onLine);
const App = () => {
const isOnline = useOnline();
console.log('render!', isOnline);
return <div>{isOnline.toString()}</div>;
};
うまく動くんですが、 初回の console.log
が出力されないのが謎ですね…(オンライン状態に変化があれば出る)
まとめ
-
useEffect
の本質は「レンダリング時にコードを実行するもの」ではなく「再レンダリング時に不要なコードの実行をスキップするもの」である - 正しいクリーンアップ処理によって潜在的なバグを防ぐことが出来る(
useMemo
との差別化ポイント) - 外部のstoreを購読する場合は
useSyncExternalStore
を使うことが推奨されている
最後に
「解体新書」を名乗れるほどの内容じゃなかった気がします…(今更)
ここまで読んでいただきありがとうございました。