なんとなくでuseEffectを使うのをやめたい。
はじめに
前回のuseStateに続いて、useEffectについてもReactドキュメントの解説をもとに学んでいきます。
useEffectとは?
useEffect は、コンポーネントを外部システムと同期させるための React フックです。
useEffect(setup, dependencies?)
引数にはエフェクト内で実行する関数と依存値を受け取り、undefinedを返します。
useEffectに渡す依存値は開発者が自ら選んで設定するものではなく、エフェクトのコードで使用されるすべてのリアクティブな値を宣言しなければなりません。
「useEffect リファレンス」はこちら
ドキュメントには以下のような記述があります。
エフェクトは「避難ハッチ」です。React の外に出る必要があり、かつ特定のユースケースに対してより良い組み込みのソリューションがない場合に使用します。エフェクトを手で何度も書く必要があることに気付いたら、通常それは、あなたのコンポーネントが依存する共通の振る舞いのためのカスタムフックを抽出する必要があるというサインです。
また、ドキュメントの「エフェクトは必要ないかもしれない」には、外部システムが関与していない場合はエフェクトは必要ないと断言されています。
外部システムが関与していない場合(例えば、props や state の変更に合わせてコンポーネントの state を更新したい場合)、エフェクトは必要ありません。
useEffectの依存値を空配列にすることでレンダリング時に1度だけ実行させる、useStateで管理している値や何らかの値の変更を検知して関数を実行するといったものはNGのようです。
このような不要なuseEffectを削除することで、コードの可読性や実行速度、エラーの発生予防に繋がることになります。
useEffectの注意点と使用例
useEffectを使う上での注意点、使用例はリファレンスに記載されています。
Reactコンポーネント内のロジックについて
エフェクトの説明の前にReactコンポーネント内の2種類のロジックを理解しておく必要があるようです。
-
レンダーコード(UI の記述で説明)
コンポーネントのトップレベルにあるもので、受け取ったpropsやstateを変換してJSXを返す場所。数学の式のように結果を計算するだけで他のことは行わない。 -
イベントハンドラ(インタラクティビティの追加で説明)
コンポーネント内にネストされた関数であり、計算だけでなく何かを実行するもの。(入力フィールドの更新、HTTP POSTリクエスト、画面遷移など)
特定のユーザアクションによって引き起こされる副作用が含まれる。
これらのロジックについても以下ドキュメントで詳しく説明されています。
エフェクトの書き方
エフェクトを書くにはReactからuseEffectフックをインポートし、コンポーネントのトップレベルで呼び出します。
import { useEffect } from 'react';
function MyComponent() {
useEffect(() => {
// Code here will run after *every* render
});
return <div />;
}
コンポーネントがレンダーされるたびにReactが画面を更新し、その後にuseEffect内のコードが実行するようです。
useEffect内のコードの実行タイミングは「画面が表示されたら」、「依存値が変更されたら」くらいのなんとなくしかわかっていなかったですが、レンダー結果が画面に反映されてから初めてコードが実行されるらしいです。
エフェクトの例
<video>
タグを使ったエフェクトの例を見てみます。
function VideoPlayer({ src, isPlaying }) {
// TODO: do something with isPlaying
return <video src={src} />;
}
この例ではvideoタグのplay()
やpause()
メソッドを手動で呼び出し、isPlaying
と同期させる必要があるため、<video>
DOM ノードへのref
を取得します。
ここで注意しなければいけないのは以下の2点です。
- Reactは「レンダーはJSXの純粋な関数であるべき」であるため、DOMの変更のような副作用を含んではいけない
- VideoPlayerが初めて呼び出されるときはDOM自体が存在していないため、JSXが返されるまでは
play()
やpause()
を呼び出すためのDOMノードは存在していない
このようなときに副作用をuseEffect
でラップして、レンダーの計算処理の外に出すことで解決することができます。
こうすることでplay()
やpause()
はReactが画面を更新するまで実行されず、更新が完了したら実行されるようになります。
import { useEffect, useRef } from 'react';
function VideoPlayer({ src, isPlaying }) {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
}
デフォルトではエフェクトは全てのレンダー後に実行されてしまうため、useEffect
の第二引数に依存値の配列を指定する必要があります。
依存値を指定することで不必要な再実行をスキップするようReactに指示することができます。
useEffect(() => {
if (isPlaying) { // It's used here...
// ...
} else {
// ...
}
}, [isPlaying]); // ...so it must be declared here!
こうすることでisPlaying
に変更がなければエフェクトの再実行をスキップさせることができます。
なお、依存値が正しく指定されていないとReact Hook useEffect has a missing dependency: 'isPlaying'
というエラーが表示されるため、エフェクト内のコードの依存関係を正しく指定する必要があります。
依存値のエラーをコメントで無視することが多々あるのですが、依存配列がコードと一致しない場合、バグが発生するリスクが非常に高くなるとのことでした。
このリンタを止めてしまうと、見つけたり修正したりするのが難しい、非常に分かりづらいバグの原因になります。
依存配列に関するリントエラーはコンパイルエラーとして扱うことをお勧めします。
以下のような記述もあるため、useEffect
の依存値は正しく指定すべきものです。このあたりは今まで意識できていなかった部分ですので、useEffect
を使う前に本当に必要なのかも考える、useEffect
を使う時は依存配列に気をつけていきたいところ…。
依存値は自分で「選ぶ」ようなものではありません。エフェクト内のコードに基づいて React が期待する配列と、指定した依存配列が合致しない場合、リントエラーが発生します。これにより、コード内の多くのバグを検出することができます。一部のコードを再実行しない場合は、エフェクトのコード自体を編集して、その依存値が「必要」とならないようにします。
なお、依存配列がない場合と空の[]
という依存配列がある場合、依存配列が指定された場合の挙動は以下の通りです。
useEffect(() => {
// 毎回のレンダー後に実行される
});
useEffect(() => {
// マウント時(コンポーネント出現時)のみ実行される
}, []);
useEffect(() => {
// マウント時と、a か b の値が前回のレンダーより変わった場合に実行される
}, [a, b]);
※エフェクトはデフォルトでは毎回のレンダーの後に実行されるため次のようなコードは無限ループが発生します。
const [count, setCount] = useState(0);
useEffect(() => {
setCount(count + 1);
});
useState
の説明でもあったようにstate
の設定はレンダーをトリガーします。
この例ではエフェクトが実行→state
がセット→再レンダーが発生→エフェクトが実行→state
がセット→再レンダーが発生…ということになります。
このようなことが起きてしまうため、外部システムではなくstate
に基づいてstate
を調整するといった場合はエフェクトは必要ないとのことです。
実際の実装でも、ある値が依存値に入っていると無限ループが発生してしまうため、その値を依存値から削除するといったことをやってしまいがちですがエフェクト内に外部システムがない場合はエフェクト自体を削除した方が良いみたいです。
エフェクトの依存値に関しての詳細は以下にあります。
最後に必要に応じてクリーンアップする必要があるものはエフェクトからクリーンアップ関数を返すようにします。
以下の場合はコンポーネントが表示されている間は接続を維持し、コンポーネントがアンマウントされた時は接続を解除する必要があります。
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
開発環境だとエフェクト内のコードが2度実行されますが、これは開発環境の正しい動作でドキュメントにも以下のような記述があります。
コンポーネントの再マウントは、クリーンアップが必要なエフェクトを見つけるために開発中にのみ行われます。Strict Mode を外すことで、開発時専用のこの挙動をオフにすることができますが、オンにしておくことをお勧めします。これにより、上記のような多くのバグを見つけることができます。
開発環境での2回発生するエフェクトについての詳細は以下にあります。
データのフェッチ
クライアント側でデータを取得したい場合は以下のようにすると良いようです。
useEffect(() => {
let ignore = false;
async function startFetching() {
const json = await fetchTodos(userId);
if (!ignore) {
setTodos(json);
}
}
startFetching();
return () => {
ignore = true;
};
}, [userId]);
ただ、ドキュメントには以下のような記述もあり、フレームワークを使っている場合はそれらの組み込みのデータフェッチ機構を、それ以外の場合はTanStack QueryやuseSWR等を使用することが勧められています。
特に完全にクライアントサイドのアプリにおいては、エフェクトの中で fetch コールを書くことはデータフェッチの一般的な方法です。しかし、これは非常に手作業頼りのアプローチであり、大きな欠点があります。
上記の欠点は、マウント時にデータをフェッチするのであれば、React に限らずどのライブラリを使う場合でも当てはまる内容です。ルーティングと同様、データフェッチの実装も上手にやろうとすると一筋縄ではいきません。
また、現在Canaryですがuse
フックというものがあり、将来的にはこちらを使うことが推奨されるかもしれません。
アプリケーション初期化はエフェクトではない
アプリケーションの初期化のためのロジックを以下のように依存配列を[]
とすることで実行することがよくあるのですが、ドキュメントによると初期化のためのロジックのような一度だけ実行されるべきものはコンポーネントの外に置くことができるようです。
このように書くことでページ読み込み時に1度だけ実行されることが保証できるようです。
function App() {
useEffect(() => {
// 初期化のためのロジック
checkAuthToken();
loadDataFromLocalStorage();
}, [])
}
if (typeof window !== 'undefined') {
// 初期化のためのロジック
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
「エフェクトの書き方」にはこれらの他にも様々な事例が記載されていました。
不要なuseEffectの削除について
ドキュメントには以下の記述があり、基本的にはエフェクトは不要なようです。
エフェクトは、特定のイベントによってではなく、レンダー自体によって引き起こされる副作用を指定するためのものです。
慌ててエフェクトをコンポーネントに追加しないようにしましょう。エフェクトは通常、React のコードから「踏み出して」、何らかの外部システムと同期するために使用されるものだということを肝に銘じてください。これには、ブラウザ API、サードパーティのウィジェット、ネットワークなどが含まれます。エフェクトが他の state に基づいて state を調整しているだけの場合、おそらくそのエフェクトは必要ありません。
不要なuseEffectの削除についての具体例もあげられていますので見てみます。
useState
で管理しているfirstName
、lastName
が変更されたらuseEffect
内でフルネームを作成しています。
普段の実装でもやりがちですが、これだと古くなったfullName
で最後までレンダーされた直後に更新されたfullName
で再レンダーをやり直すことになるようでかなり非効率のようです。
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 Avoid: redundant state and unnecessary Effect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
この実装は以下のように。state変数とエフェクトを削除することができます。
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
}
fullName
は既にあるfirstName
、lastName
というstate
から計算可能なものであり、それらはエフェクトを使う必要はありません。
既存の props や state から計算できるものは、state に入れないでください。代わりに、レンダー中に計算します。これによりコードは(余分な「連動」更新処理が消えたことにより)高速になり、(コードを削減したことにより)簡潔になり、さらに(異なる state 変数が同期しなくなるバグを回避できたことにより)エラーも少なくなります。このアプローチになじみがない場合は、React の流儀で state に入れるべきものを説明しています。
-
計算の連鎖
あるstate
をもとに、state
を調整するようにエフェクトを書くこともよくやってしまうことですが、これも削除できるエフェクトのようです。
以下の実装を見てみます。
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
const [isGameOver, setIsGameOver] = useState(false);
// 🔴 Avoid: Chains of Effects that adjust the state solely to trigger each other
useEffect(() => {
if (card !== null && card.gold) {
setGoldCardCount(c => c + 1);
}
}, [card]);
useEffect(() => {
if (goldCardCount > 3) {
setRound(r => r + 1)
setGoldCardCount(0);
}
}, [goldCardCount]);
useEffect(() => {
if (round > 5) {
setIsGameOver(true);
}
}, [round]);
useEffect(() => {
alert('Good game!');
}, [isGameOver]);
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
} else {
setCard(nextCard);
}
}
// ...
この実装ではuseState
で管理しているcard
,goldCardCount
,round
,isGameOver
それぞれの変更を検知するエフェクトを書いており、エフェクト内でstate
を更新する処理を行っています。
問題点は以下の通りです。
- 非常に効率が悪い
エフェクト内の各セット関数が呼ばれるたびに毎回再レンダーする必要があり、この例では最悪の場合、setCard
→ レンダー →setGoldCardCount
→ レンダー →setRound
→ レンダー →setIsGameOver
→ レンダーの3回の不要な再レンダーが発生してしまいます。
再レンダーが直接アプリケーションの速度に影響しなかったとしても、開発が進むにつれて機能を追加しようとしたときにエフェクトの連鎖処理のなかで開発者が意図していない、予測できない挙動が出てくる可能性があり、良いコードとは言えないです。
このような場合、レンダー中に計算できる(既存のstate
やprops
から計算可能)ものはレンダー中に行い、イベントハンドラでstateの調整を行うと良いようです。
function Game() {
const [card, setCard] = useState(null);
const [goldCardCount, setGoldCardCount] = useState(0);
const [round, setRound] = useState(1);
// ✅ ゲームオーバーはroundから判定可能なためレンダー中に計算してしまう
const isGameOver = round > 5;
function handlePlaceCard(nextCard) {
if (isGameOver) {
throw Error('Game already ended.');
}
// ✅ イベントハンドラで次の状態をすべて計算する
setCard(nextCard);
if (nextCard.gold) {
if (goldCardCount <= 3) {
setGoldCardCount(goldCardCount + 1);
} else {
setGoldCardCount(0);
setRound(round + 1);
if (round === 5) {
alert('Good game!');
}
}
}
}
イベントハンドラ内で次のstate
を直接計算できず、ネットワークとの同期が発生するような場合はエフェクトを連鎖させることは適切であり、基本的にReact内部で完結できるものはエフェクトを使う必要はなさそうです。
「エフェクトは必要ないかもしれない」には他にも様々なものが記載されているためかなり学びが多いと思います。
まとめ
今まではstate
の変更を検知して何らかの処理を行ったり、レンダー時に実行したい処理を行いたいときにuseEffect
を使うことが多かったですが、ドキュメントには以下の記述のように、外部システムが関係していない場合はuseEffectは必要なく、むしろ非効率であったり、意図しないバグを埋め込む可能性があるため、使わないにこしたことはないものでした。
外部システムが関与していない場合(例えば、props や state の変更に合わせてコンポーネントの state を更新したい場合)、エフェクトは必要ありません。
また、以下のポストによるとReact開発元のMetaのコードベースでさえ、ランダムな128個のuseEffectの内59個は不要なものだったようで、なんとなくで使うuseEffectの場合、その多くは不要なものだと言えるかもしれません…。
この記事であげているもの以外にもエフェクト関連の記事はありますので、一度目を通すだけでもかなりの学びにつながると思います。
Discussion