🦔

React Docs Effectsは必要ないかもしれない

2023/04/29に公開

You Might Not Need an Effect

EffectsはReactパラダイムからの脱出口であり、コンポーネントをReact以外のウィジェットやネットワーク、ブラウザのDOMなどの外部システムと同期させることができる。外部システムが関係ない場合(あるpropsstateが変化したときにコンポーネントを更新したい場合)はEffectsは必要ない。不要なEffectsを削除することで、コードの見通しが良くなる、実行速度の向上、エラーが発生しにくくなるといったことがある。

You will learn

  • コンポーネントから不要なEffectsを削除する理由と方法
  • Effectsを使わずに高価な計算をキャッシュする方法
  • Effectsを使わずにコンポーネントのステートをリセットして調整する方法
  • イベントハンドラ間でロジックを共有する方法
  • どのロジックをイベントハンドラに移すべきか
  • 親コンポーネントに変更内容を通知する方法

不要なEffectsを削除する方法

よくあるEffectsが不要なケースは以下の2つ。

  1. レンダリングのためのデータ変換にEffectsは必要ない
    例えば、リストを表示する前にフィルタリングを行うとする。リストが変更したときにステート変数を更新するEffectを書きたくなるかもしれないが、これは非効率的である。ステートを更新するとき、Reactはまずコンポーネント関数を呼び出し、画面に表示されるべき内容を計算する。次に、これらの変更をDOMにコミットし、画面を更新する。その後、ReactはEffectを実行するため、Effect内でステート更新処理を行おうとすると、これらのプロセス全体を最初からやり直すことになる。
    コンポーネントの最上位ですべてのデータを変換するコードであれば、propsstateが変更されるたびに自動的に再実行されるため不要なレンダーパスを避けることができる。

  2. ユーザーのイベント処理にEffectsは必要ない
    例えば/api/buyのPOSTリクエストを送信し、ユーザーが商品を購入したときに通知を表示する場合を考える。購入ボタンクリックのイベントハンドラでは何が起こったかを正確に知ることができる。Effectが実行される頃には、ユーザーが何をしたか(どのボタンがクリックされたか)まではわからないため、通常ユーザーイベントの処理は、対応するイベントハンドラで処理することになる。

propsやstateに基づいてステートを更新する

firstNamelastNameという2つのステートを持つコンポーネントがあるとする。これらを連結してfullNameの算出や、firstNamelastNameが変更されるたびにfullNameが更新されるようにしたい。最初の直感ではfullNameステートを追加して、Effectでそれを更新することかもしれない。

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]);
  // ...
}

これは必要以上に複雑であり、fullNameステートとEffectは削除できる。

function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');
  // ✅ Good: calculated during rendering
  const fullName = firstName + ' ' + lastName;
  // ...
}

既存のpropsstateから計算できるものはステートに入れずに、レンダリング時に計算する。こうすることでコードが速く、シンプルになり、エラーが発生しにくくなる。このアプローチに関してはThinking in Reactで何をステートに入れるべきかを説明している。

高価な計算をキャッシュする

コンポーネントはpropsで受け取ったTodosfilter propにしたがってフィルタリングすることでvisibleTodosを計算する。この結果をstateに保存してEffectから更新したくなるかもしれない。

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // 🔴 Avoid: redundant state and unnecessary Effect
  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);
  // ...
}

これも先ほどと同様、不要かつ非効率的であり、stateとEffectは削除する。

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ This is fine if getFilteredTodos() is not slow.
  const visibleTodos = getFilteredTodos(todos, filter);
  // ...
}

通常、このコードは問題ないが、getFilteredTodos()が遅かったり、Todosの数が多かったりする場合、getFilteredTodos()を再計算するのを避けたい。
高価な計算はuseMemo Hookを使うことで、キャッシュ(またはメモ化)することができる。

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  const visibleTodos = useMemo(() => {
    // ✅ Does not re-run unless todos or filter change
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);
  // ...
}

