React: useEffect について整理する

React: useEffect について整理する

April 24, 2023

大きく改訂された React の公式ドキュメントに沿って、useEffect について整理します。

主な参照元

useEffect の仕様 #

useEffect とは、外部システムとコンポーネントを同期させることができる React Hook です。

const [serverUrl, setServerUrl] = useState('https://localhost:1234');

useEffect(() => {
  const connection = createConnection(serverUrl, roomId);
  connection.connect();
  return () => {
    connection.disconnect();
  };
}, [serverUrl, roomId]);

第一引数にはセットアップ関数を、第二引数(オプショナル)には依存配列を渡します。セットアップ関数からは、クリーンアップ関数を return することができます(オプショナル)。

  1. コンポーネントが最初に DOM に追加(マウント)されると、セットアップ関数が実行されます。
  2. 依存配列内の値が変更されたことにより再レンダリングされると、前回レンダリング時のクリーンアップ関数(定義していた場合)を最初に実行し、その後に今回レンダリングのセットアップ関数を実行します。
  3. コンポーネントが DOM から削除(アンマウント)されると、クリーンアップ関数を実行します。

useEffect でデータを取得する #

参考

useEffect(() => {
  let ignore = false;
  setBio(null);
  fetchBio(person).then((result) => {
    if (!ignore) {
      setBio(result);
    }
  });
  return () => {
    ignore = true;
  };
}, [person]);

レースコンディションを考慮して上記のように ignore を定義します。これにより必ず最後に実行されたリクエストで取得したデータが state に設定されます。ネットワークのリクエストは、マウントおよび再レンダリングの度に実行されデータ取得が行われていますが、state へセットする処理が行われるのは最後のレンダリング時のみとなり、レースコンディションへの対応が実現されます。

不要となったネットワークリクエストそのものを中断したい場合は AbortController を使う方法があります。

useEffect(() => {
  const abortController = new AbortController();

  const fetchData = async () => {
    try {
      const response = await fetch(`https://localhost:1234/${id}/`, {
        signal: abortController.signal,
      });
      const newData = await response.json();

      setData(newData);
    } catch (error) {
      if (error.name === 'AbortError') {
        // ネットワークリクエストが中断されたとき
      } else {
        // その他の意図せぬエラーのとき
      }
    }
  };

  fetchData();
  return () => {
    abortController.abort();
  };
}, [id]);

useEffect と関連する Hooks #

  • useLayoutEffect
    • useEffect を同期的に実行するもの。
  • useEffectEvent(React18 時点では experimental)
    • リアクティブな必要性がないロジックを括り出しておくためのもの。

Strict Mode での挙動 #

開発中に Strict Mode フラグを有効にしていると、useEffect の実際のセットアップの前に1回余分に実行されます。これはストレステストであり、そのロジックが正しく実装されているかどうかを検証するために行われます。これによって問題が発生した場合、クリーンアップ関数に何らかのロジックが欠落していることを示しています。クリーンアップ関数は、セットアップ関数が行っていた処理を停止または元に戻すように設計する必要があります。

Strict Mode での挙動への対応 #

そのままで問題ない場合 #

2回実行されても問題ない場合は特に対応は必要ありません。

useEffect(() => {
  const map = mapRef.current;
  map.setZoomLevel(zoomLevel);
}, [zoomLevel]);

React widgets 以外のものを扱う場合 #

例えば HTML の <dialog> Element の showModal() を複数回実行するとエラーになります。そのような場合はクリーンアップ関数で初期状態になるよう戻します。

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

イベントの購読 #

クリーンアップ関数で購読を解除する処理を実行しましょう。

useEffect(() => {
  const dialog = dialogRef.current;
  dialog.showModal();
  return () => dialog.close();
}, []);

アニメーションの実行 #

クリーンアップ関数で初期状態になるよう戻します。

useEffect(() => {
  const node = ref.current;
  node.style.opacity = 1; // アニメーション実行
  return () => {
    node.style.opacity = 0; // リセットする
  };
}, []);

外部データの取得 #

先に記載した ignore フラグや AbortController を使った対応を行いましょう。

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

