LoginSignup
36
26

React + TypeScript: エフェクト(useEffect)を使わなくてよい場合とは

Last updated at Posted at 2022-08-30

React公式サイトのドキュメントが2023年3月16日に改訂されました(「Introducing react.dev」参照)。本稿は、応用解説の「You Might Not Need an Effect」をかいつまんでまとめた記事です。ただし、コードにはTypeScriptを加えました。反面、初心者向けのJavaScriptの基礎的な説明は省いています。

なお、本シリーズ解説の他の記事については「React + TypeScript: React公式ドキュメントの基本解説『Learn React』を学ぶ」をご参照ください。

エフェクトは、Reactの仕組みからの非常口です。Reactの「外に出て」コンポーネントを外部システムと同期できます。たとえば、React以外のウィジェットやネットワーク、あるいはブラウザDOMなどです。外部システムが関わらないなら、エフェクトは要りません。コンポーネントの状態を、プロパティや状態が変わったら更新したいといった場合です。不要なエフェクトを除けば、コードはわかりやすく、実行も速くなり、エラーが起こりにくくなります。

使わなくてよいエフェクトを除くには

エフェクトを使わなくてよい典型的な場合はつぎのふたつです。

  • レンダリングのためのデータ変換
    • リストを表示する前にフィルタリングすることになったとします。そういうときエフェクトを書いて、リストが変更されたときに状態変数を更新したくなるかもしれません。それは非効率です。
    • Reactのレンダリングはつぎのように進められます。
      1. レンダリングの開始: コンポーネントの状態が更新される。
      2. コンポーネントのレンダリング: コンポーネントの関数を呼び出して、何が画面に表示されるべきか演算する。
      3. DOMのコミット: DOMの変更にもとづき、画面を更新する。
    • Reactがエフェクトを実行するのはそのあとです。エフェクトの中で直に状態を更新したら、プロセス全体がはじめからやり直しになってしまいます。無用なレンダリングが生じないためには、コンポーネントのトップレベルですべてのデータを変換することです。そうすれば、プロパティや状態が変わるたびに、コードは自動的に再実行されます。
  • ユーザーイベントの扱い
    • たとえば、/api/buyに送るPOSTリスエストで、ユーザーが商品を購入するとしましょう。このとき購入の通知をしたい場合です。[購入]ボタンのクリックイベントハンドラなら、何が起こったのかは正確にわかります。エフェクトを待って実行するのでは、ユーザーが何をしたのか(どのボタンをクリックしたのか)は捉えきれません。ですから、ユーザーイベントは通常、対応するイベントハンドラで扱います。

外部システムと同期するには、エフェクトを使わなければなりません。たとえば、エフェクトを書いて、jQueryのウィジェットとReactの状態が同期できます。データの取得もエフェクトでできることです。たとえば、検索結果を現在の検索クエリと同期できます。ただ、最新のフレームワークに組み込みまれているデータ取得の仕組みは、エフェクトをコンポーネントに書き込むよりも効率が高いです。

プロパティや状態にもとづいて状態を更新する

コンポーネントがfirstNamelastNameというふたつの状態変数をもつとします。ふたつを連結して算出したのがfullNameです。すると、firstNamelastNameのどちらかが変われば、fullNameは更新されなければなりません。

そこで、fullNameを状態変数に加え、値はエフェクトで更新することが思いつきます。

function Form() {
	const [firstName, setFirstName] = useState('Taylor');
	const [lastName, setLastName] = useState('Swift');
	// 🔴 NG: 状態が冗長で不要なエフェクトが発生
	const [fullName, setFullName] = useState('');
	useEffect(() => {
		setFullName(firstName + ' ' + lastName);
	}, [firstName, lastName]);
	// ...
}

これは、状態を無駄に複雑にするだけで、非効率です。状態変数のfullNameがエフェクトで更新されることにより、再レンダリングが生じてしまいます。これを避けるためには、冗長な状態変数fullNameとその更新エフェクトを除きます。

function Form() {
	const [firstName, setFirstName] = useState('Taylor');
	const [lastName, setLastName] = useState('Swift');
	// ✅ OK: レンダリング時に演算
	const fullName = firstName + ' ' + lastName;
	// ...
}

