もしかしたら Effect は必要ないかも

Effects は React のパラダイムからの脱出路です。Effects を使うと React の「外に出て」、React 以外のウィジェット、ネットワーク、ブラウザ DOM のような外部システムとコンポーネントを同期させることができます。外部システムが関与しない場合 (例えば、props または state の変更時にコンポーネントの state を更新したい場合)、Effect は必要ありません。不要な Effects を削除すると、コードが理解しやすくなり、実行速度が向上し、エラーが発生しにくくなります。

学習内容

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

不要な Effects の削除方法

Effects が必要ない一般的なケースは2つあります。

  • レンダリング用のデータを変換するために Effects は必要ありません。 例えば、表示する前にリストをフィルタリングしたいとしましょう。リストが変更されたときに state 変数を更新する Effect を書きたくなるかもしれません。しかし、これは非効率的です。state を更新すると、React はまずコンポーネント関数を呼び出して、画面に表示される内容を計算します。次に、React はこれらの変更を DOM に「コミット」して、画面を更新します。そして、React は Effects を実行します。Effect がさらにすぐに state を更新する場合、これは最初からプロセス全体を再開します!不要なレンダリングパスを避けるために、コンポーネントの最上位レベルですべてのデータを変換してください。そのコードは、props または state が変更されるたびに自動的に再実行されます。
  • ユーザーイベントを処理するために Effects は必要ありません。 例えば、ユーザーが製品を購入したときに /api/buy POST リクエストを送信し、通知を表示したいとします。「購入」ボタンのクリックイベントハンドラでは、何が起こったかを正確に把握できます。Effect が実行される時点では、ユーザーが何をしたか (例えば、どのボタンがクリックされたか) はわかりません。そのため、通常は対応するイベントハンドラでユーザーイベントを処理します。

外部システムと同期するためには、必ず Effects が必要です。例えば、React state と同期するように jQuery ウィジェットを維持する Effect を記述できます。また、Effects でデータをフェッチすることもできます。例えば、検索結果を現在の検索クエリと同期させることができます。モダンなフレームワークは、コンポーネントで Effects を直接記述するよりも効率的な組み込みのデータフェッチメカニズムを提供することに留意してください。

正しい直感を養うために、いくつかの具体的な例を見てみましょう!

props または state に基づく state の更新

例えば、firstNamelastName の2つの state 変数を持つコンポーネントがあるとします。それらを連結して fullName を計算したいとします。さらに、firstName または lastName が変更されるたびに fullName を更新したいとします。最初の直感としては、fullName state 変数を追加して、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 の古い値でレンダリングパス全体を実行し、その後、更新された値で即座に再レンダリングされます。state 変数と Effect を削除します。

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

既存の props または state から計算できるものがある場合、state に入れるべきではありません。代わりに、レンダリング中に計算します。これにより、コードが高速になり (追加の「カスケード」更新を回避できます)、単純になり (一部のコードを削除できます)、エラーが発生しにくくなります (異なる state 変数が互いに同期しなくなることによるバグを回避できます)。このアプローチが初めての場合は、React で考えるで、state に何を入れるべきかが説明されています。

コストのかかる計算のキャッシュ

このコンポーネントは、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があったりする場合、newTodoのような関係のないstate変数が変更された場合に、getFilteredTodos()を再計算したくありません。

「メモ化」)することができます。コストのかかる計算をuseMemoフックでラップすることで、コストのかかる計算をキャッシュ(または「メモ化」)することができます。

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

または、1行で記述すると次のようになります。

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

これは、todosまたはfilterのいずれかが変更されない限り、内部関数を再実行したくないことをReactに伝えます。Reactは、最初のレンダリング中にgetFilteredTodos()の戻り値を記憶します。次のレンダリングでは、todosまたはfilterが前回と異なるかどうかを確認します。前回と同じであれば、useMemoは保存している最後の結果を返します。しかし、異なる場合は、Reactは内部関数を再度呼び出し(そしてその結果を保存)します。

useMemoでラップする関数はレンダリング中に実行されるため、これは純粋な計算でのみ機能します。

詳細

計算がコストがかかるかどうかをどうやって判断すればよいですか?

一般的に、数千のオブジェクトを作成したりループしたりするのでなければ、おそらくコストはかかりません。確信を深めたい場合は、コンソールログを追加して、コードの一部で費やされる時間を測定できます。

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

測定しているインタラクション(たとえば、入力へのタイピング)を実行します。コンソールにfilter array: 0.15msのようなログが表示されます。ログに記録された合計時間がかなりの量(たとえば、1ms以上)になる場合は、その計算をメモ化するのが理にかなっているかもしれません。実験として、その計算をuseMemoでラップして、そのインタラクションの合計ログ時間が減少したかどうかを確認できます。

