【保存版】「そのuseEffectの使い方あってる?」と言われる前に
参考
目的
プロジェクトで使用されている不適切なuseEffect
を減らす
本題
Reactの公式ドキュメントにuseEffect
は必要ないかもしれない,というようなページがありとても勉強になったので記事にしようと思いました.
データフェッチング
アプリのデータフェッチングをuseEffect
内で行うのはよく知られている方法です.
Bad 💣
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 🔴 Avoid: クリーンアップなしでのフェッチング
fetchResults(query, page).then(json => {
setResults(json);
});
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
このコンポーネントが表示されているときはquery
とpage
に基づき,ネットワークからのデータと同期させたいという意図でしょう.
しかし上のコンポーネントではバグが生じる可能性があります.例えばhello
と打つと,h
, he
, hel
, hell
, hello
のそれぞれについてフェッチされます.しかしこれら5つの結果がフェッチした順序で返ってくる保証はありません.
このような状態をrace conditionと言います.
これを解消するためにクリーンアップを用います.
Better 🍊
function SearchResults({ query }) {
const [results, setResults] = useState([]);
const [page, setPage] = useState(1);
useEffect(() => {
// 変数を導入
let ignore = false;
fetchResults(query, page).then(json => {
if (!ignore) {
setResults(json);
}
});
return () => {
ignore = true;
};
}, [query, page]);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
クリーンアップの導入により最後に呼ばれたフェッチ以外はすべて無視されます.
しかしデータフェッチを実装する上でrace condition
の他にもキャッシングについて考える必要があります.ページを戻ったときにまたサーバーからデータをフェッチするとUXが悪くなります.
これらの問題はReactだけでなく他のUIライブラリにも当てはまります.これらを解決するのは簡単なことではないため最近のフレームワークでは効率的な組み込みのデータフェッチ機構が備わっています.
These issues apply to any UI library, not just React. Solving them is not trivial, which is why modern frameworks provide more efficient built-in data fetching mechanisms than writing Effects directly in your components.
データフェッチライブラリを使わずにキャッシングを実現する方法として公式では以下のようなカスタムフックを導入することを提案しています.
フレームワークの組み込みの機構にくれべればそれほど効率的ではありませんが,データフェッチロジックをカスタムフックに移動しておくことで後でデータフェッチングライブラリを採用するときに楽になります.
Although this alone won’t be as efficient as using a framework’s built-in data fetching mechanism, moving the data fetching logic into a custom Hook will make it easier to adopt an efficient data fetching strategy later.
Better 🍊
function SearchResults({ query }) {
const [page, setPage] = useState(1);
const params = new URLSearchParams({ query, page });
const results = useData(`/api/search?${params}`);
function handleNextPageClick() {
setPage(page + 1);
}
// ...
}
function useData(url) {
const [data, setData] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setData(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return data;
}
propsやstateをもとに状態を変更する
公式の例で出されているのが,firstName
とlastName
をuseState
でそれぞれ管理し,それらをもとにfullName
を生成(計算)する場合です.
Bad 💣
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// 🔴 Avoid: 余計なstate・不必要なuseEffect
const [fullName, setFullName] = useState('');
useEffect(() => {
setFullName(firstName + ' ' + lastName);
}, [firstName, lastName]);
// ...
}
Good 🍊
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: レンダリング中に計算する
const fullName = firstName + ' ' + lastName;
// ...
}
このようにすでにあるpropsやstateから生成されるものはstateで管理するのではなくレンダリングの途中で定義するのが良いとあります.
その方が
- 速い
- シンプル
- エラーが少ない
というような恩恵が得られます.
When something can be calculated from the existing props or state, don’t put it in state. Instead, calculate it during rendering. This makes your code faster (you avoid the extra “cascading” updates), simpler (you remove some code), and less error-prone (you avoid bugs caused by different state variables getting out of sync with each other).
時間のかかる計算をキャッシュする
todosからpropsで渡されたfilterを使ってフィルタリングする場合,以下のように書くことができます.
Bad 💣
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// 🔴 Avoid: 余計なstate・不必要なuseEffect
const [visibleTodos, setVisibleTodos] = useState([]);
useEffect(() => {
setVisibleTodos(getFilteredTodos(todos, filter));
}, [todos, filter]);
// ...
}
このuseEffectは不必要です.
propsのtodos
やfilter
が変化したときコンポーネントは値を再計算するからです.
1つ上の例と似ています.
こういった場合useEffectを使わずにさらに,useMemo
でフィルタリングの計算結果をキャッシュしておくのが良いと思われます.
Good 🍊
import { useMemo, useState } from 'react';
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ todosかfileterが変わるまで再計算しない
const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
// ...
}
このようにuseMemo
でラップすることで内部関数の再実行を減らすことができます.
ここではtodos
かfilter
が変化して初めて再計算されます.
This tells React that you don’t want the inner function to re-run unless either todos or filter have changed. React will remember the return value of getFilteredTodos() during the initial render. During the next renders, it will check if todos or filter are different. If they’re the same as last time, useMemo will return the last result it has stored. But if they are different, React will call the wrapped function again (and store that result instead).
時間のかかる計算とは?
ここで疑問が生まれます.
時間のかかる計算とは具体的にどれくらいのことを指すのかです.
すべての関数をuseMemo
でラップしても良いかもしれませんが,非効率な場合もあります[1].
これに対し,ドキュメント内で明確な回答がされています.
1. 時間を計測する
以下のように時間のかかりそうな関数の前後にconsole.time
を置いて時間を測ります.
console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');
このログの結果(例: filter array: 0.15ms
)が1ms
以上であればuseMemo
を使った方が良いそうです.
If the overall logged time adds up to a significant amount (say, 1ms or more), it might make sense to memoize that calculation.
2. useMemoを使った場合と比較する
console.time
を使って実際にuseMemo
を使った場合と使わなかった場合で時間を計測し効果があるかどうかを実験することができます.
As an experiment, you can then wrap the calculation in useMemo to verify whether the total logged time has decreased for that interaction or not:
propsが変化したときにstateをリセットする
例としてプロフィールページを出します.
このプロフィールページにはuserIdがpropsとして渡され,ページ内でコメントを記入することができます.このコメントをReactで管理するとするとおそらく以下のようになるでしょう.
// 一部抜粋
const [comment, setComment] = useState<string>('')
ではあるとき,あるプロフィールから別のプロフィールに遷移したときにコメントがリセットされない! というバグの報告があったとします.
このときに以下のようにuseEffect
を使ってコメントをリセットするのは無駄があります.
Bad 💣
// 🔴 Avoid: useEffect内でpropが変化したときにリセットする
useEffect(() => {
setComment('');
}, [userId]);
例えばProfilePage
というコンポーネント内でProfile
コンポーネントを使用し,その中でコメントを保持しているとすると,
ここではProfile
にkey
としてユーザー固有の値を渡すことで解決できます.
Good 🍊
export default function ProfilePage({ userId }) {
return (
<Profile
userId={userId}
key={userId}
/>
);
}
function Profile({ userId }) {
// ✅ このようなstateはkeyが変化すると自動的にリセットされる
const [comment, setComment] = useState('');
// ...
}
useIdをkeyとして渡すことでReactはProfile
コンポーネントを全く別のものとして扱ってくれるのでstateも共有されません.
By passing userId as a key to the Profile component, you’re asking React to treat two Profile components with different userId as two different components that should not share any state.
挙動の理由
これはなぜ成立するのでしょうか?
それはkey(ここではuserId
)が変わるたびにReactはすべての子のDOMを作り直すからです.
その結果,あるプロフィールから別のプロフィールを見に行っても自動的にコメントがリセットされます.
Whenever the key (which you’ve set to userId) changes, React will recreate the DOM and reset the state of the Profile component and all of its children. As a result, the comment field will clear out automatically when navigating between profiles.
アプリケーションを初期化する
アプリケーションを初期化したいときにアプリのトップレベルでuseEffect
を使っていませんか.
Bad 💣
function App() {
// 🔴 Avoid: 一度しか実行したくないロジックをuseEffect内に書く
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
しかしこれは開発環境では2度実行されてしまいます.
このことは例えば,認証トークンを無効化させてしまう可能性などがあります.
Good1 🍊
let didInit = false;
function App() {
useEffect(() => {
if (!didInit) {
didInit = true;
// ✅ Only runs once per app load
loadDataFromLocalStorage();
checkAuthToken();
}
}, []);
// ...
}
もしある処理をコンポーネントのマウントごとではなく,アプリのロードごとに1度だけ行いたい場合,トップレベルに変数を使用することで再実行をスキップできます.
If some logic must run once per app load rather than once per component mount, you can add a top-level variable to track whether it has already executed, and always skip re-running it:
また、モジュールの初期化時やアプリのレンダリング前に実行することも可能です。
Good2 🍊
if (typeof window !== 'undefined') { // Check if we're running in the browser.
// ✅ Only runs once per app load
checkAuthToken();
loadDataFromLocalStorage();
}
function App() {
// ...
}
外部のstoreを購読する
サードパーティーライブラリやブラウザに組み込まれたAPIなどを使うときに,コンポーネント内でReactのstateの外側にあるデータを購読する必要があります.
これらはReactとは関係ないので手動で購読する必要があります.
Bad 💣
function useOnlineStatus() {
// useEffect内で手動サブスクリプション
const [isOnline, setIsOnline] = useState(true);
useEffect(() => {
function updateState() {
setIsOnline(navigator.onLine);
}
updateState();
window.addEventListener('online', updateState);
window.addEventListener('offline', updateState);
return () => {
window.removeEventListener('online', updateState);
window.removeEventListener('offline', updateState);
};
}, []);
return isOnline;
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
上記のようにこのような処理はuseEffect
内で行われるのが一般的ですが,公式のフックでuseSyncExternalStoreというものがあり,代わりにそちらを使用することが推奨されています.
Although it’s common to use Effects for this, React has a purpose-built Hook for subscribing to an external store that is preferred instead. Delete the Effect and replace it with a call to useSyncExternalStore
Good 🍊
function subscribe(callback) {
window.addEventListener('online', callback);
window.addEventListener('offline', callback);
return () => {
window.removeEventListener('online', callback);
window.removeEventListener('offline', callback);
};
}
function useOnlineStatus() {
// ✅ Good: Subscribing to an external store with a built-in Hook
return useSyncExternalStore(
subscribe, // React won't resubscribe for as long as you pass the same function
() => navigator.onLine, // How to get the value on the client
() => true // How to get the value on the server
);
}
function ChatIndicator() {
const isOnline = useOnlineStatus();
// ...
}
まとめ
今回はuseEffect
を減らすという記事を書きました.
これをまとめるにあたって自分自身のソースコードも見直すことができたのでよかったです.
誰かの役に立てば嬉しいです.
-
Reactはpropsなどの値が異なっているかを毎回チェックするので,関数の計算自体を行ってしまった方が速い場合もあります. ↩︎
Discussion
useEffectが難しくて困っていたので、記事を書いて下さってありがとうございます。
1点質問があります。
ここがよく分からないです。
h
,he
,hel
と入力されていくとして処理の流れを見るとquery
に変化があってuseEffectが3回実行されてsetResults(json)
も3回実行されて最後のhel
がresult
に入ってそれ以外は上書きされるのでignore
がなくても問題なさそうな気がします。useEffectって値の変化によって複数回、実行される際は
h
,he
,hel
並列に3つ動作するのでしょうか。仮にそうだとしても
ignore
を追加する事で最後のフェッチ以外を無視できるのがよく分からないです。ここのクリーンアップ関数はDOMがアンマウント(DOMが削除される際に実行されると解釈してる)される時に実行される。これってuseEffectが記述されたコンポーネントが消える時でしょうか。
その際に
ignore = true
になったところで他の処理に影響しない気がします。すいません。質問たくさんですね。
この辺りを詳しく教えてくださると嬉しいです。宜しくお願い致します。
ご質問いただきありがとうございます!
そちらに関しては公式のこちらのページがわかりやすいかもです.
こちらのサンプルコードを以下のように変更してもらえればイメージがつきやすいと思います.
ここでは仮にフェッチが300msかかると想定しています.
こちらを試した後にクリーンアップ関数
を削除してみてください.
そうすると
hello
を入力したときにはログにh
,he
,hel
,hell
,hello
のすべてが表示されると思います.今回はきちんとすべて300msで終了するので順番がずれるようなことはありませんが,実際のHTTP通信ではこれらが異なる場合があり,
hello
の入力が必ず最後に返ってくるということは言えないということがわかると思います.横から失礼します。
クリーンアップは、コンポーネントのアンマウント時だけではなく、次の副作用が実行される前にも行われます。サンプルコードでは
text
が変わった時に実行される副作用を定義しているので、前のtext
変更時の副作用のクリーンアップが、次のtext
変更時の副作用実行前に実行されます。h
:h
の副作用実行he
:h
の副作用のクリーンアップ実行→he
の副作用実行hel
:he
の副作用のクリーンアップ.....これはちょっと語弊がある表現に感じます。ちょっと冗長ですが以下のような表現が正しいです。
なので、前のフェッチが終了する時間待ったあとに次の入力を行った場合は、両方のフェッチによる処理が実行されます。
ここが抜け落ちてました。アンマウント(DOMが削除された)時にだけ実行されると思ってました。
こうですよね。最初のフェッチでも終了後だったら
setResults(json);
実行されますね。でも結局は上書きされて最後のフェッチがセットされるので、race condition
の問題は解決出来そうですね。ありがとうございます。すっきりしました。
おかげでuseEffectへの理解が少し深まりました。
Fujiyamayamaさんも返信ありがとうございます。
記事のおかげで考えるきっかけになりました。
ありがとうございます。
k4aさん,大学生だった.さん,コメントありがとうございました.
伝わりづらかった箇所は説明を追記させていただきました.
ありがとうございました🙇♂️
debounce実装の時も、useEffectの使い方を調べて見直したことを思い出しました