すでにあるプロパティや状態から算出できる値は、別の状態として加えるべきではありません。状態でなく、値をレンダリング時に演算することにより、つぎのメリットが得られます。

  • コードが高速になる: 余計な何階層もの更新が避けられる。
  • コードがシンプルになる: 不要なコードが除かれる。
  • エラーが起こりにくくなる: 異なる状態変数が互いに同期しないバグを避けられる。

高負荷の演算をキャッシュする

つぎの関数コンポーネントTodoListは、引数に渡されたふたつのプロパティ(todosfilter)からフィルタリングしたvisibleTodosを算出しています。フィルタリング結果は状態変数に収めて、エフェクトで更新したくなるかもしれません。

function TodoList({ todos, filter }) {
	const [newTodo, setNewTodo] = useState('');
	// 🔴 NG: 冗長な状態と無駄なエフェクト
	const [visibleTodos, setVisibleTodos] = useState([]);
	useEffect(() => {
		setVisibleTodos(getFilteredTodos(todos, filter));
	}, [todos, filter]);

}

前傾のコード例と同じく、要らない状態変数とエフェクトは除くべきでしょう。

function TodoList({ todos, filter }) {
	const [newTodo, setNewTodo] = useState('');
	// ✅ OK: 関数の負荷が高くない場合
	const visibleTodos = getFilteredTodos(todos, filter);

}

大抵の場合、このコードで問題ありません。けれど、関数getFilteredTodos()の処理や、todoのデータ量が重かったりする場合もありえます。そういうとき考えるのは、関数の処理に関係のない状態(たとえばnewTodo)が変わったときは再計算しないことです。

負荷の高い演算はラップして結果をキャッシュにもつ手法は「メモ化」と呼ばれます。そのために用いるフックがuseMemoです。

import { useMemo, useState } from 'react';

function TodoList({ todos, filter }) {
	const [newTodo, setNewTodo] = useState('');
	const visibleTodos = useMemo(() => {
		// ✅ 依存するtodosかfilterに変更がないかぎり再計算しない
		return getFilteredTodos(todos, filter);
	}, [todos, filter]);

}

useMemoの引数に渡した関数本体は戻り値の1文ですので、コードは1行に収められます。