console.time('filter array');
const visibleTodos = useMemo(() => {
return getFilteredTodos(todos, filter); // Skipped if todos and filter haven't changed
}, [todos, filter]);
console.timeEnd('filter array');

useMemoは、最初のレンダリングを高速化するわけではありません。更新時の不要な作業をスキップするのに役立つだけです。

お使いのコンピュータはおそらくユーザーのコンピュータよりも高速であることに注意してください。そのため、人工的な速度低下でパフォーマンスをテストすることをお勧めします。たとえば、Chromeには、このためのCPUスロットリングオプションがあります。

また、開発環境でパフォーマンスを測定しても、最も正確な結果が得られないことにも注意してください。(たとえば、Strict Modeがオンになっていると、各コンポーネントは1回ではなく2回レンダリングされます。)最も正確なタイミングを取得するには、アプリを本番用にビルドし、ユーザーが使用しているようなデバイスでテストしてください。

propsが変更されたときにすべてのstateをリセットする

このProfilePageコンポーネントは、userId propを受け取ります。ページにはコメント入力が含まれており、comment state変数を使用してその値を保持しています。ある日、ある問題に気づきました。あるプロファイルから別のプロファイルに移動すると、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とその子コンポーネントは、最初に古い値でレンダリングされ、次に再度レンダリングされるためです。また、ProfilePage内に何らかのstateを持つすべてのコンポーネントでこれを行う必要があるため、複雑になります。たとえば、コメントUIがネストされている場合は、ネストされたコメント状態もクリアする必要があります。

代わりに、明示的なキーを与えることで、各ユーザーのプロファイルが概念的に異なるプロファイルであることをReactに伝えることができます。コンポーネントを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は同じ場所に同じコンポーネントがレンダリングされる場合、状態を保持します。userIdkeyとしてProfileコンポーネントに渡すことで、異なるuserIdを持つ2つのProfileコンポーネントを、状態を共有すべきではない2つの異なるコンポーネントとして扱うようReactに指示することになります。 key(ここではuserIdに設定されています)が変更されるたびに、ReactはDOMを再作成し、Profileコンポーネントとそのすべての子の状態をリセットします。これで、プロファイル間を移動する際に、commentフィールドが自動的にクリアされます。

この例では、外側のProfilePageコンポーネントのみがエクスポートされ、プロジェクト内の他のファイルから見えることに注意してください。ProfilePageをレンダリングするコンポーネントは、キーを渡す必要はありません。代わりに、userIdを通常のpropsとして渡します。ProfilePageがそれを内部のProfileコンポーネントにkeyとして渡すのは、実装の詳細です。

propsが変更されたときに一部の状態を調整する

propsが変更されたときに、状態の一部をリセットまたは調整したいが、すべてではない場合があります。

このListコンポーネントは、propsとしてitemsのリストを受け取り、選択されたアイテムをselection状態変数に保持します。itemsのpropsが異なる配列を受け取るたびに、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とその子コンポーネントは、最初に古いselection値でレンダリングされます。次に、ReactはDOMを更新し、Effectsを実行します。最後に、setSelection(null)の呼び出しによって、Listとその子コンポーネントが再びレンダリングされ、このプロセス全体が再び開始されます。

まず、Effectを削除します。代わりに、レンダリング中に直接状態を調整します。

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

このように以前のレンダリングからの情報を格納するのは理解するのが難しいかもしれませんが、Effectで同じ状態を更新するよりも優れています。上記の例では、setSelectionはレンダリング中に直接呼び出されます。Reactは、returnステートメントで終了した後、すぐにListを再レンダリングします。Reactは、Listの子をまだレンダリングしておらず、DOMも更新していないため、これにより、Listの子は古いselection値のレンダリングをスキップできます。

レンダリング中にコンポーネントを更新すると、Reactは返されたJSXを破棄し、すぐにレンダリングを再試行します。非常に遅いカスケード再試行を避けるために、Reactではレンダリング中に同じコンポーネントの状態のみを更新できます。レンダリング中に別のコンポーネントの状態を更新すると、エラーが表示されます。items !== prevItemsのような条件は、ループを回避するために必要です。このように状態を調整できますが、他の副作用(DOMの変更やタイムアウトの設定など)は、コンポーネントを純粋に保つために、イベントハンドラーまたはEffectsに残しておく必要があります。