アプリ起動時に1回のみ実行する処理は useEffect で扱う必要がありません。それらは React コンポーネントの外に移動します。

// アプリ起動時の初期化処理
if (typeof window !== 'undefined') {
  checkAuthToken();
  loadDataFromLocalStorage();
}

// React のコード
function App() {
  // ...
}

useEffect で実行する必要がある場合はコンポーネント外に初期化フラグを持たせることで実現可能です。

let initialized = false;

function App() {
  const [user, setUser] = useState('');

  useEffect(() => {
    if (!initialized) {
      initialized = true;
      const userId = localStorage.getItem('userId');
      fetchUser(userId).then((result) => {
        setUser(result);
      });
    }
  }, []);

  // ...
}

その処理にエフェクトは必要ないかも?(You Might Not Need an Effect) #

エフェクトを使うことで、React の外側の、非 React ウィジェット、ネットワーク、ブラウザの DOM などの外部システムとコンポーネントを同期させることができますが、逆に外部システムが関係しない場合、エフェクトは必要ないはずであり、不要なエフェクトを削除することで、コードの追跡が容易になり、実行速度が速くなり、エラーの発生確率が低くなります。

プロップスやステートに基づいて他のステートを更新する場合 #

// 🔴 Avoid
function Form() {
  const [firstName, setFirstName] = useState('Taylor');
  const [lastName, setLastName] = useState('Swift');

  const [fullName, setFullName] = useState('');
  useEffect(() => {
    setFullName(firstName + ' ' + lastName);
  }, [firstName, lastName]);

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

  const fullName = firstName + ' ' + lastName;

  // ...
}

時間のかかる処理結果を保存しておきたい場合 #

// 🔴 Avoid
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  const [visibleTodos, setVisibleTodos] = useState([]);
  useEffect(() => {
    setVisibleTodos(getFilteredTodos(todos, filter));
  }, [todos, filter]);

  // ...
}
// ✅ Good
function TodoList({ todos, filter }) {
  const [newTodo, setNewTodo] = useState('');

  const visibleTodos = useMemo(() => {
    return getFilteredTodos(todos, filter);
  }, [todos, filter]);

  // ...
}

補足:計算時間の大小はどう判定する? #

参考

console.time() で計測して 1ms 以上かかっているようであれば useMemo() などでメモ化しても良いでしょう。

console.time('filter array');
const visibleTodos = getFilteredTodos(todos, filter);
console.timeEnd('filter array');

プロップスの変更に応じてステートをリセットしたい場合 #

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

  useEffect(() => {
    setComment('');
  }, [userId]);

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

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

  // ...
}

通常、同じコンポーネントが同じ場所にレンダリングされる場合、React は状態を保持しますが、コンポーネントのキーを渡すことで、React はキーが異なる 2 つのコンポーネントを別々のコンポーネントとして扱います。キーが変更されるたびに、React は DOM を再作成し、コンポーネントとそのすべての子コンポーネントの状態をリセットします。これにより、キーが変わるとステートが自動的にクリアされます。

プロップスの変更に応じてステートを修正したい場合 #

items という配列が渡され、コンポーネントでは選択中の item という状態を保持する。items が変わったら洗濯中の item をリセットしたい。

// 🔴 Avoid
function List({ items }) {
  const [selection, setSelection] = useState(null);

  useEffect(() => {
    setSelection(null);
  }, [items]);

  // ...
}
// Better
// 以前のレンダリングの情報を保持しておく方法
// このパターンは Effect よりは効率的ですが、ほとんどのコンポーネントでは必要ありません。
// プロップスや以前のステートに基づいて現在のステートを調整することは、データフローを複雑化し、デバッグが困難になります。
// キーを用いてコンポーネントをリセットするか、レンダリング中にすべてを計算する方法を取れないか、確認すべきです。
function List({ items }) {
  const [selection, setSelection] = useState(null);

  const [prevItems, setPrevItems] = useState(items);
  if (items !== prevItems) {
    setPrevItems(items);
    setSelection(null);
  }

  // ...
}
// ✅ Best
// レンダリング中にすべてを計算する
function List({ items }) {
  const [selectedId, setSelectedId] = useState(null);

  const selection = items.find((item) => item.id === selectedId) ?? null;

  // ...
}