function TodoList({ todos, filter }) {

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

useMemoフックがReactに伝えるのは、依存するtodosfilterが変わらないかぎり内部関数getFilteredTodos()は再実行しないということです。Reactははじめてのレンダリングで、getFilteredTodos()の戻り値を覚えます。つぎのレンダリング時に確認されるのは、依存するふたつの値です。前回と変わりなければ、useMemoは内部関数は実行せずに、保持した直近の値を返します。依存値に変更があったとき、関数を呼び出して新たな値を返すのです。そして、その戻り値はまた新たに保持されます。

useMemoに包まれた関数が実行されるのはレンダリング時です。したがって、関数の処理は純粋な演算でなければなりません。はじめてのレンダリングでは関数が必ず実行されますので、その処理は速められないことにご注意ください。

実際の実行速度が知りたいときは、コードにconsole.time()console.timeEnd()を差し込んで測るとよいでしょう。ただし、開発モードにおける速さは正確とはいえません。とくに、StrictModeが有効な開発時は、コンポーネントがはじめに2度レンダリングされます(「React + TypeScript: React 18でコンポーネントのマウント時にuseEffectが2度実行されてしまう」参照)。プロダクションにビルドしたうえで確かめるのが正確です。

プロパティが変わったとき状態をすべてリセットする

つぎのProfilePageコンポーネントが受け取るプロパティはuserIdです。ページにはコメントを入力でき、状態変数commentにより値が保持されます。そこで、問題が生じました。プロファイルを移ったとき、commentの状態がリセットされないのです。そうすると、うっかり間違ったユーザーのプロファイルに投稿していまうかもしれません。解決するには、userIdが変わったら状態変数commentの値を消去することです。

export default function ProfilePage({ userId }) {
	const [comment, setComment] = useState('');
	// 🔴 NG: プロパティが変わったとき状態をエフェクトの中でリセットする
	useEffect(() => {
		setComment('');
	}, [userId]);
	// ...
}

これでは、効率がよくありません。ProfilePageと子コンポーネントはまず古い値でレンダリングされ、そのあとエフェクトで再レンダリングされるからです。さらに、ProfilePage内で何らかの状態をもつすべての子コンポーネントも再設定しなければならず、複雑さが増します。たとえば、コメントのUIが入れ子になっていたら、子の状態もクリアすべきでしょう。

そのために適切なのは、Reactがユーザーごとのプロファイルを別個に捉えられるよう、明示的なキーを与えることです。ProfileのコンポーネントをProfilePageから切り分け、ユーザー識別のためのkey属性にuserIdの値を渡します。

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

function Profile({ userId }) {
	// ✅ OK: コンポーネントツリーの状態はkeyが変われば自動的にリセットされる
	const [comment, setComment] = useState('');
	// ...
}

通常Reactでは、同じコンポーネントが同じ場所にレンダリングされたら、状態はそのまま変わりません。けれど、userIdkeyプロパティの値としてProfileに渡すと、userIdの異なるProfileは異なるコンポーネントで状態を共有しないようReactに告げたことになるのです。key(userIdの値)が変われば、ReactはDOMをつくり直し、Profileとすべての子コンポーネントの状態はリセットされます。つまり、プロファイルが別ユーザーに移ったとき、commentフィールドは自動的に初期化されるということです。

このコード例では、親のProfilePageコンポーネントだけがexportされて、プロジェクトの他のファイルからimportして表示できます。ProfilePageをレンダリングするコンポーネントからはkeyは渡しません。userIdをプロパティとして与えるだけです。ProfilePageが、その値をkeyとして子コンポーネントProfileに渡すという内部的な実装にしました。

プロパティが変わったとき状態を一部修正する

場合によっては、プロパティが変わったとき、すべての状態でなく一部を初期化あるいは修正したいこともあるでしょう。

つぎのListコンポーネントは項目のリストitemsをプロパティとして受け取り、選択された項目が状態変数selectionに保持されます。そして、itemsプロパティが異なる配列を受け取ったら、selectionnullにリセットしたいとしましょう。

function List({ items }) {
	const [isReverse, setIsReverse] = useState(false);
	const [selection, setSelection] = useState(null);
	// 🔴 NG: プロパティが変わったとき状態をエフェクトの中で修正する
	useEffect(() => {
		setSelection(null);
	}, [items]);
	// ...
}

やはり、望ましい処理ではありません。itemsが変わるたびに、Listとその子コンポーネントはまず古いselectionの値でレンダリングされます。そのあと、ReactがDOMを更新したうえで実行されるのがエフェクトです。ようやく、setSelection(null)が呼び出され、Listとその子コンポーネントは再レンダリングされて、プロセス全体が繰り返されます。

まずは、エフェクトを除くことです。状態の修正は、レンダリング時に直接行います。

function List({ items }) {
	const [isReverse, setIsReverse] = useState(false);
	const [selection, setSelection] = useState(null);
	// 改善: 状態をレンダリング時に修正する
	const [prevItems, setPrevItems] = useState(items);
	if (items !== prevItems) {
		setPrevItems(items);
		setSelection(null);
	}
	// ...
}

前のレンダリングの情報をもっておくというのは、わかりにくいかもしれません。けれど、エフェクトで状態を更新するよりは有効です。前掲のコード例では、setSelection()関数がレンダリング中に直接呼び出されています。ReactがListを再レンダリングするのは、return文を終了してすぐです。このときはまだ、Reactは子コンポーネントのレンダリングもDOMの更新もしていません。Listの子コンポーネントは、古いselectionの値でレンダリングされずに済むのです。

レンダリング中にコンポーネントが更新されると、Reactは返されたJSXは破棄し、すぐにレンダリングを再実行します。何階層にも及ぶ再実行で非常に遅くならないよう、Reactではレンダリング中はそのコンポーネントの状態しか更新できません。レンダリング中に別のコンポーネントの状態を変えようとすれば、エラーが示されます。items !== prevItemsのような条件が求められるのは、ループを避けるためです。このようにして状態の修正はできます。けれど、他の副作用(DOMの変更やタイムアウトの設定など)は、イベントハンドラまたはエフェクトに残して、コンポーネントを予測可能に保つのがよいでしょう。

このやり方は、更新をすべてエフェクトに入れるよりは効率的です。けれど、ほとんどのコンポーネントではエフェクトも要らないでしょう。どのように扱っても、状態をプロパティや他の状態にもとづいて修正しようとすると、データフローはわかりづらく、デバッグも難しくなります。それよりすでに解説したように、キーを用いてすべての状態がリセットできないか、またはレンダリング中にすべて演算してしまえないか、先に確かめてください。たとえば、選択した項目を保存(およびリセット)するのでなく、その項目のIDだけ保存することが考えられます。

function List({ items }) {
	const [isReverse, setIsReverse] = useState(false);
	const [selectedId, setSelectedId] = useState(null);
	// ✅ OK: 演算はすべてレンダリング時に行う
	const selection = items.find(item => item.id === selectedId) ?? null;
	// ...
}

これで、状態を「修正」することはまったくなくなりました。選択されたIDの項目がリストにあると、選択されたままです。なければ、レンダリング時に演算されるselectionnullになります。該当項目が見つからないからです。動きは少し変わりました。けれど、itemsへのほとんどの変更が選択を保持するようになったので、明らかな改善です。ただし、あとのすべてのロジックで、selectionを使わなければなりません。selectedIdをもつ項目が存在しないかもしれないからです。

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

商品ページにふたつのボタン([購入]と[チェックアウト])があって、どちらでも購入はできるとしましょう。そして、ユーザーが商品をカートに入れるごとに、通知を表示することにします。通知表示のshowNotification()を、ふたつのボタンのクリックハンドラからそれぞれ呼び出すのは冗長に思えるかもしれません。ロジックをエフェクトに置けば避けられそうです。

function ProductPage({ product, addToCart }) {
	// 🔴 NG: イベント固有のロジックをエフェクト内に置く
	useEffect(() => {
		if (product.isInCart) {
			showNotification(`Added ${product.name} to the shopping cart!`);
		}
	}, [product]);
	const handleBuyClick = () => {
		addToCart(product);
	}
	const handleCheckoutClick = () => {
		addToCart(product);
		navigateTo('/checkout');
	}
	// ...
}

けれど、ここでエフェクトは要りません。むしろ、バグが生じる可能性も高いです。たとえば、アプリケーションがショッピングカートは「記憶」したまま、ページ間のリロードをしたとしましょう。商品をひとたびカートに入れ、通知が表示されたあとは、商品ページ更新のたびに通知は繰り返し表れます。product.isInCartがページを読み込んだときにすでにtrueだからです。そのため、前掲エフェクトはshowNotification()を呼び出します。

あるコードを実行する場所がエフェクトかイベントハンドラか迷ったときは、そもそもなぜ実行するのかご検討ください。エフェクトを用いるのは、コンポーネントがユーザーに表示されたために実行すべきコードのみです。前掲コード例の通知は、ユーザーがボタンを押したことにより示されます。ページが表示されたからではありません。したがって、エフェクトは除き、共通のロジックをひとつの関数にまとめたうえで、ふたつのイベントハンドラからそれぞれ呼び出すべきでしょう。

function ProductPage({ product, addToCart }) {
	// ✅ OK: イベント固有のロジックはイベントハンドラから呼び出す
	const buyProduct = () => {
		addToCart(product);
		showNotification(`Added ${product.name} to the shopping cart!`);		
	}
	const handleBuyClick = () => {
		buyProduct();
	}
	const handleCheckoutClick = () => {
		buyProduct();
		navigateTo('/checkout');
	}
	// ...
}

POSTリクエストを送る

以下のFormコンポーネントは、2種類のPOSTリクエストを送信する例です。

  1. マウント時には分析イベントが送られます。
    • /analytics/event
  2. フォームに入力して送信ボタンを押したとき送られます。
    • /api/register
function Form() {
	const [firstName, setFirstName] = useState('');
	const [lastName, setLastName] = useState('');
	// ✅ OK: コンポーネントが表示されたとき実行されるべきロジック
	useEffect(() => {
		post('/analytics/event', { eventName: 'visit_form' });
	}, []);
	// 🔴 NG: イベント固有のロジックをエフェクトに置いている
	const [jsonToSubmit, setJsonToSubmit] = useState(null);
	useEffect(() => {
		if (jsonToSubmit !== null) {
			post('/api/register', jsonToSubmit);
		}
	}, [jsonToSubmit]);
	const handleSubmit = (event) => {
		event.preventDefault();
		setJsonToSubmit({ firstName, lastName });
	}
	// ...
}

先ほどの例と同じ条件をあてはめてみましょう。

まず、分析のPOSTリクエストはエフェクトのままで構いません。分析イベントを送るのは、フォームが表示されたときだからです(開発中は2度実行されることについては、「React + TypeScript: React 18でコンポーネントのマウント時にuseEffectが2度実行されてしまう」参照)。

けれども、/api/registerへのPOSTリクエストは、フォームが表示されたことによるものではありません。リクエストを送るのはある決まったとき、ユーザーのボタンクリック時です。特定のインタラクションでのみ実行しなければなりません。ふたつめのエフェクトは除いて、POSTリクエストをイベントハンドラに移しましょう。

function Form() {
	const [firstName, setFirstName] = useState('');
	const [lastName, setLastName] = useState('');
	// ✅ OK: コンポーネントが表示されたとき実行されるべきロジック
	useEffect(() => {
		post('/analytics/event', { eventName: 'visit_form' });
	}, []);
	const handleSubmit = (event) => {
		event.preventDefault();
		// ✅ OK: イベント固有のロジックはイベントハンドラに
		post('/api/register', { firstName, lastName });
	}
	// ...
}

ロジックをイベントハンドラとエフェクトのどちらに入れるか選択するとき、考えなければならないのはユーザーの観点です。

  • イベントハンドラ: 特定のインタラクションが発生した。
  • エフェクト: ユーザーが画面にコンポーネントを表示した。

演算処理の連鎖

エフェクトを連鎖させて、状態の一部が他の状態にもとづいて変わるようにしたい場合もあるでしょう。

function Game() {
	const [card, setCard] = useState(null);
	const [goldCardCount, setGoldCardCount] = useState(0);
	const [round, setRound] = useState(1);
	const [isGameOver, setIsGameOver] = useState(false);
	// 🔴 NG: 単に状態を変えるだけのエフェクトが互いに連鎖
	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]);
	const handlePlaceCard = (nextCard) => {
		if (isGameOver) {
			throw Error('Game already ended.');
		} else {
			setCard(nextCard);
		}
	}
	// ...
}