このパターンはEffectよりも効率的ですが、ほとんどのコンポーネントはそれも必要としません。propsや他の状態に基づいて状態を調整すると、データフローが理解しにくく、デバッグが難しくなります。常に、キーを使用してすべての状態をリセットするか、レンダリング中にすべてを計算することができるかどうかを確認してください。たとえば、選択されたアイテムを格納(およびリセット)する代わりに、選択されたアイテム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を持つアイテムがリストにある場合、選択されたままになります。そうでない場合、レンダリング中に計算されたselectionは、一致するアイテムが見つからなかったため、nullになります。この動作は異なりますが、itemsへのほとんどの変更が選択を保持するため、おそらく優れています。

イベントハンドラー間でロジックを共有する

たとえば、製品ページに、その製品を購入できる2つのボタン(「購入」と「チェックアウト」)があるとします。ユーザーが製品をカートに入れるたびに通知を表示したいとします。両方のボタンのクリックハンドラーで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がshowNotification()を呼び出すため、product.isInCartがすでにtrueになっているためです。

一部のコードをEffectに入れるべきか、イベントハンドラーに入れるべきかわからない場合は、このコードを実行する必要がある理由を自問してください。コンポーネントがユーザーに表示されたために実行される必要があるコードにのみEffectsを使用してください。この例では、通知はページが表示されたからではなく、ユーザーがボタンを押したために表示される必要があります。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');
}
// ...
}

これにより、不要なEffectが削除され、バグが修正されます。

POSTリクエストの送信

このFormコンポーネントは、2種類の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リクエストは、Effectに残すべきです。これは、アナリティクスイベントを送信する理由は、フォームが表示されたことであるためです。(開発環境では2回発火しますが、その対処法についてはこちらを参照してください。)

ただし、/api/register POSTリクエストは、フォームが表示されたことによって発生するものではありません。ユーザーがボタンを押したという特定の瞬間にのみリクエストを送信する必要があります。それは、その特定のインタラクションでのみ発生する必要があります。2つ目のEffectを削除し、その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に保持します。

計算の連鎖

他の状態に基づいて各状態の一部を調整する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つの問題があります。

最初の問題は、非常に非効率的であるということです。コンポーネント(およびその子)は、チェーン内のset呼び出しごとに再レンダリングする必要があります。上記の例では、最悪の場合(setCard → レンダリング → setGoldCardCount → レンダリング → setRound → レンダリング → setIsGameOver → レンダリング)で、下のツリーが3回も不必要に再レンダリングされます。

2つ目の問題は、たとえ遅くなかったとしても、コードが進化するにつれて、記述した「チェーン」が新しい要件に合わないケースに遭遇するということです。ゲームの動きの履歴をステップスルーする方法を追加していると想像してください。過去の値から各状態変数を更新することで実現できます。ただし、card状態を過去の値に設定すると、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!');
}
}
}
}

// ...

これははるかに効率的です。また、ゲーム履歴を表示する方法を実装した場合、他のすべての値を調整するEffectチェーンをトリガーすることなく、各状態変数を過去の動きに設定できるようになります。複数のイベントハンドラー間でロジックを再利用する必要がある場合は、関数を抽出して、それらのハンドラーから呼び出すことができます。

イベントハンドラー内では、状態がスナップショットのように動作することを覚えておいてください。たとえば、setRound(round + 1)を呼び出した後でも、round変数は、ユーザーがボタンをクリックした時点での値を反映します。計算に次の値を使用する必要がある場合は、const nextRound = round + 1のように手動で定義します。

場合によっては、イベントハンドラーで次の状態を直接計算できないことがあります。たとえば、次のドロップダウンのオプションが前のドロップダウンで選択された値に依存する複数のドロップダウンがあるフォームを想像してください。この場合、ネットワークと同期しているため、Effectの連鎖が適切です。

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

一部のロジックは、アプリがロードされたときに1回だけ実行する必要があります。

最上位コンポーネントのEffectに配置したくなるかもしれません。

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

ただし、開発環境では2回実行されることにすぐに気づくでしょう。これにより、問題が発生する可能性があります。たとえば、関数が2回呼び出されるように設計されていないため、認証トークンが無効になる可能性があります。一般的に、コンポーネントは再マウントに対して耐性がある必要があります。これには、最上位のAppコンポーネントも含まれます。

実際には本番環境で再マウントされることはないかもしれませんが、すべてのコンポーネントで同じ制約に従うことで、コードの移動と再利用が容易になります。一部のロジックがコンポーネントのマウントごとに1回ではなく、アプリのロードごとに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() {
// ...
}

