Writer: tokuyasu 更新日:2024/06/07
こんにちは、デジナーレ福岡オフィスの徳安です。
今回は、useEffectを使用すべきではない場所で、
useEffectを使用することを減らすため、
公式ドキュメントを読んで、学んだ内容をまとめようと思います。
1. props または state に基づいて state を更新する
*firstNameとlastNameの2つのstate変数を結合して、
「fullName」という新たな値を作成する場合
■Bad
結合する2つの変数の値が更新されたタイミングで、fullNameの値も更新したい。
↓
useEffectの第二引数に、firstNameとlastNameを追加し、更新する。
1 2 3 4 5 6 7 8 9 10 11 |
const Form = () => { const [firstName, setFirstName] = useState("Taylor"); const [lastName, setLastName] = useState("Swift"); // 🔴 Bad: 冗長なuseState と 不必要なuseEffect const [fullName, setFullName] = useState(""); useEffect(() => { setFullName(firstName + " " + lastName); }, [firstName, lastName]); // ... }; |
既存の props や state から作成したいものに関しては、
Reactのレンダリング中に計算されるため、stateでの管理は行わない。
■Good レンダリング中に計算する
1 2 3 4 5 6 7 |
const Form = () => { const [firstName, setFirstName] = useState("Taylor"); const [lastName, setLastName] = useState("Swift"); // ✅ Good: レンダリング中に計算 const fullName = firstName + " " + lastName; // ... }; |
上記のように最適化することにより、高速で、シンプルかつ、エラー、バグの原因を減らすことができる。
2. 重たい計算のキャッシュ
* 親コンポーネントから受け取ったpropsを、getFilteredTodosの引数として使用する場合
■Bad 冗長なuseState と 不必要なuseEffect
1 2 3 4 5 6 7 8 9 10 11 |
const TodoList = ({ todos, filter }) => { const [newTodo, setNewTodo] = useState(""); // 🔴 Bad: 冗長なuseState と 不必要なuseEffect const [visibleTodos, setVisibleTodos] = useState([]); useEffect(() => { // NOTE: getFilteredTodosは与えられた引数をフィルタリングしてくれる関数 setVisibleTodos(getFilteredTodos(todos, filter)); }, [todos, filter]); // ... }; |
先ほどの例と同様で、propsの値が変更された場合は、
Reactが自動的に差分を計算して、
必要な際にレンダリングを行ってくれるので、useEffectは必要ない。
■Good 関数内の処理が、重たい計算でなければこれでOK
1 2 3 4 5 6 7 |
const TodoList = ({ todos, filter }) => { const [newTodo, setNewTodo] = useState(""); // ✅ Good: 関数内の処理が、重たい計算でなければこれでOK const visibleTodos = getFilteredTodos(todos, filter); // ... }; |
**関数内の処理が重い場合**
関数の処理とは関係のないstateが更新されたタイミングで、
余計なキャッシュを使用している場合は、useMemo()を使用して、関数をメモ化する。
1 2 3 4 5 6 7 8 |
import { useMemo, useState } from 'react'; const TodoList = ({ todos, filter }) => { const [newTodo, setNewTodo] = useState(''); // ✅ Good: useMemoで、関数をメモ化する const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]); // ... } |
3. props が変更されたときにすべての state をリセットする
* propsで受け取った値が変更されているのに、commentの値がリセットされていない場合
■Bad propsの値が変更になった際に、commentの値が空になるようにする
1 2 3 4 5 6 7 8 9 |
const ProfilePage = ({ userId }) => { const [comment, setComment] = useState(""); // 🔴 Bad: propsの値が変更になった際に、commentの値が空になるようにする useEffect(() => { setComment(""); }, [userId]); // ... }; |
このように記述すると、ProfilePageとProfilePageが持つ子コンポーネントは、
まず古い値でレンダリングされ、その後に再度レンダリングされることになる為、非効率。
■Good コンポーネントを 2つに分割し、親コンポーネントから子コンポーネントに key 属性を渡す
1 2 3 4 5 6 7 8 9 |
const ProfilePage = ({ userId }) => { return <Profile userId={userId} key={userId} />; }; const Profile = ({ userId }) => { // ✅ Good: このstateは、親コンポーネントで設定したkeyが変更された際にリセットされる const [comment, setComment] = useState(""); // ... } |
4. アプリケーションの初期化
* アプリが読み込まれるときに、一度だけ実行したいロジックがある場合
■Bad 一度しか実行したくないロジックをトップレベルのコンポーネントのuseEffect内に書く
1 2 3 4 5 6 7 8 |
const App = () => { // 🔴 Bad: 一度しか実行したくないロジックをトップレベルのコンポーネントのuseEffect内に書く useEffect(() => { loadDataFromLocalStorage(); checkAuthToken(); }, []); // ... }; |
上記は開発環境では2度実行されてしまう。
このように記述すると、
関数が 2 回呼び出されることを想定していないため
認証トークンを無効化させてしまう可能性などがある。
■Good トップレベルに変数を使用することで再実行をスキップする
1 2 3 4 5 6 7 8 9 10 11 12 13 |
let didInit = false; const App = () => { useEffect(() => { if (!didInit) { didInit = true; // ✅ Good: アプリが読み込まれる際に一度だけ実行される loadDataFromLocalStorage(); checkAuthToken(); } }, []); // ... }; |
■Good モジュールの初期化時やアプリのレンダリング前に実行する
1 2 3 4 5 6 7 8 9 |
if (typeof window !== "undefined") { // ✅ Good: アプリが読み込まれる際に一度だけ実行される checkAuthToken(); loadDataFromLocalStorage(); } const App = () => { // ... };` |
5. 外部ストアへのサブスクライブ
* サードパーティーライブラリやブラウザに組み込まれたAPIなどを使う場合
■Bad useEffect内で手動でサブスクライブする
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
const useOnlineStatus = () => { // 🔴 Bad: useEffect内で手動サブスクリプション const [isOnline, setIsOnline] = useState(true); useEffect(() => { const updateState = () => { setIsOnline(navigator.onLine); }; updateState(); window.addEventListener("online", updateState); window.addEventListener("offline", updateState); return () => { window.removeEventListener("online", updateState); window.removeEventListener("offline", updateState); }; }, []); return isOnline; }; const ChatIndicator = () => { const isOnline = useOnlineStatus(); // ... }; |
上記のような処理はuseEffect内で行われるのが一般的だが、
外部ストアへサブスクライブする際には、
useSyncExternalStoreを使用することが推奨されている。
■Good
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
const subscribe = (callback) => { window.addEventListener("online", callback); window.addEventListener("offline", callback); return () => { window.removeEventListener("online", callback); window.removeEventListener("offline", callback); }; }; const useOnlineStatus = () => { // ✅ Good: useSyncExternalStoreを使用して外部ストアへサブスクライブする return useSyncExternalStore( subscribe, // 同じ関数を渡す限り、React は再サブスクライブしない () => navigator.onLine, () => true ); }; |
6. データのfetch
* イベントによってデータのfetchを行う必要がある場合
■Bad クリーンアップなしでfetch
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
const SearchResults = ({ query }) => { const [results, setResults] = useState([]); const [page, setPage] = useState(1); useEffect(() => { // 🔴 Bad: クリーンアップなしでfetch fetchResults(query, page).then((json) => { setResults(json); }); }, [query, page]); const handleNextPageClick = () => { setPage(page + 1); }; // ... }; |
queryとpageの値が変更になったときに、
ネットワークからのデータと同期させる必要がある。
エフェクトに記述するべきだが、このコンポーネントでは、バグが生じる可能性がある。
例えば検索などのinputエリアがある場合、
ユーザーが入力フィールドにhelloと打つと、
h, he, hel, hell, helloのそれぞれでfetchされることになる。
しかしこの5つのレスポンスの結果がfetchした順序で返ってくる保証がない。
この現象のことを、race condition(競合状態)と呼ぶ。
これを解消するためには、クリーンアップを追加する必要がある。
■Good
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
const SearchResults = ({ query }) => { const [results, setResults] = useState([]); const [page, setPage] = useState(1); useEffect(() => { // NOTE: 変数を導入 let ignore = false; fetchResults(query, page).then((json) => { if (!ignore) { setResults(json); } }); // ✅ Good: クリーンアップ関数 return () => { ignore = true; }; }, [query, page]); const handleNextPageClick = () => { setPage(page + 1); }; // ... }; |
クリーンアップの追加により、最後にリクエストしたもの以外のレスポンスが無視される。
コンポーネント内の生の useEffect の呼び出しが少なければ少ないほど、
アプリケーションのメンテナンスは容易になるため、
データ取得ロジックをカスタムフックに移動することも検討する必要がある。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 |
const SearchResults = ({ query }) => { const [page, setPage] = useState(1); const params = new URLSearchParams({ query, page }); // ✅ Good: コンポーネント内でeffectを呼び出さずにカスタムフックに切り分ける const results = useData(`/api/search?${params}`); const handleNextPageClick = () => { setPage(page + 1); }; // ... }; const 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; }; |
まとめ
ReactのuseEffectは、副作用の管理に便利な機能ですが、
考えなしに使用すると、思わぬ場所でバグが発生したりエラーの原因となってしまうことがあります。
私自身もuseEffectを深く理解した上で、
本当にuseEffectを使用するべき場所なのかということを、
再度確認して慎重に使用していこうと思います。
ここまで読み進めていただきありがとうございます。