このコードには、ふたつの問題があります。

第1は、効率が悪いことです。コンポーネント(およびその子)は、各状態設定関数が呼び出されるたびに再レンダリングしなければなりません。最悪、不要な再レンダリングの連鎖が3回生じてしまいます。

  • setCardの呼び出しによるレンダリング
  • setGoldCardCountの呼び出しによるレンダリング
  • setRoundの呼び出しによるレンダリング
  • setIsGameOverの呼び出しによるレンダリング

速度には影響なかったとしても、コードが増えるにつれ、記述した「連鎖」は新たな要件にそぐわなくなるかもしれません。たとえば、ゲームの進行の履歴を取って、前後できる機能が加えられたとします。その場合、各状態変数を前の値に戻すことで遡るでしょう。ところが、cardの状態を古い値に改めると、またエフェクトとなり、表示されるデータが更新されてしまいます。このようなコードは大抵硬直的で脆弱です。

この場合でしたら、レンダリング中にできる演算は済ませ、イベントハンドラで状態を改めるのがよいでしょう。

function Game() {
	const [card, setCard] = useState(null);
	const [goldCardCount, setGoldCardCount] = useState(0);
	const [round, setRound] = useState(1);
	// ✅ レンダリング中にできる演算は行う
	const isGameOver = round > 5;
	const 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!');
				}
			}
		}
	}
	// ...
}