最上位レベルのコードは、コンポーネントがインポートされたときに、たとえレンダリングされなくても1回実行されます。任意のコンポーネントをインポートするときに速度低下や予期しない動作を回避するために、このパターンを過度に使用しないでください。アプリ全体の初期化ロジックは、App.jsのようなルートコンポーネントモジュールまたはアプリケーションのエントリポイントに保持してください。

状態変化に関する親コンポーネントへの通知

例えば、内部に isOn という状態を持ち、true または false のいずれかになる Toggle コンポーネントを作成しているとします。これを切り替えるにはいくつかの異なる方法(クリックやドラッグなど)があります。Toggle の内部状態が変化するたびに親コンポーネントに通知したいので、onChange イベントを公開し、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);
}
}

// ...
}

先ほどと同様に、これは理想的ではありません。Toggle は最初に自身の状態を更新し、React が画面を更新します。次に、React が Effect を実行し、親コンポーネントから渡された onChange 関数を呼び出します。これで、親コンポーネントは自身の状態を更新し、別のレンダリングパスが開始されます。すべてを単一のパスで実行する方が良いでしょう。

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

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 コンポーネントとその親コンポーネントの両方がイベント中に状態を更新します。React は異なるコンポーネントからの更新をまとめてバッチ処理するため、レンダリングパスは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);
}
}

// ...
}

「状態のリフトアップ」を使用すると、親コンポーネントは親自身の状態を切り替えることで、Toggle を完全に制御できます。これは、親コンポーネントがより多くのロジックを含む必要があることを意味しますが、全体的に心配する必要のある状態は少なくなります。異なる2つの状態変数を同期させようとする場合は、代わりに状態をリフトアップしてみてください。

親へのデータ渡し

この Child コンポーネントはいくつかのデータをフェッチし、それを Effect 内で 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 では、データは親コンポーネントから子コンポーネントに流れます。画面上で何か問題が発生した場合、どのコンポーネントが間違ったプロパティを渡しているか、または間違った状態を持っているかを把握するために、コンポーネントチェーンをたどって情報の出所を特定できます。子コンポーネントが Effect で親コンポーネントの状態を更新すると、データの流れが非常に追跡しにくくなります。子コンポーネントと親コンポーネントの両方が同じデータを必要とするため、親コンポーネントにそのデータをフェッチさせ、代わりに子コンポーネントに*渡す*ようにします。

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

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

これはよりシンプルであり、データの流れを予測可能に保ちます。データは親から子に流れます。

外部ストアへのサブスクライブ

コンポーネントが React の状態以外のデータにサブスクライブする必要がある場合があります。このデータは、サードパーティライブラリや組み込みのブラウザAPIからのものである可能性があります。このデータは React が認識せずに変更される可能性があるため、コンポーネントを手動でサブスクライブする必要があります。これは多くの場合、以下のような Effect で行われます。

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 に設定されます。ブラウザでそのデータストアの値が変更されるたびに、コンポーネントは自身の状態を更新します。

Effect を使用するのが一般的ですが、React には外部ストアをサブスクライブするための専用の Hook があり、代わりにそちらを使用することが推奨されます。Effect を削除し、代わりに 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();
// ...
}

このアプローチは、Effect を使用して可変データを React の状態に手動で同期するよりもエラーが発生しにくいです。通常、上記の useOnlineStatus() のようなカスタム Hook を作成して、個々のコンポーネントでこのコードを繰り返す必要がないようにします。React コンポーネントからの外部ストアへのサブスクライブについて詳しくはこちらをご覧ください。

データのフェッチ

多くのアプリでは、データフェッチを開始するために Effect を使用します。以下のようなデータフェッチの Effect を記述するのはごく一般的です。

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から事前に入力されており、ユーザーは入力を触ることなく戻る/進む操作を行う可能性があります。

pagequery がどこから来たかは関係ありません。このコンポーネントが表示されている間は、現在の pagequery のネットワークからのデータと results同期させておく必要があります。これが Effect である理由です。

しかし、上記のコードにはバグがあります。たとえば、"hello"と高速に入力することを想像してください。すると、query"h"から"he""hel""hell"、そして"hello"へと変化します。これにより、個別のフェッチが開始されますが、どの順序でレスポンスが到着するかは保証されません。たとえば、"hell"のレスポンスが"hello"のレスポンス*後*に到着する可能性があります。最後にsetResults()を呼び出すため、誤った検索結果が表示されることになります。これは「競合状態」と呼ばれます。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);
}
// ...
}

これにより、Effectがデータをフェッチする際、最後にリクエストされたもの以外のすべてのレスポンスが無視されるようになります。