// 一行で書くと以下の通り
import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');
  // ✅ Does not re-run getFilteredTodos() unless todos or filter change
  const visibleTodos = useMemo(() => getFilteredTodos(todos, filter), [todos, filter]);
  // ...
}

これはTodosFilterのどちらかが変更されない限り、内部関数を再実行させたくないということをReactに伝えるためのものである。Reactは最初のレンダリング時にgetFilteredTodos()の戻り値を記憶し、次のレンダリングでTodosまたはFilterが異なるかどうかがチェックされる。これが前回と同じであれば、useMemoは記憶している最後の結果を返し、それらが異なる場合にのみ、内部関数を再度呼び出す。

useMemoでラップした関数はレンダリング時に実行されるため、純粋な計算の場合のみ有効となる。

propが変わるとすべてのstateがリセットされる

このProfilePageコンポーネントは、userId propを受け取る。また、このページにはコメント入力がありコメントステートを使用してその値を保持している。
この実装では、あるプロフィールから別のプロフィールに移動するとき、コメントのステートがリセットされておらず、誤って間違ったユーザーのプロフィールにコメントを投稿してしまうバグがある。この問題を解決するために、userIdが変更されるたびにコメントステートをクリアするよう修正する。

export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState('');

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment('');
  }, [userId]);
  // ...
}

ProfilePageとその子コンポーネントは、まず古い値でレンダリングし、それから再度レンダリングするため非効率である。また、ProfilePageの内部にステートを持つすべてのコンポーネントでこの処理を行う必要があるため複雑となる。(たとえばコメントUIがネストされている場合、ネストされたコメントのステートもクリアする必要がある)

Effectでステートをクリアする代わりに、Reactに明示的にキーを与えることで各ユーザーのProfileが概念的に異なるProfileであることを伝えることができる。コンポーネントを2つに分割して、外側のコンポーネントから内側のコンポーネントにkey属性を渡す。

export default function ProfilePage({ userId }) {
  return (
    <Profile
      userId={userId}
      key={userId}
    />
  );
}

function Profile({ userId }) {
  // ✅ This and any other state below will reset on key change automatically
  const [comment, setComment] = useState('');
  // ...
}

通常、Reactでは同じコンポーネントを同じ場所でレンダリングする場合、ステートを保持するが、ProfileコンポーネントのキーにuserIdを渡すことで、ステートを共有しない異なるコンポーネントとして扱うようにできる。キーが変わるたびにReactはDOMを再作成し、Profileコンポーネントとその子コンポーネントのステートをすべてリセットする。これでプロフィール間を移動するとき、コメント欄が自動的にクリアされるようになる。

propが変化したときに何らかのステートを調整する

propの変更でステートの一部をリセットしたり調整することはあっても、すべてのステートをリセットすることはほとんどないだろう。
以下のListコンポーネントではアイテムリストを受け取り、選択されたアイテムをステートに保持する。Listコンポーネントがが異なるアイテムを受け取るたびにステートをリセットする。

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // 🔴 Avoid: Adjusting state on prop change in an Effect
  useEffect(() => {
    setSelection(null);
  }, [items]);
  // ...
}

この方法も理想的ではない。アイテムが変わるたびにListとその子コンポーネントは更新される前のステートでレンダリングされた後、DOMを更新してエフェクトを実行する。最後にsetSelection(null)が呼び出され、Listとその子コンポーネントが再レンダリング(再びプロセス全体が行われる)される。

エフェクトを削除し、レンダリング時に直接値を調整するよう修正する。

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selection, setSelection] = useState(null);

  // Better: Adjust the state while rendering
  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }
  // ...
}

このように以前のレンダリングからの情報を保存することは理解しにくいかもしれないが、エフェクトで同じステートを更新するよりはましである。上記例はsetSelectionはレンダリング中に直接呼び出されており、return文後、すぐにListを再レンダリングする。このとき、Listの子コンポーネントはまだレンダリングされておらず、DOMも未更新なので、エフェクトでの実装で起きていた更新前の値でのレンダリングを防ぐことができる。