このコードはずっと効率的です。また、ゲーム履歴をたどって表示する機能が実装されたとしても、エフェクトの連鎖は生じません。各状態変数を以前の値に改めるのは、イベントハンドラ内で行えばよいからです。複数のイベントハンドラで使い回すロジックがあれば、関数に切り出してそれぞれのイベントハンドラから呼び出せば済みます。

イベントハンドラ内では、状態がスナップショットのように扱われるのでご注意ください。たとえば、setRound(round + 1)を呼び出したあとでも、変数roundの値はユーザーがボタンクリックした時点のままです。演算に新たな値を使う場合は、const nextRound = round + 1のように変数に取り出してください(設定はsetRound(nextRound))。

イベントハンドラではつぎの状態が直接算出できないことはありえます。たとえば、複数のドロップダウンをもつフォームで、つぎのドロップダウンのオプションがそれぞれの前の選択値に依存する場合です。あるいは、データをエフェクトの連鎖で取得することは、ネットワークと同期するためですので差し支えありません。

アプリケーションを初期化する

ロジックには、アプリケーションの読み込み時に1度だけ実行されるべきものもあります。その場合、トップレベルのコンポーネントのエフェクトに置こうと考えるかもしれません。

function App() {
	// 🔴 NG: 1度だけ実行すべきロジックをもつエフェクト
	useEffect(() => {
		loadDataFromLocalStorage();
		checkAuthToken();
	}, []);
	// ...
}