データフェッチの実装における困難は、競合状態の処理だけではありません。レスポンスのキャッシュ(ユーザーが「戻る」をクリックしたときに前の画面がすぐに表示されるように)、サーバーでのデータフェッチ方法(初期サーバーレンダリングされたHTMLにスピナーではなくフェッチされたコンテンツを含めるため)、ネットワークのウォーターフォールを回避する方法(子要素がすべての親要素を待たずにデータをフェッチできるように)についても検討する必要があるかもしれません。

これらの問題は、Reactだけでなく、あらゆるUIライブラリに当てはまります。それらを解決するのは簡単ではありません。そのため、最新のフレームワークは、Effectでのデータフェッチよりも効率的な組み込みのデータフェッチメカニズムを提供しています。

フレームワークを使用しない場合(自分で構築したくない場合)でも、Effectからのデータフェッチをより人間工学的にしたい場合は、この例のように、フェッチロジックをカスタムフックに抽出することを検討してください。

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

また、エラー処理やコンテンツがロード中かどうかを追跡するためのロジックを追加する必要があるでしょう。このようなフックは自分で構築することも、Reactエコシステムですでに利用可能な多くのソリューションの1つを使用することもできます。これだけではフレームワークの組み込みのデータフェッチメカニズムを使用するほど効率的ではありませんが、データフェッチロジックをカスタムフックに移動することで、後で効率的なデータフェッチ戦略を採用しやすくなります。

一般に、Effectを書く必要が出てきたら、上記のようなuseDataのように、より宣言的で目的特化のAPIを備えたカスタムフックに機能の一部を抽出できるかどうかを注意深く確認してください。コンポーネント内の生のuseEffect呼び出しが少なければ少ないほど、アプリケーションの保守が容易になります。

まとめ

  • レンダリング中に何かを計算できる場合は、Effectは必要ありません。
  • コストのかかる計算をキャッシュするには、useEffectの代わりにuseMemoを追加します。
  • コンポーネントツリー全体の状態をリセットするには、異なるkeyを渡します。
  • プロップの変更に応じて特定の状態をリセットするには、レンダリング中に設定します。
  • コンポーネントが*表示された*ために実行されるコードはEffectに、残りはイベントに配置する必要があります。
  • 複数のコンポーネントの状態を更新する必要がある場合は、単一のイベント中に更新することをお勧めします。
  • 異なるコンポーネントで状態変数を同期しようとする場合は、状態をリフトアップすることを検討してください。
  • Effectでデータをフェッチできますが、競合状態を避けるためにクリーンアップを実装する必要があります。

チャレンジ 1 4:
Effectなしでデータを変換する

以下のTodoListは、TODOのリストを表示します。「アクティブなTODOのみを表示」チェックボックスがオンの場合、完了したTODOはリストに表示されません。どのTODOが表示されているかにかかわらず、フッターにはまだ完了していないTODOの数が表示されます。

不要な状態とEffectをすべて削除して、このコンポーネントを簡略化します。

import { useState, useEffect } from 'react';
import { initialTodos, createTodo } from './todos.js';

export default function TodoList() {
  const [todos, setTodos] = useState(initialTodos);
  const [showActive, setShowActive] = useState(false);
  const [activeTodos, setActiveTodos] = useState([]);
  const [visibleTodos, setVisibleTodos] = useState([]);
  const [footer, setFooter] = useState(null);

  useEffect(() => {
    setActiveTodos(todos.filter(todo => !todo.completed));
  }, [todos]);

  useEffect(() => {
    setVisibleTodos(showActive ? activeTodos : todos);
  }, [showActive, todos, activeTodos]);

  useEffect(() => {
    setFooter(
      <footer>
        {activeTodos.length} todos left
      </footer>
    );
  }, [activeTodos]);

  return (
    <>
      <label>
        <input
          type="checkbox"
          checked={showActive}
          onChange={e => setShowActive(e.target.checked)}
        />
        Show only active todos
      </label>
      <NewTodo onAdd={newTodo => setTodos([...todos, newTodo])} />
      <ul>
        {visibleTodos.map(todo => (
          <li key={todo.id}>
            {todo.completed ? <s>{todo.text}</s> : todo.text}
          </li>
        ))}
      </ul>
      {footer}
    </>
  );
}

function NewTodo({ onAdd }) {
  const [text, setText] = useState('');

  function handleAddClick() {
    setText('');
    onAdd(createTodo(text));
  }

  return (
    <>
      <input value={text} onChange={e => setText(e.target.value)} />
      <button onClick={handleAddClick}>
        Add
      </button>
    </>
  );
}