HTML5テクニカルノート
React: エフェクトによる同期 ー useEffectのあれこれ
- ID: FN2207001
- Technique: ECMAScript 2015
- Package: React 18.2
本稿執筆現在、「React Docs」(BETA)の公開が進んでいるようです。その中の記事のひとつ「Synchronizing with Effects」が、初心者にとってはもちろん、中級者以上にとってもわかりにくい副作用とuseEffect
フックのあれこれについて詳しく解説しています。その内容をお伝えする目的で書いたのがこの記事です。基本的に情報は網羅しているものの、邦訳ではありません。足りない部分は補ったり、説明の仕方を改めたり、不要と思われる記述は削除しました。コード例の一部は、CodeSandboxに公開しましたので、興味があれば実際にお試しください。
コンポーネントによっては、外部システムと同期しなければならないかもしれません。たとえば、つぎのような場合です。
- React以外のコンポーネントをReactの状態にもとづいて制御する。
- サーバーとの接続を設定する。
- コンポーネントが画面に表示されたら解析ログを送信する。
エフェクトを用いると、コードがレンダリング後に実行されます。それにより、コンポーネントをReact以外のシステムと同期できるのです。
01 エフェクトとは何か、イベントとどう違うのか?
エフェクトに触れる前に、Reactコンポーネントの中のふたつの種類のロジックについて知っておかなければなりません。
- レンダリングコード: コンポーネントの最上位です(「Describing the UI」参照)。プロパティ(
props
)と状態(state
)を受け取って変換し、画面に表示するJSXを返します。レンダリングコードは純粋でなければなりません。数式のように結果を計算するだけで、ほかには何もしません。 - イベントハンドラ: コンポーネント内に入れ子にされた関数です。計算するだけでなく、処理を行います(「Adding Interactivity」参照)。たとえば、以下のような処理がその例です。イベントハンドラには「副作用」(プログラムの状態の変更)があり、特定のユーザーアクション(ボタンのクリックや入力など)によって発生します。
- 入力フィールドを更新する。
- HTTP POSTリクエストを送信して製品を購入する。
- ユーザーを別の画面に遷移させる。
これだけでは、必ずしも十分ではありません。ChatRoom
コンポーネントを考えてみましょう。画面に表示されている間は、つねにチャットサーバーに接続する必要があります。サーバーへの接続は純粋な計算ではなく、副作用です。したがって、レンダリング中は発生しません。ところが、ChatRoom
を表示するというイベント(たとえばクリック)がとくにないのです。
エフェクトを用いると、特定のイベントでなく、レンダリングそのものから生じる副作用が定められます。たとえば、チャットでメッセージを送るのはイベントの処理でしょう。ユーザーが特定のボタンをクリックすることにより、直接実行されるからです。これに対して、サーバー接続の設定はエフェクトになります。コンポーネントを表示させるインタラクションに関係なく、処理されなければならないからです。エフェクトは、画面が更新されたあと、レンダリングプロセスの最後に実行されます。Reactコンポーネントを外部システム(ネットワークやサードパーティライブラリなど)と同期するのに適したタイミングです。
02 エフェクトは要らないこともある
コンポーネントにエフェクトをあわてて加えないでください。エフェクトはReactコードから「一歩引いて」、外部システムと同期するために用いられるのが通常です。これには、ブラウザAPI、サードパーティウィジェット、ネットワークなどが含まれます。ある状態を他の状態にもとづいて調整するだけなら、エフェクトは要らないかもしれません。
03 エフェクトの書き方
エフェクトを書くには、3つの段階を踏みます。
03-01 エフェクトを宣言する
コンポーネントでエフェクトを宣言するには、ReactからuseEffect
フックをインポートします。
import { useEffect } from 'react';
そして、コンポーネントのトップレベルでuseEffect
を呼び出し、副作用は引数の関数本体に記述します。
function MyComponent() { useEffect(() => { // デフォルトではコードがレンダリングのたびに実行される }); return <div /> }
コンポーネントがレンダリングされるたびに、Reactが画面を更新してからuseEffectに渡した(副作用)関数は呼び出されます。つまり、useEffect
は、レンダリングが画面に反映されるまで、コードの一部を「遅延」して実行するのです。
エフェクトを用いて、外部システムとどのように同期するのかみてみましょう。Reactコンポーネント<VideoPlayer>
があるとします。渡したisPlaying
は、ビデオが再生中か一時停止中かを制御するプロパティです。
<VideoPlayer src={src} isPlaying={isPlaying} />; // srcはビデオのパス
VideoPlayerコンポーネントは、JSXで<video>要素を返してレンダリングします。
function VideoPlayer({ src, isPlaying }) { // TODO: isPlayingについての処理 return <video src={src} />; }
ただし、isPlaying
は、<video>
要素に備わるプロパティではありません。再生と一時停止が制御できるようにするには、要素のメソッドplay()
やpause()
をJavaScriptコードで呼び出さなければならないのです。
<video>
DOMノードを参照するには、要素にRefを与えます。
そのうえで、コンポーネントからplay()
やpause()
を呼び出せばよいでしょうか。それだけでは、メソッドの呼び出しでエラーが生じてしまいます。
TypeError Cannot read properties of null (reading 'pause')
VideoPlayer.jsimport { useRef } from 'react'; export const VideoPlayer = ({ src, isPlaying }) => { const ref = useRef(null); if (isPlaying) { ref.current.play(); } else { ref.current.pause(); } return <video ref={ref} src={src} loop playsInline />; };
なお、VideoPlayer
コンポーネントを読み込むルートモジュールApp.js
の記述は、つぎのコード001のとおりです。
コード001■ルートモジュール
App.js
import { useState } from 'react';
import { VideoPlayer } from './VideoPlayer';
import './styles.css';
export default function App() {
const [isPlaying, setIsPlaying] = useState(false);
return (
<div className="App">
<button onClick={() => setIsPlaying(!isPlaying)}>
{isPlaying ? 'Pause' : 'Play'}
</button>
<VideoPlayer
isPlaying={isPlaying}
src="https://interactive-examples.mdn.mozilla.net/media/cc0-videos/flower.mp4"
/>
</div>
);
}
原因は、DOMノードをレンダリング中に操作しようとしていることです。Reactでは、レンダリングはJSXの純粋な計算でなければなりません。DOMの変更などの副作用を含むことは避けるべきです。
しかも、VideoPlayer
がはじめて呼び出されたとき、DOMはまだ存在しません。ReactはつくるべきDOMを、JSXが返されるまで知らないのです。
解決するには、副作用をuseEffect
で包んで、レンダリングの計算から外します。
VideoPlayer.js// import { useEffect, useRef } from 'react'; import { useRef } from 'react'; export const VideoPlayer = ({ src, isPlaying }) => { useEffect(() => { if (isPlaying) { ref.current.play(); } else { ref.current.pause(); } }); };
DOMの更新をエフェクトで包むことにより、Reactは画面を先に描画するのです。副作用はそのあと実行されます。
VideoPlayer
コンポーネントがレンダリングされるとき(初回および再描画時)に起こるのはつぎのようなことです。
Play/Pauseボタンを繰り返し押してみれば、ビデオプレーヤーがisPlaying
プロパティの値に同期していると確かめられるでしょう(サンプル001)。VideoPlayer
モジュールの記述全体は、つぎのコード002のとおりです。
コード002■VideoPlayerモジュール
VideoPlayer.js
import { useEffect, useRef } from 'react';
export const VideoPlayer = ({ src, isPlaying }) => {
const ref = useRef(null);
useEffect(() => {
if (isPlaying) {
ref.current.play();
} else {
ref.current.pause();
}
});
return <video ref={ref} src={src} loop playsInline />;
};
サンプル001■React: Synchronizing with Effects 01
この作例でReactの状態に同期させた「外部システム」は、ブラウザのメディアAPIでした。似たやり方で、従来のReact以外のコード(jQueryプラグインなど)もラップして宣言型Reactコンポーネントにできるでしょう。
ご注意いただきたいのは、ビデオプレーヤーの制御は実際にはもっと複雑なことです。play()
の呼び出しが失敗したり、ユーザーは組み込みのブラウザコントロールで再生または一時停止の操作をするかもしれません。作例はかなり単純化されており、不完全です。
ノート01■エフェクトの無限ループ
デフォルトでは、エフェクトはレンダリングのたびに、そのあと実行されます。つぎのようなコードが、無限ループを起こす理由です。
const [count, setCount] = useState(0); useEffect(() => { setCount(count + 1); });
エフェクトの実行はレンダリングの結果です。状態を設定すると、レンダリングが行われます。レンダリングされれば、そのあと走るのがエフェクトです。すると、新たな状態にもとづいて、再びレンダリングされてしまいます。この繰り返しの結果起こるのが無限ループです。
エフェクトは、通常コンポーネントを外部システムと同期させるために用いられます。外部システムはなく、状態を他の状態にもとづいて調整したいだけであれば、エフェクトは要らないかもしれません。
03-02 エフェクトの依存を定める
エフェクトは、レンダリングのたび、そのあとに実行されます。けれど、それが望ましくない場合もあるでしょう。
- そのために、アプリケーションが遅くなるかもしれません。外部システムとの同期は、つねにすぐ行われるとはかぎらないのです。同期は必要なときだけにかぎった方が望ましいこともあるでしょう。たとえば、チャットサーバーにキー入力のたび再接続しなくてもよさそうです。
- 適切な処理でなくなることもあります。たとえば、コンポーネントのフェードインアニメーションはキー入力のたびに実行するものではないでしょう。アニメーションは一度だけ、コンポーネントがはじめて表示されたときに再生すればよいはずです。
問題を実際に示すため、前の作例のVideoPlayerにconsole.log()
の呼び出しと、親のAppにはテキスト入力フィールドを加えました。テキストを入力すると親コンポーネントの状態(text
)が変わります。すると、エフェクトが再実行され、コンソール出力されることにご注目ください(サンプル002)。
VideoPlayer.jsApp.jsexport const VideoPlayer = ({ src, isPlaying }) => { useEffect(() => { console.log(`Calling video.${isPlaying ? 'play' : 'pause'}()`); }); };
export default function App() { const [text, setText] = useState(''); return ( <div className="App"> <input type="text" value={text} onChange={(event) => setText(event.target.value)} /> </div> ); }
サンプル002■React: Synchronizing with Effects 02
Reactに必要のないエフェクトを再実行しないよう指示できます。そのときuseEffect
の呼び出しに加えるのが、依存を定めた第2引数の配列です。まずは、前掲サンプル002のuseEffect
に空の配列[]
を与えてみましょう。
VideoPlayer.jsuseEffect(() => { // ... }, []);
すると、Linterからつぎのような警告が示されるはずです。しかも、ボタンを押してもビデオが再生されません。それは、空の配列[]
は依存がないことを示し、副作用は1度(マウント時とアンマウント時)だけしか実行されないからです(「ヒント:副作用のスキップによるパフォーマンス改善」参照)。
React Hook useEffect has a missing dependency: 'isPlaying'. Either include it or remove the dependency array.
警告が告げるとおり、依存配列にプロパティisPlaying
を加えなければなりません。そうすれば、依存するisPlaying
の値が変わったときのみ、副作用は再実行されるのです。
VideoPlayer.jsuseEffect(() => { if (isPlaying) { // プロパティ値を用いて判別 // ... } else { // ... } }, [isPlaying]); // 依存するプロパティを配列に加える
依存配列の要素をReactは監視します。前のレンダリングから値が変わっていなければ、副作用の再実行は省かれるのです。テキストフィールドに入力して親コンポーネントの状態が変わっても、isPlaying
の値はそのままです。したがって、副作用は働きません。ボタンが押されて依存値が変わったときだけ、副作用は再実行されるのです。
サンプル003■React: Synchronizing with Effects 03
依存配列には、複数の依存が含められます。Reactが副作用を再実行するのは、そのうちのいずれかの値が変わったときです。すべての依存値が前のレンダリング時と同じなら、副作用は働きません。Reactが依存値の比較に用いるのは、Object.is()
です。詳しくは「useEffect」をご参照ください。
Linterが依存に含めるよう警告した値を、あえて副作用の再実行から外したい場合があるかもしれません。そのときは、依存配列から個別に除いてください。
ノート02■useEffect
の第2引数の依存配列
useEffect
の第2引数の依存配列を省くのと、空の配列[]
を渡すのでは意味が大きく異なります。
useEffect(() => { // 毎レンダリング後に実行される。 }); useEffect(() => { // マウントおよびアンマウント時(コンポーネントの表示・非表示のとき)に1度だけ実行される。 }, []); useEffect(() => { // マウントおよびアンマウント時に加えて前回レンダリング時からaまたはbの値が変わったときに再実行される。 }, [a, b]);
ノート03■なぜref
は依存配列に含めなくてよいのか
VideoPlayer
コンポーネントのuseEffect
に渡した関数は、isPlaying
プロパティだけでなく、変数ref
も参照しています。変数は依存配列に含めなくてよいのでしょうか。
VideoPlayer.jsexport const VideoPlayer = ({ src, isPlaying }) => { const ref = useRef(null); useEffect(() => { if (isPlaying) { ref.current.play(); } else { ref.current.pause(); } }); };
これはrefオブジェクトが変化しない同質性をもつからです。Reactは、ひとつのuseRef
呼び出しからは、レンダリングのたびにつねに同じオブジェクトが得られることを保証します。変わらないので、副作用の再実行は起こしません。つまり、依存に含めても含めなくても同じなのです。含めてもとくに問題はありません。
useState
が返す配列の第2要素(インデックス1)の設定関数も、変化しない同質性を備えます。ですから、依存からは省かれることが少なくありません。Linterが警告を発しなければ、除いて構わないでしょう。
つねに変化しない依存を省けるのは、Linterがオブジェクトが変わらないと「確認」できる場合だけです。たとえば、ref
が親コンポーネントから渡されたら、依存配列に加えなければなりません。親コンポーネントがつねに同じref
を渡すとはかぎらないからです。いくつかのオブジェクトから条件によってひとつが選ばれるのかもしれません。だとすれば、エフェクトは受け取るref
に依存します。
03-03 必要に応じてクリーンアップ関数を加える
ChatRoom
コンポーネントを書いて、それが表示されたときチャットサーバーに接続したいとします。与えられたAPIのcreateConnection()
が返すのは、メソッドconnect()
とdisconnect()
を備えたオブジェクトです。ユーザーがコンポーネントを表示している間接続しつづけるにはどうすればよいでしょう。
まずは、エフェクトのロジックから書き始めます。
useEffect(() => { const connection = createConnection(); connection.connect(); });
これでは、アプリケーションの動きが遅くなってしまいます。レンダリングのたびに、チャットに接続するからです。そこで、依存配列を加えます。
useEffect(() => { }, []);
副作用のコードはプロパティも状態も参照していません。したがって、依存配列は空[]
です。つまり、Reactがこのエフェクトを実行するのは、コンポーネントが「マウント」されたときだけになります。それは、コンポーネントがはじめて画面に表示されたときです。
そこで、つぎのコードを試してみます。
chat.jsChatRoom.jsexport const createConnection = () => { // 実際にはサーバーへの接続と切断を実装する。 const connect = () => { console.log('Connecting...'); }; const disconnect = () => { console.log('Disconnected.'); }; return { connect, disconnect }; };
import { useEffect } from 'react'; import { createConnection } from './chat.js'; import './styles.css'; export default function ChatRoom() { useEffect(() => { const connection = createConnection(); connection.connect(); }, []); return ( <div className="App"> <h1>Welcome to the chat!</h1> </div> ); }
このエフェクトはマウントでのみ実行されるため、コンソールには「Connecting...」と1回だけ出力されると予想したかもしれません。ところが、コンソールを確かめると、同じ出力が2回行われるのです。これはなぜでしょうか。
(2) Connecting...
ChatRoom
コンポーネントが大きなアプリケーションの一部で、多くの異なる画面が備わっていたとします。ユーザーがはじめに開くのはChatRoom
のページです。コンポーネントはマウントされて、副作用のconnection.connect()
が呼び出されます。そのあと、別の画面たとえば設定ページに移ったとしましょう。ChatRoom
コンポーネントがアンマウントされます。 そのうえで、ユーザーが[戻る]をクリックしたら、ChatRoom
はふたたびマウントされるのです。すると、2度目の接続が設定されます。けれど、はじめの接続が破棄されていません。ユーザーがアプリケーション内のページを移動することにより、接続がたまってしまうのです。
こうしたバグは、手作業による広範なテストを行わないかぎり、見逃されやすくなります。問題がすばやく見つけられるように、Reactは開発時にはすべてのコンポーネントをはじめのマウントの直後に再マウントするのです。「Connecting...」のログを2回見ることで、本当の問題に気づきやすくなります。コンポーネントがアンマウントされたとき、コードは接続を閉じていないということです。
この問題を解決するには、useEffect
からクリーンアップ関数を返してください。
useEffect(() => { const connection = createConnection(); connection.connect(); return () => { connection.disconnect(); }; }, []);
Reactがクリーンアップ関数を呼び出すのは、つねにエフェクトが再実行される前です。そして、最後にコンポーネントがアンマウント(削除)されるときに、クリーンアップを行います。書き替えたChatRoom
コンポーネントの記述全体は、つぎのコード003のとおりです。実際の動きについては、サンプル004のCodeSandbox作例をお確かめください。
コード003■ChatRoomモジュール
ChatRoom.js
import { useEffect } from 'react';
import { createConnection } from './chat.js';
import './styles.css';
export default function ChatRoom() {
useEffect(() => {
const connection = createConnection();
connection.connect();
return () => {
connection.disconnect();
};
}, []);
return (
<div className="App">
<h1>Welcome to the chat!</h1>
</div>
);
}
サンプル004■React: Synchronizing with Effects 04
開発時のコンソールには、つぎの3つの出力が示されるでしょう。
- Connecting...
- Disconnected.
- Connecting...
これは開発時における正しい動作です。コンポーネントの再マウントにより、Reactはナビゲーションで遷移して戻っても、コードが破綻しないことを確かめます。切断したのち再接続することは、まさに適切な動作です。クリーンアップが正しく実装されていれば、ユーザーから見て1度だけエフェクトを実行することと、そのあとクリーンアップしてから再実行することに差はありません。接続・再接続の呼び出しを1回余分に行うのは、Reactがコードにバグがないか検証するためです。これは正常なことで、除こうとするべきではありません。
開発ではなく本番環境では「Connecting...」の出力は1度だけです。コンポーネントの再マウントは、開発時のみクリーンアップが必要なエフェクトを見つけられるように行われます。StrictMode
をオフにすれば、開発時のこの機能は働きません。けれど、オンにしておくことをお勧めします。前述のような多くのバグを見つけることができるからです。
04 開発時に2度実行されるエフェクトの扱い方
Reactは、開発時はあえてコンポーネントを再マウントします。前述のようなバグを見つけるのに役立てるためです。つまり、正しい問いは「エフェクトの実行を1度だけにする方法」ではありません。「エフェクトが再マウントされたあともどうやって正しく動作させるか」です。
通常は、クリーンアップ関数を実装して対応します。クリーンアップが行うのは、エフェクトの実行していたことをすべて停止あるいはもとに戻すことです。大抵のユーザーは、(本番環境の)1度だけのエフェクトと、(開発時の)副作用→クリーンアップ→副作用の実行の違いには気づきません。
エフェクトの多くに共通するパターンをご紹介しましょう。
04-01 React以外のウィジェットの制御
Reactで書かれていないUIウィジェットを組み込む場合です。たとえば、地図のコンポーネントをページに加えるとしましょう。地図はsetZoomLevel()
メソッドを備えていて、ズーム率はReactコードの状態変数zoomLevel
と同期させます。つぎのコードがエフェクトの記述です。
useEffect(() => { const map = mapRef.current; map.setZoomLevel(zoomLevel); }, [zoomLevel]);
この場合には、クリーンアップは要りません。開発時に、Reactはエフェクトをはじめに2度実行します。けれど、setZoomLevel
を同じ値で2回呼び出したからといって変化はないので、問題にはならないのです。動きが少しもたつくことはありえます。それでも、再マウントが起こるのは開発時のみです。本番環境では、結局問題ありません。
APIによっては、2回続けて呼び出せないかもしれません。たとえば、組み込みの<dialog>
要素に対するshowModal()
メソッドです。続けざまに2度呼び出せばエラーになるでしょう。クリーンアップ関数を加えて、ダイアログは閉じなければなりません。
useEffect(() => { const dialog = dialogRef.current; dialog.showModal(); return () => dialog.close(); }, []);
開発環境では、エフェクトはshowModal()
に続けてすぐにclose()
を呼び出し、そしてshowModal()
の再実行となります。動きとしては、ユーザーが見るshowModal()
を1度だけ呼び出すのと変わりません。本番環境になれば、エフェクトのはじめの実行は1回です。
04-02 イベントのサブスクライブ
エフェクトが何かをサブスクライブしている場合、クリーンアップ関数が行うべきはサブスクライブの解除です。
useEffect(() => { function handleScroll(e) { console.log(e.clientX, e.clientY); } window.addEventListener('scroll', handleScroll); return () => window.removeEventListener('scroll', handleScroll); }, []);
このコード例は開発環境では、エフェクトはaddEventListener()
に続けてハンドラはただちにremoveEventListener()
で除き、同じハンドラをaddEventListener()
でふたたび加えています。したがって、有効なサブスクリプションは1度にひとつだけです。動きとしては、ユーザーが見るaddEventListener()
を1度だけ呼び出すのと変わりません。本番環境になれば、エフェクトのはじめの実行は1回です。
04-03 アニメーションの実行
エフェクトで何かをアニメーションさせたら、クリーンアップ関数が行うべきなのは初期値にリセットすることです。
useEffect(() => { const node = ref.current; node.style.opacity = 1; // アニメーション開始 return () => { node.style.opacity = 0; // 初期値にリセット }; }, []);
このコード例のエフェクトは、はじめアニメーションに使う不透明度のプロパティ値を1に設定しています。開発環境では、ただちにクリーンアップ関数が0にリセットし、副作用の再実行で与えられる値が改めて1です。動きとしては、ユーザーが見るはじめから1であるのと変わりません。本番環境になれば、エフェクトのはじめの実行は1度きりです。トゥイーンをサポートするサードパーティのアニメーションライブラリで再生する場合は、クリーンアップ関数はタイムラインを初期状態にリセットすることになるでしょう。
04-04 データの読み込み
エフェクトで実行するのがデータの読み込みのときは、クリーンアップ関数が行うのは読み込みの中断か、結果の無視です。
useEffect(() => { let ignore = false; async function startFetching() { const json = await fetchTodos(userId); if (!ignore) { setTodos(json); } } startFetching(); return () => { ignore = true; }; }, [userId]);
すでに発信したネットワークリクエストは、なかったことにはできません。けれど、クリーンアップは、もう関係なくなったデータの受信が、そのあとアプリケーションに影響を与えないようにすべきです。このコード例では、userId
が変わったらクリーンアップが前のレスポンスは無視して、副作用により新たなuserId
でデータを取得します。
ただし、開発環境ではふたつのデータ取得が実行されるはずです。けれど、このコード例では問題ありません。はじめのエフェクトには、ただちにクリーンアップがかかり、関数の外の変数ignore
にtrue
が与えられます。レスポンス(json
)は受け取っても、値の条件判定によりその先の処理(setTodos()
)には渡されないからです。
もちろん、本番環境ではリクエストははじめにひとつしか送られません。開発時にふたつめのリクエストが気になるようでしたら、重複した発信は除くのが最善です。レスポンスはコンポーネント間でキャッシュします。
function TodoList() { const todos = useSomeDataLibrary(`/api/user/${userId}/todos`); // ... }
これにより、開発体験が向上するだけではありません。アプリケーションの動きも速く感じられます。たとえば、ユーザーが[戻る]ボタンを押しても、データがキャッシュされているので、読み込みを待たずに済むのです。このようなキャッシュは自作しても構いません。あるいは、既存のさまざまな選択肢を用いて、エフェクトで手動フェッチすることもできます。
ノート04■エフェクトでデータをどのように取得するのがよいか
エフェクトの中にデータ取得のコードを書くのは、とくに完全にクライアント側のアプリケーションなら、一般的なやり方です。ただ、手間がかるうえに、問題も少なくありません。
- エフェクトはサーバー上では実行されない: サーバーでレンダリングされる初期HTMLは、データのないロード状態だけが含まれるということです。クライアントコンピューターは、JavaScriptコードもすべてダウンロードしたうえで、アプリケーションをレンダリングすることになります。データをロードしなければならないと知るためです。あまり効率的とはいえません。
- エフェクトで直接フェッチすると「ネットワークウォーターフォール」ができやすい: 親コンポーネントをレンダリングすると、必要なデータがフェッチされます。そのあと、子コンポーネントをレンダリングすると、またそれぞれのデータの取得が始まるのです。ネットワークがあまり早くない場合、すべてのデータを並行してフェッチするよりも大幅に時間がかかります。
- エフェクトで直接フェッチすると通常データのプリロードもキャッシュもしないことになる: たとえば、コンポーネントのマウントを外し、そのあと再マウントした場合、ふたたびデータを取得し直さなければなりません。
- あまり人間工学的ではない: フェッチの呼び出しには、かなりの定型的なコードを書かなければなりません。競合状態などのバグに悩まされないようにするためです。
上記に掲げた問題は、Reactにかぎったことではありません。マウント時にデータを取得するなら、どのようなライブラリにも当てはまります。ルーティングと同じく、うまくデータフェッチするのはたやすくありません。お勧めするのは、つぎのふたつのやり方のどちらかです。
- フレームワークを使ってその組み込み機能でデータフェッチする: 最新のReactフレームワークには、統合データフェッチ機構が備わっています。効率的で上記のような問題に悩まされません。
- クライアントサイドキャッシュの使用または構築を検討する: 人気のあるオープンソースソリューションには、以下の3つがあります。独自のソリューションを構築しても構いません。その場合、内部的にエフェクトを用いつつ、リクエストの重複排除、レスポンスのキャッシュ、ネットワークのウォーターフォールの回避(データのプリロードあるいはデータ要件のルートへの引き上げ)のロジックが加わるでしょう。
- React Query
- useSWR
- React Router(6.4以降)
ふたつのいずれも適していない場合には、エフェクトで直接データを取得しても差し支えありません。
04-05 分析の送信
ページを訪問したときに分析のイベントを送るつぎのコードについて考えてみましょう。
useEffect(() => { logVisit(url); // POSTリクエストを送る。 }, [url]);
開発段階では、logVisit
ははじめ同じURLに対して2度呼ばれます。これは避けたいと感じるかもしれません。けれど、コードはそのままにしておくのがよいでしょう。前項のコード例と同じように、ユーザーから見た動きは、実行が1回だろうと2回だろうと違わないからです。実践的な場面では、logVisit
は開発時には何もしないでしょう。開発用マシンからのログが、本番用のメトリクスを歪めてしまうことは避けたいからです。開発時、コンポーネントはファイルを保存するたびに再マウントされます。余計な訪問が送信されたからといって気にはならないでしょう。
本番環境では訪問ログが再送信されることはないのです。
送信される分析イベントのデバッグは、アプリケーションをステージング環境(本番モードで実行)にデプロイすればできます。あるいは、StrictMode
は一時的にオプトアウトして、開発専用の再マウントチェクをしてもよいでしょう。エフェクトに替えて、ルート変更イベントハンドラから分析を送信する手もあります。さらに正確な分析をするために使えるのが、交差オブザーバー(intersection observers)です。どのコンポーネントがビューポートにあり、どれくらいの間表示されているか調べるのに役立ちます。
04-06 エフェクトなし: アプリケーションの初期化
ロジックによっては、アプリケーションの起動時に1度だけ実行すれば済みます。その場合には、コンポーネントの外に出してしまえばよいでしょう。
if (typeof window !== 'undefined') { // ブラウザで実行されていることを確認。 checkAuthToken(); loadDataFromLocalStorage(); } function App() { // ... }
コンポーネントの外に置いたロジックは、ブラウザがページを読み込んだあと1度だけ実行されることが保証されます。
04-07 エフェクトなし: 製品の購入
クリーンアップ関数を書いても、エフェクトが2回実行されてしまうと、ユーザーから見た結果は1回同じにできないこともあります。たとえば、エフェクトが製品購入のようなPOSTリクエストを送る場合です。
useEffect(() => { // 🔴 NG: 開発時、副作用が2度実行されると、コードに問題が発生する。 fetch('/api/buy', { method: 'POST' }); }, []);
製品の重複購入は避けなければなりません。つまり、ロジックをエフェクトに置いてはいけないのです。ユーザーが他のページに遷移して、[戻る]をクリックしたらどうなるでしょう。エフェクトは再実行されます。製品を購入するのは、ユーザーのページ訪問時ではありません。ユーザーが購入ボタンをクリックしたときです。
購入はレンダリングで引き起こされてはなりません。特定のインタラクションによって実行されるべきです。そうすれば、購入するのは1度、インタラクション(クリック)が起こったときとなります。/api/buy
のリクエストをエフェクトから除き、購入ボタンのイベントハンドラに移すのが適切です。
function handleClick() { // ✅ 購入はイベントで特定のインタラクションから実行する。 fetch('/api/buy', { method: 'POST' }); }
再マウントでアプリケーションのロジックが壊れるというのは、通常バグの存在を示すということです。ユーザーから見てページの訪問は、ページにアクセスして別ページへのリンクをクリックしたうえで[戻る]のと、結果が同じでなければなりません。Reactは、コンポーネントがこの原則から外れていないことを、開発時の再マウントにより確かめているのです。
05 エフェクトの結果を試す
以下のコード004のコンポーネントPlayground
で、エフェクトの結果を確かめてみましょう。そのためのサンプル005をCodeSandboxに掲げました。
この作例は、setTimeout()
で入力フィールドのテキストを、副作用の実行から3秒後にコンソール出力します。クリーンアップ関数が行うのは、clearTimeout()
の呼び出しによる保留中のタイムアウトの解除です。
コード004■Playgroundモジュール
Playground.js
import { useState, useEffect } from 'react';
export const Playground = () => {
const [text, setText] = useState('a');
useEffect(() => {
const onTimeout = () => {
console.log(`⏰ ${text}`);
};
console.log(`🔵 Schedule "${text}" log`);
const timeoutId = setTimeout(onTimeout, 3000);
return () => {
console.log(`🟡 Cancel "${text}" log`);
clearTimeout(timeoutId);
};
}, [text]);
return (
<>
<label>
What to log:{' '}
<input
value={text}
onChange={({ target: { value } }) => setText(value)}
/>
</label>
<h1>{text}</h1>
</>
);
};
[Mount the component]ボタンのクリックで、Playground
コンポーネントはマウントされ、エフェクトが開始します。
サンプル005■React: Synchronizing with Effects 05
コンソールにはじめに出力されるのは、つぎの3つのログです。
- 🔵 Schedule "a" log
- 🟡 Cancel "a" log, and
- 🔵 Schedule "a" log
そして、3秒後につぎの出力が示されるでしょう。
- ⏰ a
出力に加わった副作用のクリーンアップと再実行のログは、開発時にReactがコンポーネントを1度再マウントしていることによります。こうして、クリーンアップが正しく実装されていることを確かめられるのです。
つぎに、テキスト入力フィールドに「bc」を加え、「abc」としてみましょう。ひと文字入力するごとに、前の副作用はキャンセルされ、新たなスケジュールが加わります。
- 🟡 Cancel "a" log
- 🔵 Schedule "ab" log
- 🟡 Cancel "ab" log
- 🔵 Schedule "abc" log
- ⏰ abc
新たなレンダリングのエフェクトが加えられる前に、Reactはつねに前の副作用をクリーンアップするのです。どれだけ素早く入力しても、1度に最大でひとつしかタイムアウトはスケジュールされません。
Playground
コンポーネントがマウントされていると、ボタンは[Unmount the component]に変わっているはずです。テクストフィールドに続けて入力し、すぐにこのボタンを押してみてください。マウントが外れることにより、最後のレンダリングのエフェクトがクリーンアップされるでしょう。タイムアウトは解除されて、コンソールには何も出力されません。
最後に、Playgroundコンポーネントに手を加えて、クリーンアップ関数はコメントアウトします。これで、タイムアウトは解除されません。
Playground.jsexport const Playground = () => { useEffect(() => { /* return () => { console.log(`🟡 Cancel "${text}" log`); clearTimeout(timeoutId); }; */ }, [text]); };
改めてPlayground
コンポーネントをマウントすると、はじめのエフェクトがクリーンアップされないので、タイムアウトのログはふたつ出力されます。
- 🔵 Schedule "a" log
- 🔵 Schedule "a" log
- ⏰ a
- ⏰ a
では、テキストフィールドに「bc」と入力を加えて、「abc」にしてみます。クリーンアップされませんから、ふたつのタイムアウトがスケジュールされました。
- 🔵 Schedule "ab" log
- 🔵 Schedule "abc" log
ここで問題です。3秒後にはどのようなログが出力されるでしょうか。最終的に入力された「abc」が2回表示されると予測したかもしれません。実際の出力はつぎのとおりです。
- ⏰ ab
- ⏰ abc
各エフェクトは、それぞれのレンダリングの状態変数(text
)の値を保持するからです。最終的な状態編数値を参照するのではありません。つまり、エフェクトはレンダリングごとに分離されているのです。この仕組みについてさらに詳しくは「クロージャ」をご参照ください。
ノート05■レンダリングごとにエフェクトがある
useEffect
は、レンダリング出力にふるまいを「つけ加える」と捉えられます。たとえば、つぎのChatRoom
コンポーネントのエフェクトです。
ChatRoom.jsexport default function ChatRoom({ roomId }) { useEffect(() => { const connection = createConnection(roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h1>Welcome to {roomId}!</h1>; }
ユーザーがこのアプリケーションを操作すると何が起こるのか、細かく見ていきましょう。
初期レンダリング
親コンポーネントがChatRoom
にプロパティroomId
として、値"general"
を与えてページに差し込んでいたとします。すると、コンポーネントが返すJSXは、つぎのの記述と変わりません。「Rendering takes a snapshot in time」にならって、コードに具体的な値をあてはめながら考えましょう。
ChatRoom.js// 最初のレンダリングのJSX(roomId="general") return <h1>Welcome to general!</h1>;
エフェクトもレンダリング出力に含まれます。したがって、初期レンダリングのエフェクトは、結果としてつぎの記述と同じです。
ChatRoom.js// 初期レンダリング時のエフェクト(roomId="general") () => { const connection = createConnection('general'); connection.connect(); return () => connection.disconnect(); }, // 初期レンダリング時の依存(roomId="general") ['general']
Reactはこのエフェクトを実行して、チャットルーム「general」に接続します。
同じ依存における再レンダリング
<ChatRoom roomId="general" />
が再レンダリングされるとしましょう。JSX出力は変わりません。
ChatRoom.js// 2度目のレンダリングのJSX(roomId="general") return <h1>Welcome to general!</h1>;
Reactはレンダリング出力に違いがないと知ります。そのため、DOMは更新しません。
2度目のレンダリングのエフェクトは、つぎのように捉えられるでしょう。
ChatRoom.js// 2度目のレンダリング時のエフェクト(roomId="general") () => { const connection = createConnection('general'); connection.connect(); return () => connection.disconnect(); }, // 2度目のレンダリング時の依存(roomId="general") ['general']
Reactは初期と2度目のレンダリングの依存を比べて、同じ['general']
だと確かめます。そして、依存配列の要素がすべて変わらないなら、2度目のレンダリングのエフェクトは無視されるのです。副作用は実行されません。
異なる依存における再レンダリング
つぎに、ユーザーが訪れたのは<ChatRoom roomId="travel" />
です。ChatRoom
コンポーネントは、異なるJSXを返します。
ChatRoom.js// 3度目のレンダリングのJSX(roomId="travel") return <h1>Welcome to travel!</h1>;
すると、ReactはDOMを更新しなければなりません。「Welcome to」に続くroomId
のテキストは「general」から「travel」に改められます。
3度目のレンダリングのエフェクトも、つぎのように変わるでしょう。
ChatRoom.js// 3度目のレンダリング時のエフェクト(roomId="travel") () => { const connection = createConnection('travel'); connection.connect(); return () => connection.disconnect(); }, // 3度目のレンダリング時の依存(roomId="travel") ['travel']
Reactは3度目のレンダリングの依存配列['travel']
を2度目の['general']
と比べます。依存が同じではありません。Object.is('travel', 'general')
はfalse
です。したがって、エフェクトの実行は省けません。
Reactが3度目のレンダリングでエフェクトを加える前に、直前に実行した副作用はクリーンアップしなければなりません。2度目のレンダリングでは、エフェクトは省きました。つまり、Reactがクリーンアップすべきは、最初のレンダリングのエフェクトです。はじめのレンダリングのエフェクトまで遡ると、クリーンアップはdisconnect()
を呼び出していることがわかります。そこで、createConnection('general')
によりつくられた接続を切るのです。アプリケーションはチャットルーム「general」から抜けます。
そのうえで実行されるのが、3度目のレンダリングのエフェクトです。こうして、チャットルーム「travel」に接続します。
アンマウント
最後の遷移は、ユーザーの退室です。ChatRoom
コンポーネントはマウントが外れます。そして、Reactが実行するのは、最後のエフェクトのクリーンアップ関数です。つまり、3度目のレンダリングのエフェクトがクリーンアップされ、createConnection('travel')
でつくられた接続は破棄されます。こうして、アプリケーションは「travel」ルームから切断されるのです。
開発時のみの動作
StrictMode
が有効のとき、Reactはすべてのコンポーネントについて、マウント後に再マウントします(状態とDOMは保持)。クリーンアップしなければならないエフェクトが見つけやすくなり、競合状態などのバグが早期に発見できるのです。また、開発中にファイルを保存するたびに、Reactはエフェクトを再マウントします。これらの動作は、いずれも開発専用です。
06 まとめ
- イベントと異なり、エフェクトはレンダリングそのものから引き起こされます。特定のインタラクションには関係しません。
- エフェクトを用いると、コンポーネントを外部システム(サードパーティのAPIやネットワークなど)と同期できます。
- デフォルトでは、エフェクトは各レンダリング後に実行されます(初期レンダリングを含む)。
- 最後のレンダリングとすべての依存の値が変わらないとき、Reactはエフェクトを実行しません。
- 依存はエフェクトのコードから決まります。Linterは依存を自動的に判別します。
- 空の依存配列
[]
は、エフェクトをコンポーネントのマウント時に実行します。それは、コンポーネントが画面に表示されたときです。 - StrictModeが有効のとき、開発時はReactがコンポーネントを2度マウントします。エフェクトをストレステストするためです。
- 再マウントによりエフェクトが破綻したときは、クリーンアップ関数を実装すべきです。
- Reactのクリーンアップ関数は、つぎのエフェクトの実行前とマウントが外れるときに呼び出されます。
作成者: 野中文雄
作成日: 2022年07月11日
Copyright © 2001-2020 Fumio Nonaka. All rights reserved.