ところが、すぐに気づくのは、開発中はエフェクトが2度実行されることです。これにより、問題が生じるかもしれません。たとえば、認証トークンが無効になることも考えられます。認証の関数が2度続けざまに呼ばれることを想定していない場合です。一般に、コンポーネントは再マウントにも耐えなければなりません。最上位のAppコンポーネントも同じです。もっとも本番環境では、再マウントは起こらないかもしれません。けれど、すべてのコンポーネントにこの同じ制約を課せば、コードの移動や再利用はしやすくなります。ロジックの実行がアプリケーションのロード時に1度だけで、コンポーネントのマウントのたびでないなら、トップレベルに変数を加えればよいでしょう。すでに実行されたかどうかを変数にもたせれば、再マウント時の実行が避けられます。

let didInit = false;
function App() {
	useEffect(() => {
		if (!didInit) {
			didInit = true;
			// ✅ OK: アプリケーションのロード時に1度だけ実行
			loadDataFromLocalStorage();
			checkAuthToken();
		}
	}, []);
	// ...
}

モジュールの初期化時、アプリケーションをレンダリングする前に実行してもよいでしょう。

if (typeof window !== 'undefined') { // ブラウザで実行していることを確認する
	// ✅ OK: アプリケーションのロード時に1度だけ実行
	checkAuthToken();
	loadDataFromLocalStorage();
}
function App() {
	// ...
}

トップレベルのコードは、コンポーネントがインポートされたとき1度だけ実行されます。最終的にレンダリングされるかは問いません。何かのコンポーネントをインポートしたら遅くなったとか、予期せぬ動きに陥らないよう、このやり方は使いすぎないでください。アプリケーション全体の初期化ロジックは、App.jsのようなルートモジュールかエントリポイントモジュールにとどめるようにしましょう。

親コンポーネントに状態の変化を伝える

Toggleコンポーネントを書いていて、内部の状態isOntruefalseの論理値だとしましょう。値を切り替えるイベントは複数(クリックまたはドラッグ)です。Toggleコンポーネントの状態変数isToggleが変わるたびに、親コンポーネントに値を伝えなければなりません。親からプロパティとして渡されたイベントハンドラの関数onChangeをエフェクト内で呼び出せばよいでしょうか。

function Toggle({ onChange }) {
	const [isOn, setIsOn] = useState(false);
	// 🔴 NG: onChangeハンドラの実行が遅くなりすぎる
	useEffect(() => {
		onChange(isOn);
	}, [isOn, onChange])
	const handleClick = () => {
		setIsOn(!isOn);
	}
	const handleDragEnd = (e =>) {
		if (isCloserToRightEdge(e)) {
			setIsOn(true);
		} else {
			setIsOn(false);
		}
	}
	// ...
}

前述の例と同じく、望ましくありません。処理がつぎのように進むからです。

  1. Toggleが状態を改め、Reactは画面を更新します。
  2. Reactはエフェクトを実行して、呼び出されるのが親コンポーネントから渡されたonChange関数です。
  3. 親コンポーネントは自身の状態を更新して、つぎのレンダリングパスが開始されます。

すべてを1回のパスで済ませるべきです。エフェクトは削り、かわりにふたつのコンポーネントの状態をひとつの関数で更新しましょう。

function Toggle({ onChange }) {
	const [isOn, setIsOn] = useState(false);
	const updateToggle = (nextIsOn) => {
		// ✅ OK: イベント時に生じるすべての更新をまとめて行う
		setIsOn(nextIsOn);
		onChange(nextIsOn);
	}
	const handleClick = () => {
		updateToggle(!isOn);
	}
	const handleDragEnd = (event) => {
		if (isCloserToRightEdge(event)) {
			updateToggle(true);
		} else {
			updateToggle(false);
		}
	}
	// ...
}

このやり方なら、Toggleと親コンポーネント両方の状態がイベント中に改められます。Reactは異なるコンポーネントの状態をまとめてバッチ処理するため、結果としてレンダリングパスはひとつだけになるのです。

この例の場合、Toggleコンポーネントから状態をすべて除いてしまっても構いません。isOnは親コンポーネントからプロパティとして受け取ればよいのです。