レンダリング中にコンポーネントを更新すると、Reactは返されたJSXを捨て、すぐに再レンダリングを行う。(Reactはレンダリング中に同じコンポーネントのステートを更新することだけ許可している)レンダリング中に他のコンポーネントのステートを更新するとエラーが発生する。

このパターンはエフェクトよりも効率的だが、ほとんどのコンポーネントはエフェクトも必要ないはずであり、どのような場面であってもpropsやstateの状態に基づいてステートを調整することはデータフローの理解やデバッグを難しくする。
キーですべてのステートをリセットできるか、レンダリング時にすべて計算できるか、常に意識する必要がある。例えば選択されたアイテムを保持、リセットする代わりに選択されたアイテムのIDを保存することができる。

function List({ items }) {
  const [isReverse, setIsReverse] = useState(false);
  const [selectedId, setSelectedId] = useState(null);
  // ✅ Best: Calculate everything during rendering
  const selection = items.find(item => item.id === selectedId) ?? null;
  // ...
}

こうすることで、選択されたIDを持つアイテムがリストにあればそれは選択されたままであり、ステートを調整する必要はなくなる。選択されたIDを持つアイテムがなければ一致するアイテムが見つからないため、レンダリング時の計算結果はnullとなる。

イベントハンドラ間のロジック共有

例えば、ある商品ページの2つのボタン(購入とチェックアウト)があり、どちらもその商品を購入できるとする。ユーザーが商品をカートに入れるたびに通知を出したい場合両方のボタンのクリックハンドラでshowNotification()を呼び出すと繰り返しになるため、ロジックをエフェクトに置きたくなるかもしれない。

function ProductPage({ product, addToCart }) {
  // 🔴 Avoid: Event-specific logic inside an Effect
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

  function handleCheckoutClick() {
    addToCart(product);
    navigateTo('/checkout');
  }
  // ...
}

このエフェクトは不要であり、バグの原因になる可能性が高い。例えばページがロードされる間にアプリがショッピングカートの内容を保持しているとする。一度カートに商品を入れた後、ページを更新するたびに通知が表示され、その商品ページを更新するたびに通知が表示され続けることになる。
これはページロード時にproduct.isInCartがすでにtrueになっており、showNotification()が呼び出される。
あるコードをエフェクトに入れるべきか、イベントハンドラに入れるべきか迷った場合、なぜこのコードが実行される必要があるのかを考え、コンポーネントの表示に必要なコードのみエフェクトを使用する。上記例ではページが表示されたからではなく、ユーザーがボタンを押したから通知を表示する。
そのため、エフェクトを削除し、共有ロジックを両方のイベントハンドラから呼び出すようにする。

function ProductPage({ product, addToCart }) {
  // ✅ Good: Event-specific logic is called from event handlers
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

  function handleCheckoutClick() {
    buyProduct();
    navigateTo('/checkout');
  }
  // ...
}

POSTリクエストの送信

コンポーネントマウント時のアナリティクスイベント送信、フォームサブミット時の/api/registerエンドポイントへのPOSTリクエスト送信について考える。

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic should run because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  // 🔴 Avoid: Event-specific logic inside an Effect
  const [jsonToSubmit, setJsonToSubmit] = useState(null);
  useEffect(() => {
    if (jsonToSubmit !== null) {
      post('/api/register', jsonToSubmit);
    }
  }, [jsonToSubmit]);

  function handleSubmit(e) {
    e.preventDefault();
    setJsonToSubmit({ firstName, lastName });
  }
  // ...
}

先ほどの例と同じ条件を適用してみる。
アナリティクスのPOSTリクエストはフォームが表示されたことをPOSTするため、エフェクトのままにしておく必要があるが、/api/registerへのPOSTリクエストはユーザーがボタンを押したときにリクエストを送るため、エフェクトではなくイベントハンドラに移動させる。