イベントハンドラ間でロジックを共有したい場合 #

そのコードがなぜ実行される必要があるかを考えてください。この例では、通知が表示されるべき理由は、ページが表示されたためではなく、ユーザーがボタンを押したためです。Effect を削除し、共有のロジックをイベントハンドラーの両方から呼び出される関数に置き換えます。

加えて、今回の Effect を用いた例はバグを引き起こす可能性があります。アプリがページをリロードする間も買い物カートの状態を記憶している場合、一度商品をカートに入れてページをリロードすると、通知が再び表示されます。その商品のページをリロードするたびに、通知が表示され続けます。

コンポーネントが表示された時に実行したい処理なのか、イベントハンドラとして実行したい処理なのかを分類してください。

// 🔴 Avoid
unction ProductPage({ product, addToCart }) {
  useEffect(() => {
    if (product.isInCart) {
      showNotification(`Added ${product.name} to the shopping cart!`);
    }
  }, [product]);

  function handleBuyClick() {
    addToCart(product);
  }

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

  // ...
}
// ✅ Good
function ProductPage({ product, addToCart }) {
  function buyProduct() {
    addToCart(product);
    showNotification(`Added ${product.name} to the shopping cart!`);
  }

  function handleBuyClick() {
    buyProduct();
  }

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

  // ...
}

親コンポーネントにステートの変化を通知したい場合 #

クリックまたはドラッグ操作があった場合に親コンポーネントに通知するとします。

これまでの例同様に前半の例は無駄なレンダリングを要することから効率が悪く、理想的ではありません。

Effect を削除し、代わりに同じイベントハンドラ内で両方のコンポーネントの状態を更新してください。

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

  useEffect(() => {
    onChange(isOn);
  }, [isOn, onChange]);

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

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

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

  function updateToggle(nextIsOn) {
    setIsOn(nextIsOn);
    onChange(nextIsOn);
  }

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

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

  // ...
}

あるいは次のようにすることもできます。

ステートをリフトアップすることで、親コンポーネントが自分自身のステートを切り替えることで、子コンポーネントを完全に制御できるようになります。これにより親コンポーネントが心配する必要があるステートが少なくなります。2つの異なるコンポーネントのステートを同期する必要がある場合は、これは常に検討すべきアプローチです。

// ✅ Also good
function Toggle({ isOn, onChange }) {
  function handleClick() {
    onChange(!isOn);
  }

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

  // ...
}

連鎖的な計算がしたい場合 #

ステートを調整するために他のステートに基づいて計算を行う効果を連鎖させたくなることがあるかもしれません。

次のコードには2つの問題があります。

1つの問題は非常に効率が悪いことです。各 set 呼び出しの間にコンポーネント(およびその子)が再レンダリングされる必要があります。この例では、最悪の場合下位ツリーが3回無駄に再レンダリングされます(setCard -> “render” -> setGoldCardCount -> “render” -> setRound -> “render” -> setIsGameOver -> “render”)。

もう1つの問題はこのようなコードは柔軟性がなく修正に弱いことです。

この場合、レンダリング中にできる限り計算を行い、イベントハンドラで状態を調整する方が良いです。

// 🔴 Avoid
function Game() {
  const [card, setCard] = useState(null);
  const [goldCardCount, setGoldCardCount] = useState(0);
  const [round, setRound] = useState(1);
  const [isGameOver, setIsGameOver] = useState(false);

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

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

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

  // ...
}

ただし場合によっては、イベントハンドラで直接次の状態を計算できない場合があります。たとえば、前のドロップダウンの選択値に依存する次のドロップダウンのオプションがある複数のドロップダウンを持つフォームです。その場合は Effect チェーンが適切です。

アプリケーションの初期化処理をしたい場合 #

先に記載した Strict Mode での挙動への対応と同じです。React コンポーネント外で実行するか、初期化フラグを用います。