// ✅ OK: コンポーネントは親が完全に制御する
function Toggle({ isOn, onChange }) {
	const handleClick = () => {
		onChange(!isOn);
	}
	const handleDragEnd = (event) => {
		if (isCloserToRightEdge(event)) {
			onChange(true);
		} else {
			onChange(false);
		}
	}
	// ...
}

状態のリフトアップ」を持ちいれば、親コンポーネントは自身の状態の切り替えだけで子のToggleが完全に制御できます(「Sharing State Between Components」参照)。結果として、親コンポーネントに多くのロジックが集まってしまうことは否めません。逆に、全体的な状態があちこちに分かれて気を配らなければならないことは減らせるのです。ふたつの状態変数を同期させなければならなくなったら、状態のリフトアップも検討してみてください。

データを親に渡す

Childコンポーネントがデータを取得して、親のParentに渡します。エフェクトで実行すればよいでしょうか。

function Parent() {
	const [data, setData] = useState(null);
	// ...
	return <Child onFetched={setData} />;
}

function Child({ onFetched }) {
	const data = useSomeAPI();
	// 🔴 NG: データを親にエフェクトで渡す
	useEffect(() => {
		if (data) {
			onFetched(data);
		}
	}, [onFetched, data]);
	// ...
}

Reactでは、データは親コンポーネントから子に流れるのが基本です。画面に不具合があったら、情報がどこからきたのか、コンポーネントチェーンを遡ります。どのコンポーネントから誤ったプロパティが渡されたのか、あるいは適切でない状態をもっているのか探るのです。子コンポーネントがエフェクトで親の状態を変えると、データの流れはきわめて確かめにくくなってしまいます。子と親のコンポーネントがともに同じデータを使いたいなら、親コンポーネントに取得させ、子に渡せばよいのです。

function Parent() {
	const data = useSomeAPI();
	// ...
	// ✅ OK: データは親から子に渡す
	return <Child data={data} />;
}

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

この方がより単純で、データの流れも予測しやすくなります。データは親から子に送ってください。

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

場合によっては、コンポーネントがReactの状態の外にあるデータを、購読しなければならないかもしれません。データはサードパーティのライブラリや組み込みのブラウザAPIから得たりするでしょう。Reactの知らないうちにデータが変更されるかもしれない場合には、コンポーネントは手動でサブスクライブしなければなりません。そうなると、つぎのようにエフェクトが用いられる場合も多いです。

function useOnlineStatus() {
	// NG: エフェクトから手動でストアのサブスクリプション
	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です。データストアの値がブラウザで変わるたびに、コンポーネントは状態を更新します。

このとき、エフェクトを用いるのは一般的ではあります。けれども、Reactに備わる外部ストアを購読するための専用のフックを使う方が望ましいです。エフェクトは除き、useSyncExternalStoreフックの呼び出しに書き替えましょう。フックは3つの引数を受け取って、ストアの値を返します。

const state = useSyncExternalStore(subscribe, getSnapshot[, getServerSnapshot]);
  • subscribe: ストアに変更があったら呼び出されるコールバックを登録するための関数。
  • getSnapshot: 現在のストアの値を返す関数。
  • getServerSnapshot: サーバーレンダリング時にスナップショットを返すための関数。
function subscribe(callback) {
	window.addEventListener('online', callback);
	window.addEventListener('offline', callback);
	return () => {
		window.removeEventListener('online', callback);
		window.removeEventListener('offline', callback);
	};
}

function useOnlineStatus() {
	// ✅ OK: 組み込みフックで外部ストアのサブスクリプション
	return useSyncExternalStore(
		subscribe, // Reactは同じ関数が渡されたときは再購読しない
		() => navigator.onLine, // クライアントの値をどう取得するか
		() => true // サーバーの値をどう取得するか
	);
}

function ChatIndicator() {
	const isOnline = useOnlineStatus();
	// ...
}

このやり方なら、ミュータブルなデータをReactの状態に手動で同期するよりも、エラーが起きにくいです。通常は、前掲useOnlineStatus()のようなカスタムフックを書き、個々のコンポーネントからコードが重複して実行されないようにします。

データを取得する

エフェクトでデータの取得を開始するアプリケーションは少なくありません。つぎのように、データの取得をエフェクトに書くのもごく一般的です。

function SearchResults({ query }) {
	const [results, setResults] = useState([]);
	const [page, setPage] = useState(1);
	useEffect(() => {
		// 🔴 NG: データ取得のクリーンアップロジックがない
		fetchResults(query, page).then(json => {
			setResults(json);
		});
	}, [query, page]);
	const handleNextPageClick = () => {
		setPage(page + 1);
	}
	// ...
}

データの取得をイベントハンドラに移さなくて結構です。

すると少し前にご説明した、ロジックはイベントハンドラに入れるべきだとしたコード例とどこが違うのかと感じるかもしれません。けれど、入力イベントを主因として取得するのでないことにご注意ください。検索の入力は、多くの場合URLからあらかじめ得られているでしょう。ユーザーがとくに入力はせず、ただ前後にページ遷移する場合もあります。pagequeryがどこからくるか関係ないのです。コンポーネントが表示されている間、resultを現在のpagequeryにしたがって、ネットワークのデータと同期させなければなりません。そのため、エフェクトにロジックを置くのです。

ただし、前掲のコードには不具合があります。たとえば、"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]);
	const handleNextPageClick = () => {
		setPage(page + 1);
	}
	// ...
}