function Form() {
  const [firstName, setFirstName] = useState('');
  const [lastName, setLastName] = useState('');

  // ✅ Good: This logic runs because the component was displayed
  useEffect(() => {
    post('/analytics/event', { eventName: 'visit_form' });
  }, []);

  function handleSubmit(e) {
    e.preventDefault();
    // ✅ Good: Event-specific logic is in the event handler
    post('/api/register', { firstName, lastName });
  }
  // ...
}

あるロジックをイベントハンドラに入れるか、エフェクトに入れるかを選択するときはユーザーから見てそれがどのようなロジックであるかということである。ロジックが特定のインタラクションによって引き起こされるものであればイベントハンドラに入れ、画面上にコンポーネントが表示されたときに発生するものであればエフェクトに入れる。

計算の連鎖

他のステートに基づいてステートの一部を調整するエフェクトを連鎖させたくなるかもしれない。

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);
    }
  }

  // ...

このコードには2つの問題がある。

  1. コンポーネント(およびその子コンポーネント)は連鎖する各セット関数の呼び出しの間に再レンダリングする必要があるため、非常に非効率的である。
    この例では最悪の場合(setCardrendersetGoldCardCountrendersetRoundrendersetIsGameOverrender)、下のツリーの不要なレンダリングが3回発生している。
    たとえ速度に影響がなかったとしても、コードが増えていくにつれてこの実装に沿わない場合に遭遇することになる。例えば、ゲームの動きの履歴をたどる方法を追加することになった場合、各ステートを過去の値で更新することになる。しかし、カードのステートを過去の値に設定すると再びエフェクトの連鎖が発生し、表示されるデータが変わってしまう。

この場合、レンダリング中に計算できることを計算し、イベントハンドラでステートを調整する方が良い。

function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);

  // ✅ Calculate what you can during rendering
  const isGameOver = round > 5;

  function handlePlaceCard(nextCard) {
    if (isGameOver) {
      throw Error('Game already ended.');
    }

    // ✅ Calculate all the next state in the event handler
    setCard(nextCard);
    if (nextCard.gold) {
      if (goldCardCount <= 3) {
        setGoldCardCount(goldCardCount + 1);
      } else {
        setGoldCardCount(0);
        setRound(round + 1);
        if (round === 5) {
          alert('Good game!');
        }
      }
    }
  }

  // ...

こうすることでより効率的になり、ゲームの履歴を確認する方法を実装した際も、他の値を調整するエフェクトの連鎖を起こすことなく各ステートに過去の値を設定できるようになる。複数のイベントハンドラ間でロジックを再利用する必要がある場合、必要なところのハンドラからそれらの関数を呼び出すことができる。

イベントハンドラ内ではステートはスナップショットのように動作することを忘れてはならない。例えば、setRound(round + 1)を呼び出した後でもroundの値はユーザーがボタンをクリックした時点のものである。次の値を計算に使う必要がある場合は、const nextRound = round + 1のように手動で定義する。

アプリケーションの初期化

アップロード時に一度だけ実行されるべきロジックは、最上位コンポーネントのエフェクトに置きたくなるかもしれない。

function App() {
  // 🔴 Avoid: Effects with logic that should only ever run once
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}

しかし、開発環境ではこの関数が2回実行され、認証トークンを無効にしてしまうなどの問題が発生する可能性がある。一般的にコンポーネントは再マウントされても問題ないようにする必要がある。これにはトップレベルのAppコンポーネントも含まれる。

本番環境では実際に再マウントされることはないかもしれないが、すべてのコンポーネントで同じ制約に従うことで、コードの移動、再利用性が容易になる。コンポーネントをマウントするたびに実行するのではなく、アプリをロードするたびに1回実行するロジックがある場合は、トップレベルの変数を追加して、それが実行済みかどうかを確認する。

let didInit = false;

function App() {
  useEffect(() => {
    if (!didInit) {
      didInit = true;
      // ✅ Only runs once per app load
      loadDataFromLocalStorage();
      checkAuthToken();
    }
  }, []);
  // ...
}

