dackdive's blog

新米webエンジニアによる技術ブログ。JavaScript(React), Salesforce, Python など

React公式ドキュメントの「You Might Not Need an Effect」を読んだ

少し前に話題になっていた以下のドキュメントをようやく読んだ。

beta.reactjs.org

記事を書いた時点でドキュメントはまだベータという位置づけ。

長いので先に目次を。
⭐ は中でも特にへえ〜って思ったやつ。💬 はコメント。

How to remove unnecessary Effects

Effects が不要なケースとしてよくあるのが次の 2 つ。

  • レンダリングのためのデータ変換に Effects は必要ない。たとえば、リストを表示前にフィルタしたい場合。
  • ユーザーイベントをハンドルするために Effects は必要ない

逆に、外部システムと同期するために Effects は必要。たとえば jQueryウィジェットと React の state を同期する場合。また、データをフェッチする、たとえば、現在の検索クエリで取得した検索結果を同期するなどのケースでも Effects が使える。

Updating state based on props or state (props や state に応じて state を更新する)

🔴   これは避ける
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]);
  // ...
}
✅  こうする
function Form() {
  const [firstName, setFirstName] = useState("Taylor");
  const [lastName, setLastName] = useState("Swift");
  // ✅ Good: calculated during rendering
  const fullName = firstName + " " + lastName;
  // ...
}
補足
  • なぜこうすべき?
    • 必要以上に複雑にしている
    • 効率が悪い。 firstName または lastName が更新されたとき、まず古い fullName で一度レンダーパスが実行され、それから更新後の値で再レンダーされる
  • props や state から計算可能な値は state に入れない。かわりにレンダリング内で計算する

Caching expensive calculations (コストの高い計算をキャッシュする)

🔴  これは避ける
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]);

  // ...
}
✅  こうする
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]);
  // ...
}
補足
  • getFilteredTodos() が重い処理でなければメモ化せず const visibleTodos = getFilteredTodos(todos, filter); で十分

⭐ Resetting all state when a prop changes (props 変更時にすべての state をリセット)

例:

  • <ProfilePage> コンポーネントuserId prop を受け取る。ページはコメント入力用の input を持ち、その値を保持するために comment state を使う
  • ある日、問題があることに気づいた:ある ProfilePage から別の ProfilePage に遷移したとき、 comment state がリセットされない。その結果、誤って間違ったユーザーのプロフィールでコメントする
  • この問題を修正するため、 userId が変更になったら常に comment state はクリアしたい
🔴  これは避ける
export default function ProfilePage({ userId }) {
  const [comment, setComment] = useState("");

  // 🔴 Avoid: Resetting state on prop change in an Effect
  useEffect(() => {
    setComment("");
  }, [userId]);
  // ...
}
  • 効率が悪い。なぜなら ProfilePage とその子コンポーネントはまず古い値でレンダーし、その後再度( userEffect で更新された後の値を使って)レンダーするから
  • 同様に、複雑である。なぜなら ProfilePage 内でなんらかの state を持つすべてのコンポーネントで同じことをやる必要があるから
    • もし comment UI がネストしていたとき、ネストしたコンポーネントの state もクリアする必要がある
✅  こうする
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("");
  // ...
}
補足

Adjusting some state when a prop changes (prop の変更時にある state を調整する)

https://beta.reactjs.org/learn/you-might-not-need-an-effect#adjusting-some-state-when-a-prop-changes

想定シーン
  • List コンポーネントitems prop を受け取る。かつ選択中の item を selection という state で管理する
  • items が変わったら毎回 selectionnull にリセットしたい
🔴  これは避ける
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]);
  // ...
}
  • これも、 items が変わったとき List とその子コンポーネント
    1. 古い selection の値で一度レンダー
    2. DOM を更新し Effects を実行
    3. setSelection(null) が実行され再レンダー
    4. となり効率が悪い
✅  こうする
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);
  }
  // ...
}
補足
  • Storing information from previous renders の項ではなるべく避けるべき(多くのケースでこれは必要ない)と書いてたけど、Effect 内で state を更新するよりは良い
  • React は return 文で終了した後すぐに List を再レンダーする。その時点では、React はまだ List の子をレンダーしておらず、DOM も更新していないので、 List の子には古い selectionでのレンダーをスキップさせることができる
  • レンダリング中にコンポーネントを更新した場合、React は return された JSX を捨ててすぐにレンダリングをリトライする。非常に遅いリトライのカスケードを避けるため、レンダー中は同じコンポーネントの state を更新することしかできない。他のコンポーネントの state を更新しようとした場合、エラーが発生する
  • items !== prevItems のような条件式はループを避けるために必要
  • この(修正後の)パターンは Effect を使ったものよりは効率的だが、多くのコンポーネントではこれも必要がない。reset all state with a keycalculate everything during rendering で紹介したパターンを使えないかチェックすべき
  • たとえば今回だと、選択中の item を保持するかわりにその 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;
  // ...
}
  • こうすると state を “調整” する必要はなくなる
    • 挙動が少し変わってはいるが…(常に選択中 item をクリアしてた修正前と比べて、同じ item があれば選択中状態が保持される)

