You Might Not Need an Effect
下記の和訳 + 自分的メモをまとめる
How to remove unnecessary Effects
Effectsが不要なケースは、よく2つあります。
- レンダリングのためのデータ変換にEffectsは必要ない。 例えば、リストを表示する前にフィルタリングを行いたいとする。リストが変化したときにステート変数を更新するような Effect を書きたくなるかもしれない。しかし、これは非効率的です。コンポーネントの状態を更新するとき、Reactはまずコンポーネントの関数を呼び出して、画面に表示されるべき内容を計算します。次に、Reactはこれらの変更をDOMに「コミット」し、画面を更新します。その後、ReactはEffectを実行します。もしEffectもすぐに状態を更新してしまうと、プロセス全体が最初からやり直しになります。 不要なレンダーパスを回避するには、コンポーネントの最上位ですべてのデータを変換します。このコードは、propsやstateが変更されるたびに、自動的に再実行されます。
- ユーザーイベントを処理するためにEffectsは必要ありません。 例えば、/api/buy の POST リクエストを送信し、ユーザーが商品を購入したときに通知を表示する場合を考えてみましょう。購入ボタンのクリックイベントハンドラでは、何が起こったかを正確に把握することができます。エフェクトが実行されるまでに、ユーザが何をしたのか(例えば、どのボタンがクリックされたのか)わかりません。このため、通常は、対応するイベントハンドラでユーザイベントを処理することになります。
外部システムと同期するためのEffectは必要です。 例えば、jQueryのウィジェットをReactの状態と同期させるEffectを書くことができる。また、Effectsでデータを取得することもできます。例えば、検索結果を現在の検索クエリと同期させることができます。最近のフレームワークでは、コンポーネントに直接Effectを記述するよりも効率的な組み込みデータ取得メカニズムが提供されていることに留意してください。
Updating state based on props or state
firstNameとlastNameという2つの状態変数を持つコンポーネントがあるとします。それらを連結して、fullNameを計算したいとします。さらに、firstNameまたはlastNameが変更されるたびに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の値が古いままレンダーパス全体が実行され、その後すぐに更新された値で再レンダリングされます。 ステート変数とエフェクトの両方を削除してください。
function Form() {
const [firstName, setFirstName] = useState('Taylor');
const [lastName, setLastName] = useState('Swift');
// ✅ Good: calculated during rendering
const fullName = firstName + ' ' + lastName;
// ...
}
既存のプロップスやステートから計算できるものは、ステートに入れない。 その代わりに、レンダリング時に計算するようにします。これにより、コードが高速化し(余分な「カスケード」更新を回避)、シンプルになり(いくつかのコードを削除)、エラーが発生しにくくなります(異なるステート変数が互いに同期しないことによって発生するバグを回避できます)。このアプローチが新しいと感じる場合は、Thinking in Reactに、ステートに何を入れるべきかのガイダンスがあります。
Caching expensive calculations
このコンポーネントは、propsで受け取ったTodoをfilter propに従ってフィルタリングすることで、visibleTodosを算出しています。結果をステート変数に格納し、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]);
// ...
}
先の例と同様、これは不要かつ非効率的です。まず、ステートとエフェクトを削除します。
function TodoList({ todos, filter }) {
const [newTodo, setNewTodo] = useState('');
// ✅ This is fine if getFilteredTodos() is not slow.
const visibleTodos = getFilteredTodos(todos, filter);
// ...
}
多くの場合、このコードで大丈夫です。しかし、getFilteredTodos() が遅い場合や、たくさんのTodoがある場合があるかもしれないです。その場合、newTodo のような無関係な状態変数が変化したときに 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]);
// ...
}
これは、TODOS やフィルタが変更されない限り、内部関数を再実行させないことを React に伝えるものです。React は最初のレンダリングで getFilteredTodos() の戻り値を記憶します。次のレンダリングでは、TODOS やフィルターが異なるかどうかをチェックします。前回と同じ場合は、useMemo は最後に保存された結果を返します。しかし、異なる場合は、Reactはラップした関数を再度呼び出します(そして、代わりにその結果を保存します)。
useMemoでラップした関数はレンダリング中に実行されるので、これは純粋な計算の場合のみ機能します。
Resetting all state when a prop changes
この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 がネストされている場合、ネストされたコメントの状態もクリアする必要があります。
その代わりに、明示的にキーを与えることで、各ユーザーのプロファイルが概念的に異なるプロファイルであることを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 は同じコンポーネントが同じ場所にレンダリングされた場合、状態を保持します。Profile コンポーネントに userId をキーとして渡すことで、異なる userId を持つ 2 つの Profile コンポーネントを、状態を共有しない 2 つの異なるコンポーネントとして扱うように React に要求しています。 userId に設定したキーが変更されるたびに、React は DOM を再作成し、Profile コンポーネントとその子コンポーネントすべての状態をリセットします。その結果、プロファイル間を移動する際にコメント欄が自動的にクリアされます。
この例では、外側の ProfilePage コンポーネントだけがエクスポートされ、プロジェクト内の他のファイルから見えるようになっていることに注意してください。ProfilePage をレンダリングするコンポーネントはキーを渡す必要がありません: 彼らは userId を通常の prop として渡します。ProfilePage が内側の Profile コンポーネントにキーとしてそれを渡すのは実装の詳細です。
Adjusting some state when a prop changes
プロップチェンジの際に、全ての状態ではなく、一部の状態をリセットしたり、調整したりしたい場合があります。
このListコンポーネントは、アイテムのリストをpropとして受け取り、選択されたアイテムをselectionステート変数に保持します。items プロパティが異なる配列を受け取るたびに、選択状態を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]);
// ...
}
これも、理想的ではありません。アイテムが変更されるたびに、List とその子コンポーネントは、最初は古い選択値でレンダリングされます。その後、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 の子では古い選択値のレンダリングをスキップすることができます。このパターンの正しい使い方について、詳しくはこちらをご覧ください*1。
次に進む前に、レンダリング中にすべてを計算する要件をさらに簡素化できないか考えてみましょう。たとえば、選択した項目を保存する(そしてリセットする)代わりに、選択した項目の 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を持つアイテムがリストにあれば、それは選択されたままです。そうでない場合は、一致するアイテムが見つからなかったため、レンダリング時に計算される選択項目は null になります。この動作は少し異なりますが、アイテムに対するほとんどの変更で選択状態が維持されるため、間違いなくこの動作の方が優れています。しかし、selectedId を持つアイテムが存在しないかもしれないので、以下のすべてのロジックで selection を使用する必要があります。
*1 Storing information from previous renders
カウンタが最後に変更されてから増加したか減少したかを表示したいとします。count プロパティは、このことを教えてくれません。それを追跡するためにprevCountステート変数を追加します。トレンドという別の状態変数を追加して、カウントが増加したか減少したかを保持します。prevCountとcountを比較し、等しくなければ、prevCountとtrendの両方を更新します。これで、現在の count prop と、前回のレンダリング以降にどのように変化したかを両方表示することができます。
export default function CountLabel({ count }) {
const [prevCount, setPrevCount] = useState(count);
const [trend, setTrend] = useState(null);
if (prevCount !== count) {
setPrevCount(count);
setTrend(count > prevCount ? 'increasing' : 'decreasing');
}
return (
<>
<h1>{count}</h1>
{trend && <p>The count is {trend}</p>}
</>
);
}
レンダリング中に set 関数を呼び出す場合は、prevCount !== count のような条件の中で、 setPrevCount(count) のような呼び出しが必要であることに注意してください。さもなければ、コンポーネントはクラッシュするまでループで再レンダリングされます。また、このようにレンダリング中のコンポーネントの状態を更新できるのは、そのコンポーネントだけです。レンダリング中に他のコンポーネントの set 関数を呼び出すと、エラーになります。この特殊なケースは、純粋関数の他のルールを破ってよいという意味ではありません。
このパターンは理解しにくいので、通常は避けたほうがよいでしょう。 しかし、エフェクトでステートを更新するよりはましです。 レンダリング中に set 関数を呼び出すと、React は return 文でコンポーネントが終了した直後、子コンポーネントをレンダリングする前に、そのコンポーネントを再レンダリングします。この方法では、子要素は 2 回レンダリングする必要がありません。コンポーネント関数の残りの部分はまだ実行されますが(そして結果は捨てられます)、条件がHooksのすべての呼び出しより下にある場合、その中にearly return;を追加してレンダリングを早く再開することもできます。
Sharing logic between event handlers
例えば、2つのボタン(購入とチェックアウト)がある商品ページがあり、どちらもその商品を購入することができるとします。ユーザーが製品をカートに入れるたびに、通知用のトーストを表示したいとします。両方のボタンのクリックハンドラに showToast() を追加すると、繰り返しになるため、このロジックを Effect に配置したくなるかもしれません。
function ProductPage({ product, addToCart }) {
// 🔴 Avoid: Event-specific logic inside an Effect
useEffect(() => {
if (product.isInCart) {
showToast(`Added ${product.name} to the shopping cart!`);
}
}, [product]);
function handleBuyClick() {
addToCart(product);
}
function handleCheckoutClick() {
addToCart(product);
navigateTo('/checkout');
}
// ...
}
この効果は不要です。また、バグの原因になる可能性も高いです。例えば、ページが再読み込みされる間に、アプリがショッピングカートを「記憶」しているとします。一度商品をカートに入れ、ページを更新すると、通知トーストが再び表示されます。その商品のページを更新するたびに表示され続けることになります。これは、ページロード時に product.isInCart が既に true になっているためで、上記の Effect は showToast() を呼び出すことになります。
あるコードがEffectにあるべきか、イベントハンドラにあるべきか迷ったときは、なぜこのコードが実行される必要があるのかを自問してください。Effects は、コンポーネントがユーザに表示されたために実行されるべきコードにのみ使用します。 この例では、トーストが表示されるのは、ユーザがボタンを押したからであり、商品ページが表示されたからではありません。エフェクトを削除して、共有ロジックを関数に入れ、両方のイベントハンドラから呼び出すようにします。
function ProductPage({ product, addToCart }) {
// ✅ Good: Event-specific logic is called from event handlers
function buyProduct() {
addToCart(product);
showToast(`Added ${product.name} to the shopping cart!`);
}
function handleBuyClick() {
buyProduct();
}
function handleCheckoutClick() {
buyProduct();
navigateTo('/checkout');
}
// ...
}
Sending a POST request
この Form コンポーネントは、2 種類の POST リクエストを送信します。マウントすると、Analytics イベントを送信します。フォームに入力して Submit ボタンをクリックすると、/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のままであること。なぜなら、analytics イベントを送信する理由は、フォームが表示されたからです。 (開発時には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に記述する。
Initializing the application
アプリのロード時に一度だけ実行されるべきロジックもあります。そのようなロジックは、トップレベルコンポーネントのEffectに配置することができます。
function App() {
// 🔴 Avoid: Effects with logic that should only ever run once
useEffect(() => {
loadDataFromLocalStorage();
checkAuthToken();
}, []);
// ...
}
しかし、開発中にこの関数が二度実行されることにすぐに気がつくでしょう。たとえば、認証トークンを無効にしてしまうなどです。この関数は二度呼び出されることを想定していないのです。一般的に、コンポーネントは再マウントされにくいものであるべきです。これには、トップレベルの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
例えば、Toggleコンポーネントを書いていて、内部でisOnの状態がtrueかfalseのどちらかになっているとする。トグルを切り替えるには、いくつかの方法があります(クリックやドラッグなどによる)。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 関数を呼び出します。ここで、親コンポーネントが自身の状態を更新し、別のレンダリングパスが開始されます。代わりに、すべてを 1 回のパスで行う方がよいでしょう。
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);
}
}
// ...
}
「状態の引き上げ」(子コンポーネントでは状態を持たず、props経由で状態を受け取る)によって、親コンポーネントは、親コンポーネント自身の状態をトグルすることによって、Toggleを完全に制御することができます。つまり、親コンポーネントはより多くのロジックを含む必要がありますが、心配するようなステートは全体的に少なくなります。2つの異なるステート変数を同期させようとするときはいつでも、代わりにステートを持ち上げてみることをお勧めします。
※TODO
Passing data to the parent
この子コンポーネントは、あるデータを取得し、それをEffectで親コンポーネントに渡します。
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を渡しているか、間違った状態になっているかを見つけるまで、コンポーネントチェーンをさかのぼることで、その情報がどこから来たかを追跡することができます。子コンポーネントがEffectsで親コンポーネントの状態を更新する場合、データの流れを追跡するのは非常に難しくなります。 子コンポーネントと親コンポーネントの両方が同じデータを必要とするので、親コンポーネントにそのデータを取得させ、代わりに子コンポーネントに渡します。
function Parent() {
const data = useSomeAPI();
// ...
// ✅ Good: Passing data down to the child
return <Child data={data} />;
}
function Child({ data }) {
// ...
}
この方がシンプルで、データの流れも予測しやすい。データは親から子へと流れていく。
Subscribing to an external store
時には、あなたのコンポーネントは、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に設定されています。そのデータストアの値がブラウザで変更されるたびに、コンポーネントはその状態を更新します。
このためにEffectsを使用するのが一般的ですが、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コンポーネントから外部ストアにサブスクライブする方法についてはこちらをご覧ください。
Fetching data
多くのアプリは、データ取得のキックオフにEffectsを使用しています。データ取得の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からあらかじめ入力されており、ユーザーは入力に触れることなく「戻る」「進む」の操作を行うかもしれません。ページとクエリがどこから来るかは重要ではありません。このコンポーネントが表示されている間は、現在のページとクエリに従って、ネットワークからのデータと結果を同期させておきたいと思います。これがEffectである理由です。
しかし、上のコードにはバグがある。あなたが "hello "と速くタイプしたと想像してください。すると、クエリは「h」から「he」、「hel」、「hell」、「hello」へと変化する。これは別々の取得を開始しますが、どの順序で応答が到着するかは保証されません。 たとえば、"hell" のレスポンスが "hello" のレスポンスの後に来るかもしれません。setResults() は最後にコールされるので、間違った検索結果が表示されることになります。これは「レースコンディション」と呼ばれるもので、 ふたつの異なるリクエストが互いに「競争」し、 予想とは異なる順番でやってくるというものです。
レースコンディションを修正するには、クリーンアップ関数を追加して 古くなったレスポンスを無視するようにしなければなりません。
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がデータを取得する際、最後に要求されたもの以外のすべてのレスポンスが無視されるようになる。
データ取得の実装で難しいのは、レースコンディションの処理だけではありません。レスポンスをキャッシュする方法(ユーザーが Back をクリックすると、スピナーの代わりに前の画面がすぐに表示されるようにする)、サーバーでフェッチする方法(サーバーでレンダリングされた最初の HTML がスピナーの代わりにフェッチしたコンテンツを含むようにする)、ネットワークのウォーターフォールを回避する方法(データをフェッチする必要がある子コンポーネントがフェッチを始める前にその上のすべての親のデータフェッチを待つ必要がないように)についても考えたいかも知れません。これらの問題は、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 [result, setResult] = useState(null);
useEffect(() => {
let ignore = false;
fetch(url)
.then(response => response.json())
.then(json => {
if (!ignore) {
setResult(json);
}
});
return () => {
ignore = true;
};
}, [url]);
return result;
}
また、エラー処理のためのロジックや、コンテンツがロードされているかどうかを追跡するためのロジックも追加したいと思うでしょう。このようなHookを自分で作ることもできますし、Reactのエコシステムですでに提供されている多くのソリューションのうちの一つを使うこともできます。これだけではフレームワークの組み込みデータ取得メカニズムほど効率的ではありませんが、データ取得ロジックをカスタムHookに移動することで、後で効率的なデータ取得戦略を採用することが容易になります。
一般的に、Effectsを書かなければならないときはいつでも、上記のuseDataのように、より宣言的で目的のAPIを持つカスタムHookに機能の一部を抽出できるときに目を光らせておいてください。コンポーネントの中で生の useEffect 呼び出しが少なければ少ないほど、アプリケーションの保守が容易になります。