また、モジュールの初期化やアプリのレンダリング前に実行することも可能。

if (typeof window !== 'undefined') { // Check if we're running in the browser.
   // ✅ Only runs once per app load
  checkAuthToken();
  loadDataFromLocalStorage();
}

function App() {
  // ...
}

トップレベルのコードはコンポーネントがインポートされたときに一度だけ実行される。任意のコンポーネントをインポートしたときの速度低下や予想外な動作を避けるためにこのパターンはあまり使いすぎないようにし、アプリ全体の初期化ロジックはApp.jsなどのルートコンポーネントやアプリケーションのエントリポイントにとどめておく。

親コンポーネントにステート変化を通知する

例えば、内部でtruefalseのどちらかの状態を持つisOnステートをもつToggleコンポーネントがあるとする。トグルを切り替えるにはクリックやドラッグなど、いくつかの方法がある。トグルのステートが変化するたびに親コンポーネントに通知したいため、エフェクトでonChangeイベントを呼び出す。

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  // 🔴 Avoid: The onChange handler runs too late
  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange])

  function handleClick() {
    setIsOn(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      setIsOn(true);
    } else {
      setIsOn(false);
    }
  }

  // ...
}

このやり方も理想的ではない。Toggleコンポーネントでステートが更新されると画面が再レンダリングし、その後エフェクトが実行され、親コンポーネントから呼ばれたonChange関数を呼び出す。そうすると、親コンポーネントが自身のステートを更新し、そこで別のレンダーパスが開始されるが、すべてを1度のレンダリングで行う方が良い。

function Toggle({ onChange }) {
  const [isOn, setIsOn] = useState(false);

  function updateToggle(nextIsOn) {
    // ✅ Good: Perform all updates during the event that caused them
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

  function handleClick() {
    updateToggle(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      updateToggle(true);
    } else {
      updateToggle(false);
    }
  }

  // ...
}

この修正では、Toggleコンポーネントとその親コンポーネントの両方がイベント中にステートを更新するため、異なるコンポーネントのステート更新を一括して処理し、レンダーパスは1回だけとなる。

また、ステートを完全に削除し、親コンポーネントからisOnを受け取る方法もある。

// ✅ Also good: the component is fully controlled by its parent
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

  function handleDragEnd(e) {
    if (isCloserToRightEdge(e)) {
      onChange(true);
    } else {
      onChange(false);
    }
  }

  // ...
}

ステートのリフトアップを行うことで、子コンポーネントは親コンポーネントから受け取るステートによって制御されるようになる。親コンポーネントにはロジックが増えるが、考慮すべきステートは全体的に少なくなる。2つの異なるステートを同期させようとする場合、代わりにステートのリフトアップを検討してもよい。

親にデータを渡す

子コンポーネントで取得したデータをエフェクトを使って親コンポーネントに渡す。

function Parent() {
  const [data, setData] = useState(null);
  // ...
  return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
  const data = useSomeAPI();
  // 🔴 Avoid: Passing data to the parent in an Effect
  useEffect(() => {
    if (data) {
      onFetched(data);
    }
  }, [onFetched, data]);
  // ...
}

Reactではデータは親コンポーネントから子コンポーネントに流れる。画面上で不具合があったとき、どのコンポーネントが間違ったpropを渡したり、間違ったステートになっているかを見つけるまで、コンポーネントチェーンを遡っていくことでどのコンポーネントから来た情報なのかを追うことができる。エフェクトで子コンポーネントから親コンポーネントのステートを更新する場合、データの流れが逆方向になっているため追うのが難しくなる。親と子が同じデータを必要とする場合は、親コンポーネントがデータを取得し、それを子コンポーネントに渡すようにする。

function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ Good: Passing data down to the child
  return <Child data={data} />;
}

function Child({ data }) {
  // ...
}

外部ストアに登録する