Sharing logic between event handlers イベントハンドラ間でロジックを共有する

https://beta.reactjs.org/learn/you-might-not-need-an-effect#sharing-logic-between-event-handlers

想定シーン
  • 商品ページに Buy と Checkout の 2 つのボタンがある
  • ユーザーが商品をカートに入れるたびに通知を出したい
  • 両方のボタンの click イベントハンドラ内にshowNotification() を追加すると繰り返しになるため、ロジックを Effect に配置したくなるかもしれない
🔴  これは避ける
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");
  }
  // ...
}
  • これも Effect は必要ないし、バグを引き起こす原因にもなりうる
    • たとえばアプリケーションがリロード後もショッピングカートの内容を記憶してる場合、カートに追加 → リロード した後も通知が出続ける
  • あるコードを Effect 内に入れるべきかイベントハンドラ内に入れるべきか迷ったら、「このコードを “なぜ” 実行するのか」を自問すると良い。コンポーネントがユーザーに表示された “から” このコードを実行したい、というときのみ Effect を使う
  • この例では、ボタンが押されたから通知を出したいのであって、ページが表示されたからではない
✅  こうする
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");
  }
  // ...
}
  • 共通ロジックを関数化してイベントハンドラ内で呼び出す
  • 💬  そりゃそうだって感じはする

Sending a POST request (POST リクエストを送る)

https://beta.reactjs.org/learn/you-might-not-need-an-effect#sending-a-post-request

想定シーン
  • Form コンポーネントは 2 つの POST リクエストを送る
    1. マウント時にアナリティクスイベントを送る
    2. Submit ボタンをクリックしたときに、 /api/register エンドポイントにフォームの情報をおくる
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 });
  }
  // ...
}
  • 先ほどの判断基準(このコードを “なぜ” 実行するのか)に照らし合わせると
    • アナリティクスの送信は OK。なぜならアナリティクスイベントを送信したい理由は、フォームが表示されたから、だから
    • /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 });
  }
  // ...
}
  • (繰り返しではあるが)あるロジックをイベントハンドラに入れるか Effect に入れるかで迷ったら、ユーザーの視点で見てそれがどんな種類のロジックかを自問するとよい
    • もし、このロジックが特定のインタラクションによって引き起こされるのであれば、イベントハンドラで処理すべき
    • ユーザーが画面上のコンポーネント見ることによって発生するものであれば、Effect に記述すべき

Chains of computations (計算のチェーン)

https://beta.reactjs.org/learn/you-might-not-need-an-effect#chains-of-computations

想定シーン
  • 他の state にもとづき state の一部を調整するために Effect をチェーンしたくなるときがあるかもしれない
🔴  これは避ける
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. 非常に効率が悪い:チェーンの中で setXXX が呼ばれるたびに再レンダーしないといけない
      • 最悪のケースでは setCard → レンダー → setGoldCardCount→ レンダー →setRound→ レンダー →setIsGameOver→ レンダー
    2. 仮にこのコードが遅くはなかったとしても、コードが発達するにつれ、書いたチェーンが新しい要求にフィットしなくなるケースに遭遇するかもしれない
      • 仮に、ゲームの動きの履歴をたどる方法を追加することを想像してみる。過去の値で state を更新していけばいいが、 card state に過去の値をセットすると Effect チェーンが再びトリガーされ、表示されるデータが変わってしまう
✅  こうする
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!');
        }
      }
    }
  }

  // ...
補足
  • イベントハンドラ内では state はスナップショットのように振る舞う。たとえば、 setRound(round + 1) を実行したとしても、 round はユーザーがボタンをクリックしたときの値を反映している
  • いくつかのケースでは、イベントハンドラ内で次の state を計算できないケースがある。たとえば、複数のドロップダウンからなるフォームで、次のドロップダウンの選択肢が前のドロップダウンの選択値に依存するような場合。この場合は Effect のチェーンは適切である

⭐Initializing the application (アプリケーションの初期化)

https://beta.reactjs.org/learn/you-might-not-need-an-effect#initializing-the-application

想定シーン
  • アプリケーションのロード時に一度だけ実行したいロジックがある
🔴  これは避ける
function App() {
  // 🔴 Avoid: Effects with logic that should only ever run once
  useEffect(() => {
    loadDataFromLocalStorage();
    checkAuthToken();
  }, []);
  // ...
}
  • development モードのときに 2 回実行される
  • 一般的には、再マウントに強い (resilient) コンポーネントにすべきで、それはトップレベルの App も同様
  • 実際には絶対再マウントされないとしても、すべてのコンポーネントで同じ制約に従っておいたほうがコードの移動・再利用がしやすい
✅  こうする
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 のようなルートコンポーネントモジュールか、アプリケーションのエントリポイントモジュールに留めておく

