😅

【保存版】「そのuseEffectの使い方あってる?」と言われる前に

2022/08/09に公開約11,200字5件のコメント

参考

https://beta.reactjs.org/learn/you-might-not-need-an-effect

目的

プロジェクトで使用されている不適切な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);
  }
  // ...
}

このコンポーネントが表示されているときはquerypageに基づき,ネットワークからのデータと同期させたいという意図でしょう.

しかし上のコンポーネントではバグが生じる可能性があります.例えば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をもとに状態を変更する

公式の例で出されているのが,firstNamelastNameuseStateでそれぞれ管理し,それらをもとに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のtodosfilterが変化したときコンポーネントは値を再計算するからです.
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でラップすることで内部関数の再実行を減らすことができます
ここではtodosfilterが変化して初めて再計算されます.

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コンポーネントを使用し,その中でコメントを保持しているとすると,
ここではProfilekeyとしてユーザー固有の値を渡すことで解決できます.

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を減らすという記事を書きました.
これをまとめるにあたって自分自身のソースコードも見直すことができたのでよかったです.

誰かの役に立てば嬉しいです.

脚注
  1. Reactはpropsなどの値が異なっているかを毎回チェックするので,関数の計算自体を行ってしまった方が速い場合もあります. ↩︎

Discussion

useEffectが難しくて困っていたので、記事を書いて下さってありがとうございます。
1点質問があります。

クリーンアップの導入により最後に呼ばれたフェッチ以外はすべて無視されます.

ここがよく分からないです。

h, he, hel と入力されていくとして処理の流れを見ると query に変化があってuseEffectが3回実行されて setResults(json) も3回実行されて最後の helresult に入ってそれ以外は上書きされるので ignore がなくても問題なさそうな気がします。
useEffectって値の変化によって複数回、実行される際は h, he, hel 並列に3つ動作するのでしょうか。
仮にそうだとしても ignore を追加する事で最後のフェッチ以外を無視できるのがよく分からないです。

ここのクリーンアップ関数はDOMがアンマウント(DOMが削除される際に実行されると解釈してる)される時に実行される。これってuseEffectが記述されたコンポーネントが消える時でしょうか。
その際にignore = true になったところで他の処理に影響しない気がします。

すいません。質問たくさんですね。
この辺りを詳しく教えてくださると嬉しいです。宜しくお願い致します。

ご質問いただきありがとうございます!

そちらに関しては公式のこちらのページがわかりやすいかもです.

こちらのサンプルコードを以下のように変更してもらえればイメージがつきやすいと思います.
ここでは仮にフェッチが300msかかると想定しています.

import { useState, useEffect } from 'react';

function Playground() {
  const [text, setText] = useState('a');

  useEffect(() => {
    let ignore = false;
    function onTimeout() {
      if (!ignore) {
      	console.log('⏰ ' + text);  
      }
    }

    console.log('🔵 Schedule "' + text + '" log');
    const timeoutId = setTimeout(onTimeout, 300);

    return () => {
      console.log('🟡 Cancel "' + text + '" log');
      clearTimeout(timeoutId);
      ignore = true;
    };
  }, [text]);

  return (
    <>
      <label>
        What to log:{' '}
        <input
          value={text}
          onChange={e => setText(e.target.value)}
        />
      </label>
      <h1>{text}</h1>
    </>
  );
}

export default function App() {
  const [show, setShow] = useState(false);
  return (
    <>
      <button onClick={() => setShow(!show)}>
        {show ? 'Unmount' : 'Mount'} the component
      </button>
      {show && <hr />}
      {show && <Playground />}
    </>
  );
}

こちらを試した後にクリーンアップ関数

 return () => {
  console.log('🟡 Cancel "' + text + '" log');
  clearTimeout(timeoutId);
  ignore = true;
};

を削除してみてください.

そうするとhelloを入力したときにはログに
h, he, hel, hell, helloのすべてが表示されると思います.

今回はきちんとすべて300msで終了するので順番がずれるようなことはありませんが,実際のHTTP通信ではこれらが異なる場合があり,helloの入力が必ず最後に返ってくるということは言えないということがわかると思います.

横から失礼します。

ここのクリーンアップ関数はDOMがアンマウント(DOMが削除される際に実行されると解釈してる)される時に実行される。これってuseEffectが記述されたコンポーネントが消える時でしょうか。

クリーンアップは、コンポーネントのアンマウント時だけではなく、次の副作用が実行される前にも行われます。サンプルコードではtextが変わった時に実行される副作用を定義しているので、前のtext変更時の副作用のクリーンアップが、次のtext変更時の副作用実行前に実行されます。

  • hhの副作用実行
  • he:hの副作用のクリーンアップ実行→heの副作用実行
  • hel:heの副作用のクリーンアップ.....

最後に呼ばれたフェッチ以外はすべて無視されます

これはちょっと語弊がある表現に感じます。ちょっと冗長ですが以下のような表現が正しいです。

前のフェッチの処理が終了する前に、次のフェッチ(を実行する副作用)が実行された場合は、前のフェッチによる処理は無視されます。

なので、前のフェッチが終了する時間待ったあとに次の入力を行った場合は、両方のフェッチによる処理が実行されます。

次の副作用が実行される前にも行われます。

ここが抜け落ちてました。アンマウント(DOMが削除された)時にだけ実行されると思ってました。

前のフェッチの処理が終了する前に、次のフェッチ(を実行する副作用)が実行された場合は、前のフェッチによる処理は無視されます。

こうですよね。最初のフェッチでも終了後だったら setResults(json); 実行されますね。でも結局は上書きされて最後のフェッチがセットされるので、race condition の問題は解決出来そうですね。

ありがとうございます。すっきりしました。
おかげでuseEffectへの理解が少し深まりました。
Fujiyamayamaさんも返信ありがとうございます。
記事のおかげで考えるきっかけになりました。
ありがとうございます。

k4aさん,大学生だった.さん,コメントありがとうございました.

伝わりづらかった箇所は説明を追記させていただきました.
ありがとうございました🙇‍♂️

ログインするとコメントできます