これで、エフェクトはデータが取得されても、最後の要求以外のすべてのリクエストを無視するのです。

競合状態の扱いだけが、データフェッチを実装するときに困る点ではありません。たとえば、つぎのようなことが挙げられます。

  • レスポンスをどうキャッシュするか: ユーザーが[戻る]をクリックしたとき、スピナーではなく前の画面がただちに表示できるようにしたい。
  • サーバーからどうフェッチするか: サーバーでレンダリングされた初期HTMLが、スピナーでなく、フェッチしたコンテンツを含むようにしたい。
  • ネットワークのウォーターフォールをどう避けるか: 子コンポーネントがデータフェッチしなければならないとき、すべての親のフェッチ完了まで待たずに始められるようにしたい。

これらの問題は、Reactにかぎりません。どのUIライブラリにもあてはまることです。しかも、解決が容易ではありません。そのため、最新のフレームワークには、より効率的な組み込みのデータフェッチメカニズムを備えたものがあります。コンポーネントに直接エフェクトを書き込まずに済むのです。

フレームワークは用いない(自作の構築もしない)場合でも、エフェクトからのデータ取得をより効率的で扱いやすくはできます。つぎのコード例のように、取得ロジックをカスタムフック(useData)に切り出すことです。

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

function SearchResults({ query }) {
	const [page, setPage] = useState(1); 
	const params = new URLSearchParams({ query, page });
	const results = useData(`/api/search?${params}`);
	const handleNextPageClick = () => {
		setPage(page + 1);
	}
	// ...
}

さらに、ロジックを加えたくなるかもしれません。エラーの扱いやコンテンツが読み込み中か追跡するといったことです。このようなフックを自作してもよいですし、Reactエコシステムですでに利用できる多くのソリューションからも選べるでしょう。これだけでは、フレームワークの組み込みデータフェッチメカニズムほど効率的ではありません。それでも、データ取得のロジックをカスタムフックに移すことで、あとあと効率的なデータフェッチ戦略が採り入れやすくなります

一般に、エフェクトを書かなければならないときは、機能を一部切り出してカスタムフックにできないかご検討ください。より宣言的で目的に沿ったAPIを用いた前掲useDataがその例です。コンポーネント内から直にuseEffectを呼び出すことはできるかぎり避けると、アプリケーションが保守しやすくなります。

まとめ

この記事では、つぎのような項目についてご説明しました。

  • レンダリング中にできる演算にエフェクトは要りません。
  • コストのかかる演算のキャッシュには、useEffectではなく、useMemoを加えてください。
  • コンポーネントツリー全体の状態をリセットするには、異なるkeyを渡します。
  • 特定の状態を、プロパティの変更に応じてリセットする処理は、レンダリング中に行ってください。
  • コンポーネントが表示されたことにより実行しなければならないコードはエフェクトに、それ以外はイベントに置きます。
  • 複数のコンポーネントの状態を改めたい場合は、ひとつのイベントの中で更新するのが望ましいです。
  • 異なるコンポーネントの状態変数を同期したいときは、状態のリフトアップが検討されるべきでしょう。
  • エフェクトによるデータの取得には、競合状態に陥らないため、クリーンアップを実装しなければなりません。
36
26
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
36
26