Notifying parent components about state changes (state の変更を親コンポーネントに通知する)

https://beta.reactjs.org/learn/you-might-not-need-an-effect#notifying-parent-components-about-state-changes

想定シーン
  • Toggle コンポーネントisOn: bool という state を持つ
  • この内部 state が変わるたびに親コンポーネントに通知したいので、 onChange イベントを prop として公開し Effect 内で呼び出すことにした
🔴  これは避ける
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);
    }
  }

  // ...
}
✅  こうする
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);
    }
  }

  // ...
}
補足
  • isOn を prop として受け取り、親コンポーネント側で完全に制御するようにしても OK

Passing data to the parent (データを親に渡す)

https://beta.reactjs.org/learn/you-might-not-need-an-effect#passing-data-to-the-parent

想定シーン
🔴  これは避ける
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 を渡してるか、あるいは間違った state を持っているか、コンポーネントチェーンをさかのぼることでトレースできる
  • が、この例のように子コンポーネントが Effect で親コンポーネントの state を更新する場合、データフローのトレースは非常に難しくなる
  • 親でも子でも同じデータが必要なら、データのフェッチは親にやらせて、子には渡すだけにする
✅  こうする
function Parent() {
  const data = useSomeAPI();
  // ...
  // ✅ Good: Passing data down to the child
  return <Child data={data} />;
}

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

Subscribing to an external store (外部の store をサブスクライブする)

https://beta.reactjs.org/learn/you-might-not-need-an-effect#subscribing-to-an-external-store

想定シーン
🔴  普通に実装するとこうなる
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();
  // ...
}
✅  こうするとベター
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();
  // ...
}
  • この用途に適した [useSyncExternalStore](https://beta.reactjs.org/apis/react/useSyncExternalStore) というフックがある

💬  [useSyncExternalStore](https://beta.reactjs.org/apis/react/useSyncExternalStore) を使ったことがなかったので軽く調べた。ベータ版ドキュメントには情報が少ないので今のドキュメントを見ると、

https://ja.reactjs.org/docs/hooks-reference.html#usesyncexternalstore


const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
  • subscribe: ストアに変更があった場合に呼び出されるコールバックを登録するための関数。
  • getSnapshot: 現在のストアの値を返す関数。
  • getServerSnapshot: サーバレンダリング時にスナップショットを返すための関数。

とのこと。

Fetching data (データをフェッチする)

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

🔴  これは避ける
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);
  }
  // ...
}
  • Effect を使うのは 🙆‍♂️ 。フェッチする主な理由は typing event ではなく、検索入力は多くの場合 URL にあらかじめ入力されており、input に触れずに「戻る」「進む」の操作を行うかもしれない。 pagequery がどこから来るかは重要ではなく、これらの値に従ってネットワークから取得されたデータと results を同期させておきたい
  • ただ、上のコードにはバグがある。 "hello" とタイプしたときを想像してみる。 query"h""he""hell" と変化する際、別々のフェッチが走るが、レスポンスがどの順番で取得できるかについてなんの保証もない。 "hell" の検索結果を "hello" の後に受け取ると、表示する検索結果は間違ったものになる
    • いわゆる race condition と呼ばれるやつ
  • これを避けるためには、古いレスポンスを無視するよう cleanup function を追加する
✅  こうする
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 の扱い以外にもデータフェッチを実装するときに難しいポイントはある
    • レスポンスをどうキャッシュするか(ユーザーが戻るをクリックしたとき、spinner の代わりに前の画面がすぐ表示されるようにする)
    • サーバー側でどうフェッチするか(サーバーでレンダリングされた最初の HTML がスピナーの代わりにフェッチしたコンテンツを含むようにする)
    • ネットワークのオーバーフォールをどう避けるか(データをフェッチする必要のある子コンポーネントが、その上のすべての親コンポーネントのフェッチが完了するのを待たなくて済むように)
  • これらの問題は React だけでなくすべての UI ライブラリに当てはまる。モダンなフレームワークが Effect を直接記述するよりも効率的な組み込みのフェッチメカニズムを提供している理由でもある
  • フレームワークを使わず、Effect でのデータフェッチをよりエルゴノミック(ergonomic)に行いたい場合、カスタムフックに抽出することを検討してみては

Recap (要約)

  • レンダー中になんらかの計算を行える場合、Effect は必要ない
  • 時間のかかる計算をキャッシュする場合、 useEffect のかわりに useMemo を使う
  • あるコンポーネントツリー全体の state をリセットしたい場合、異なる keyを渡す
  • prop の変更に応じて state の一部をリセットしたい場合、レンダリング中にセットする
  • コンポーネント表示されたことを理由に実行する必要があるコードは Effect に、それ以外はイベントに記述する
  • いくつかのコンポーネントの state を更新したい場合、1 つのイベント内で行う
  • 異なるコンポーネント間の state 変数を同期したい場合、state のリフトアップを検討すべし
  • Effect でデータフェッチするのは良いが、 race condition を避けるため cleanup の処理を実装すること