少し前に話題になっていた以下のドキュメントをようやく読んだ。
記事を書いた時点でドキュメントはまだベータという位置づけ。
長いので先に目次を。
⭐ は中でも特にへえ〜って思ったやつ。💬 はコメント。
- How to remove unnecessary Effects
- Updating state based on props or state (props や state に応じて state を更新する)
- Caching expensive calculations (コストの高い計算をキャッシュする)
- ⭐ Resetting all state when a prop changes (props 変更時にすべての state をリセット)
- Adjusting some state when a prop changes (prop の変更時にある state を調整する)
- Sharing logic between event handlers イベントハンドラ間でロジックを共有する
- Sending a POST request (POST リクエストを送る)
- Chains of computations (計算のチェーン)
- ⭐Initializing the application (アプリケーションの初期化)
- Notifying parent components about state changes (state の変更を親コンポーネントに通知する)
- Passing data to the parent (データを親に渡す)
- Subscribing to an external store (外部の store をサブスクライブする)
- Fetching data (データをフェッチする)
- Recap (要約)
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(""); // ... }
- 明示的に key を渡すことで、各ユーザーのプロフィールは概念的に異なるプロフィールであることを React に伝える
- コンポーネントを 2 つに分け、外側のコンポーネントから内側のコンポーネントに
key
を渡す
補足
- 通常、React は同じコンポーネントが同じ場所(spot)にレンダリングされるとき、state を保持する
userId
をkey
としてProfile
コンポーネントに渡すことで、異なるuserId
での 2 つのProfile
コンポーネントを、state を共有すべきでない 2 つの異なるコンポーネントとして扱うよう React に要求していることになる
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
が変わったら毎回selection
はnull
にリセットしたい
🔴 これは避ける
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
とその子コンポーネントは- 古い
selection
の値で一度レンダー - DOM を更新し Effects を実行
setSelection(null)
が実行され再レンダー- となり効率が悪い
- 古い
✅ こうする
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 key や calculate 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 リクエストを送る- マウント時にアナリティクスイベントを送る
- 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 に入れるかで迷ったら、ユーザーの視点で見てそれがどんな種類のロジックかを自問するとよい
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 つある
- 非常に効率が悪い:チェーンの中で
setXXX
が呼ばれるたびに再レンダーしないといけない- 最悪のケースでは
setCard
→ レンダー →setGoldCardCount
→ レンダー →setRound
→ レンダー →setIsGameOver
→ レンダー
- 最悪のケースでは
- 仮にこのコードが遅くはなかったとしても、コードが発達するにつれ、書いたチェーンが新しい要求にフィットしなくなるケースに遭遇するかもしれない
- 仮に、ゲームの動きの履歴をたどる方法を追加することを想像してみる。過去の値で state を更新していけばいいが、
card
state に過去の値をセットすると Effect チェーンが再びトリガーされ、表示されるデータが変わってしまう
- 仮に、ゲームの動きの履歴をたどる方法を追加することを想像してみる。過去の値で state を更新していけばいいが、
- 非常に効率が悪い:チェーンの中で
✅ こうする
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 計算すればいいよね
補足
- イベントハンドラ内では 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() { // ... }
- (上)コンポーネントのマウントにつき一度だけ、ではなくアプリケーションのロードにつき一度だけ実行したい場合、トップレベルの変数でトラッキングできる
- (下)モジュールの初期化時、アプリケーションのレンダリングがはじまる前に実行することもできる
Notifying parent components about state changes (state の変更を親コンポーネントに通知する)
想定シーン
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); } } // ... }
- React は異なるコンポーネントからの更新を一括して処理する(batches updates)ため、結果としてレンダーパスは 1 回だけとなる
補足
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 に触れずに「戻る」「進む」の操作を行うかもしれない。
page
とquery
がどこから来るかは重要ではなく、これらの値に従ってネットワークから取得されたデータと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 の扱い以外にもデータフェッチを実装するときに難しいポイントはある
- これらの問題は React だけでなくすべての UI ライブラリに当てはまる。モダンなフレームワークが Effect を直接記述するよりも効率的な組み込みのフェッチメカニズムを提供している理由でもある
- フレームワークを使わず、Effect でのデータフェッチをよりエルゴノミック(ergonomic)に行いたい場合、カスタムフックに抽出することを検討してみては
Recap (要約)
- レンダー中になんらかの計算を行える場合、Effect は必要ない
- 時間のかかる計算をキャッシュする場合、
useEffect
のかわりにuseMemo
を使う - あるコンポーネントツリー全体の state をリセットしたい場合、異なる
key
を渡す - prop の変更に応じて state の一部をリセットしたい場合、レンダリング中にセットする
- コンポーネントが表示されたことを理由に実行する必要があるコードは Effect に、それ以外はイベントに記述する
- いくつかのコンポーネントの state を更新したい場合、1 つのイベント内で行う
- 異なるコンポーネント間の state 変数を同期したい場合、state のリフトアップを検討すべし
- Effect でデータフェッチするのは良いが、 race condition を避けるため cleanup の処理を実装すること