コンポーネントがサードパーティのライブラリや、ブラウザAPIなどのReactのステート外にあるデータをサブスクライブする必要があるかもしれない。これらのデータはReactが知らないうちに変更される可能性があるため、コンポーネントを手動でサブスクライブする必要があり、この処理はエフェクトで行われることが多い。

function useOnlineStatus() {
  // Not ideal: Manual store subscription in an Effect
  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();
  // ...
}

この例では、コンポーネントが外部のデータストア(ブラウザのnavigator.onLine API)をサブスクライブしており、このAPIはサーバー上に存在しないため(最初のHTMLに使用することはできない)、最初は状態がtrueに設定されている。そのデータストアの値がブラウザで変化するたびに、コンポーネントはそのステートを更新する。
そのため、エフェクトを使うのが一般的だが、Reactには外部ストアをサブスクライブするための専用Hookがあり、それを使うのが望ましい。エフェクトを削除してuseSyncExternalStoreの呼び出しに置き換える。

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();
  // ...
}

この方法は、エフェクトでミュータブルデータを手動でステートに同期させるよりもエラーが起こりにくい。一般的に上記のuseOnlineStatus()のようなカスタムHookを書くことで、個々のコンポーネントでこのコードを繰り返す必要がないようにする。
Reactコンポーネントから外部ストアにサブスクライブする方法について

データの取得

多くのアプリではデータ取得にEffectsを使っている。

function SearchResults({ query }) {
  const [results, setResults] = useState([]);
  const [page, setPage] = useState(1);

  useEffect(() => {
    // 🔴 Avoid: Fetching without cleanup logic
    fetchResults(query, page).then(json => {
      setResults(json);
    });
  }, [query, page]);

  function handleNextPageClick() {
    setPage(page + 1);
  }
  // ...
}

このフェッチをイベントハンドラに移す必要はない。これはイベントハンドラにロジックを入れる必要があった以前の例と矛盾しているように見えるかもしれないが、フェッチする理由がタイピングイベントでないことに注意。検索入力はURLがあらかじめ入力されていることが多く、ユーザー入力なく、「戻る」や「進む」といった操作をするかもしれない。
ページとクエリがどこから来るのかは関係なく、コンポーネントが表示されている間は、ページとクエリがネットワークからのデータと結果を同期させ続けておきたいためエフェクトを使っている。

しかしこのままでは、クエリの入力が速い場合、各入力値ごとに別々のフェッチを開始し、それらがどの順序でレスポンスされるかは保証されない。setResult()は最後に呼び出すため、間違った検索結果が表示されてしまう恐れがある。これは「レースコンディション」と呼ばれるもので、2つの異なるリクエストが互いに「競争」して、期待とは異なる順序で返ってくることを言う。
レースコンディションを修正するには不要なレスポンスを無視するクリーンアップ関数を追加する必要がある。

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);
  }
  // ...
}

こうすることでエフェクトがデータ取得する際、最後に要求されたもの以外のすべてのレスポンスが無視されるようになる。

データフェッチ実装が難しいのは、レースコンディションの処理だけではない。レスポンスのキャッシュ、サーバーでのデータフェッチ方法、ネットワークウォーターフォールを避ける方法についても考える必要がある点である。

これらの問題はReactだけでなく、どのUIフレームワークにも当てはまり、最近のフレームワークではEffectsでデータ取得するよりも効率的な組み込みデータ取得メカニズムを提供している。もし、フレームワークを使わずにEffectsからデータ取得をより人間工学的に行いたい場合は、以下例のように取得ロジックをカスタムHookにすることを検討する。

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;
}

エラー処理用のロジックや、コンテンツがロードされているかを確認するロジックも追加したくなるだろう。それらのHookも自分で作ることもできるし、Reactのエコシステムで提供されている多くの解決法の1つを利用することもできる。

一般的に、Effectsを書かなければならないときは、useDataのようにより宣言的で目的のAPIを持つカスタムHookに機能の一部を抽出できないか検討する。コンポーネント内で生のuseEffect呼び出しが少なければ少ないほどアプリケーションの捕手が容易になる。

Discussion

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