エフェクトからイベントを分離する
イベントハンドラは、ユーザが同じ操作を繰り返した場合にのみ再実行されます。エフェクトはイベントハンドラとは異なり、props や state 変数のようなそれが読み取る値が前回のレンダー時と異なる場合に「再同期」を行います。また両方の動作をミックスさせて、ある値には反応して再実行されるが他の値には反応しないというエフェクトが欲しくなる場合もあります。このページでは、その方法を説明します。
このページで学ぶこと
- イベントハンドラとエフェクトのどちらを選ぶか
- エフェクトがリアクティブで、イベントハンドラがリアクティブでない理由
- エフェクトのコードの一部をリアクティブにしたくない場合の対処法
- エフェクトイベントとは何か、そしてエフェクトからエフェクトイベントを分離する方法
- エフェクトイベントを使用してエフェクトから最新の props と state を読み取る方法
イベントハンドラとエフェクトのどちらを選ぶか
まず、イベントハンドラとエフェクトの違いについておさらいしておきましょう。
チャットルームのコンポーネントを実装している場合を想像してください。要件は次のようなものです。
- コンポーネントは選択中のチャットルームに自動的に接続する。
- “Send” ボタンをクリックすると、チャットにメッセージが送信される。
このためのコードはすでに実装されているが、それをどこに置くか迷っているとしましょう。イベントハンドラを使うべきでしょうか、エフェクトを使うべきでしょうか。このような質問に答える必要がある場合は常に、なぜそのコードを実行する必要があるのかを考えるようにしてください。
イベントハンドラは具体的なユーザ操作に反応して実行される
ユーザの立場からすると、メッセージの送信とは、“Send” という特定のボタンがクリックされたから起こるべきものです。それ以外のタイミングや理由でメッセージが送信されてしまうとユーザは怒ることでしょう。これが、メッセージの送信はイベントハンドラで行うべき理由です。イベントハンドラを使うことで、特定のユーザ操作を処理できます。
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
function handleSendClick() {
sendMessage(message);
}
// ...
return (
<>
<input value={message} onChange={e => setMessage(e.target.value)} />
<button onClick={handleSendClick}>Send</button>
</>
);
}
イベントハンドラを使うことにより、ユーザがボタンを押したときにだけ sendMessage(message)
が実行される、ということが保証されるのです。
エフェクトは同期が必要なときに常に実行される
コンポーネントはチャットルームへの接続を維持する必要がある、という要件もあるのでした。そのためのコードはどこに記述すべきでしょうか?
そのコードを実行する理由は、何か特定のユーザ操作ではありません。ユーザがチャットルーム画面に移動した理由や方法は問題ではありません。ユーザがチャットルームの画面を見てそれを操作できるようになった以上、そのコンポーネントは選択されたチャットサーバへの接続を維持する必要があります。チャットルームコンポーネントがアプリの初期画面であって、ユーザは何の操作も行っていないという場合でも、やはり接続は必要です。したがってエフェクトを使用するべきです。
function ChatRoom({ roomId }) {
// ...
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);
// ...
}
このコードにより、ユーザが行った特定の操作とは関係なく、現在選択されているチャットサーバへの接続が常にアクティブであると確信することができます。ユーザがアプリを開いただけの場合でも、別のルームを選んだ場合でも、他の画面に移動して戻ってきた場合でも、このエフェクトによりコンポーネントが現在選択されているルームと同期し、必要なとき常に再接続を行うことが保証されます。
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId }) { const [message, setMessage] = useState(''); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.connect(); return () => connection.disconnect(); }, [roomId]); function handleSendClick() { sendMessage(message); } return ( <> <h1>Welcome to the {roomId} room!</h1> <input value={message} onChange={e => setMessage(e.target.value)} /> <button onClick={handleSendClick}>Send</button> </> ); } export default function App() { const [roomId, setRoomId] = useState('general'); const [show, setShow] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <button onClick={() => setShow(!show)}> {show ? 'Close chat' : 'Open chat'} </button> {show && <hr />} {show && <ChatRoom roomId={roomId} />} </> ); }
リアクティブな値とリアクティブなロジック
直感的にはイベントハンドラとは、例えばボタンをクリックするなど、常に「手動」でトリガされるものだと言えるでしょう。一方、エフェクトは「自動」であり、同期を保つために必要なだけ実行・再実行されます。
しかし、もっと正確な考え方があります。
コンポーネントの本体部分で宣言された props、state および変数のことをリアクティブな値 (reactive value) と呼びます。この例では、serverUrl
はリアクティブな値ではありませんが、roomId
と message
はリアクティブな値です。これらはレンダーのデータフローに関わる値です。
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
}
このようなリアクティブな値は、再レンダー時に変化する可能性があります。例えばユーザは message
を編集したり、ドロップダウンで別の roomId
を選択したりするかもしれません。イベントハンドラとエフェクトは、それぞれ異なる方法で値の変化に対応します。
- イベントハンドラ内のロジックはリアクティブではない。ユーザが同じ操作(クリックなど)を再度行わない限り、再度実行されることはない。イベントハンドラは値の変化に「反応」することなく、リアクティブな値を読み取ることができる。
- エフェクト内のロジックはリアクティブである。エフェクトがリアクティブな値を読み取る場合、依存配列としてそれを指定する必要がある。その後再レンダーによって値が変化した場合、React は新しい値でエフェクトのロジックを再実行する。
この違いを理解するため、先ほどの例をもう一度見てみましょう。
イベントハンドラ内のロジックはリアクティブではない
このコードをご覧ください。このロジックはリアクティブであるべきでしょうか、そうではないでしょうか?
// ...
sendMessage(message);
// ...
ユーザの観点からは、message
が変化することがメッセージを送りたいという意味になるわけではありません。あくまでユーザが入力の最中だということでしかありません。つまり、メッセージ送信のロジックは、リアクティブであってはならないということです。リアクティブな値が変わったからと言って、再び実行されるべきではありません。したがって、これはイベントハンドラの中にあるべきです。
function handleSendClick() {
sendMessage(message);
}
イベントハンドラはリアクティブではないので、sendMessage(message)
はユーザが送信ボタンをクリックしたときのみ実行されます。
エフェクト内のロジックはリアクティブである
ではこちらの行に戻りましょう。
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...
ユーザの観点からは、roomId
が変化することは、別の部屋に接続したいことを意味します。つまり、ルームに接続するためのロジックもリアクティブであるべきだ、ということです。コードがリアクティブな値の変化に「キャッチアップ」するようにし、値が変われば再度実行されるようにしたいのです。したがってこれはエフェクトの中にあるべきです。
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);
エフェクトはリアクティブなので、createConnection(serverUrl, roomId)
と connection.connect()
は、roomId
の値が変わるごとに実行されます。エフェクトにより、チャットの接続が選択中のルームに同期された状態が維持されます。
エフェクトから非リアクティブなロジックを分離する
リアクティブなロジックと非リアクティブなロジックを混在させたい場合、少し厄介なことになります。
例えば、ユーザがチャットに接続したときに通知を表示したいとします。正しい色で通知を表示することができるよう、props から現在のテーマ(ダークまたはライト)を読み取ることにしましょう。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...
しかし、theme
は(再レンダーの結果として変化する可能性があるので)リアクティブな値であり、そしてエフェクトが読み取るすべてのリアクティブな値は依存値として宣言する必要があります。というわけで theme
はエフェクトの依存配列として指定しないといけません。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId, theme]); // ✅ All dependencies declared
// ...
以下の例をいじってみてください。ユーザエクスペリエンスの問題点が分かりますか?
import { useState, useEffect } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId, theme }) { useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { showNotification('Connected!', theme); }); connection.connect(); return () => connection.disconnect(); }, [roomId, theme]); return <h1>Welcome to the {roomId} room!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <label> <input type="checkbox" checked={isDark} onChange={e => setIsDark(e.target.checked)} /> Use dark theme </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
roomId
が変わるとチャットが再接続され、それは期待通りの動作です。しかし、theme
も依存値であるため、ダークテーマとライトテーマを切り替えることでも毎回チャットが再接続されてしまっています。これはあまり良くありません!
つまり、以下の行は(リアクティブである)エフェクトの中にあるにもかかわらず、リアクティブであってほしくないということです。
// ...
showNotification('Connected!', theme);
// ...
この非リアクティブなロジックを、周囲にあるリアクティブなエフェクトのコードから分離する方法が必要です。
エフェクトイベントの宣言
useEffectEvent
という特別なフックを使うことで、エフェクトからこの非リアクティブなロジックを分離することができます。
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...
ここで、onConnected
はエフェクトイベント (Effect Event) と呼ばれるものです。これはエフェクトロジックの一部でありながら、むしろイベントハンドラに近い動作をします。この中のロジックはリアクティブではなく、常に props と state の最新の値を「見る」ことができます。
これで、エフェクトの内部から onConnected
エフェクトイベントを呼び出せるようになります。
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
onConnected();
});
connection.connect();
return () => connection.disconnect();
}, [roomId]); // ✅ All dependencies declared
// ...
これで問題は解決しました。なお、エフェクトの依存値のリストに onConnected
を入れてはいけません。エフェクトイベント自体はリアクティブではないので、依存配列から除外する必要があります。
新しい動作が期待通りであることを確認してください。
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; import { createConnection, sendMessage } from './chat.js'; import { showNotification } from './notifications.js'; const serverUrl = 'https://localhost:1234'; function ChatRoom({ roomId, theme }) { const onConnected = useEffectEvent(() => { showNotification('Connected!', theme); }); useEffect(() => { const connection = createConnection(serverUrl, roomId); connection.on('connected', () => { onConnected(); }); connection.connect(); return () => connection.disconnect(); }, [roomId]); return <h1>Welcome to the {roomId} room!</h1> } export default function App() { const [roomId, setRoomId] = useState('general'); const [isDark, setIsDark] = useState(false); return ( <> <label> Choose the chat room:{' '} <select value={roomId} onChange={e => setRoomId(e.target.value)} > <option value="general">general</option> <option value="travel">travel</option> <option value="music">music</option> </select> </label> <label> <input type="checkbox" checked={isDark} onChange={e => setIsDark(e.target.checked)} /> Use dark theme </label> <hr /> <ChatRoom roomId={roomId} theme={isDark ? 'dark' : 'light'} /> </> ); }
エフェクトイベントは、イベントハンドラと非常に似たものだと考えることができます。主な違いは、イベントハンドラがユーザの操作に反応して実行されるのに対し、エフェクトイベントはエフェクトからトリガされることです。エフェクトイベントを使うことで、リアクティブであるエフェクトと、リアクティブであってはならないコードとの間の「繋がりを断ち切る」ことができます。
エフェクトイベントで最新の props や state を読み取る
依存値に関するリンタを抑制したくなるようなパターンの多くは、エフェクトイベントによって回避可能です。
例えば、ページへの訪問をログに記録するエフェクトがあるとしましょう。
function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}
後になって、サイトに複数のページを追加することになりました。Page
コンポーネントは現在のパスを表す url
を props として受け取るようになります。この url
を logVisit
コールに渡そうと思うのですが、そこで依存値リンタが文句を言ってきます。
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, []); // 🔴 React Hook useEffect has a missing dependency: 'url'
// ...
}
コードに何をさせたいのか考えてみましょう。それぞれの URL は別々のページを表しているので、やりたいことはそれぞれの URL に対して別々に訪問ログを記録することです。言い換えれば、この logVisit
の呼び出しは、url
に関して確かにリアクティブであるべきですね。したがってこの場合、依存値リンタが言う通り、url
を依存配列に追加することは理にかなっています。
function Page({ url }) {
useEffect(() => {
logVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}
ところがここで、個々のページ訪問ログに、ショッピングカート内にある商品の数も含めたくなったとしましょう。
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
}, [url]); // 🔴 React Hook useEffect has a missing dependency: 'numberOfItems'
// ...
}
エフェクト内で numberOfItems
を使用したので、リンタは依存値としてそれを追加するように言ってきます。しかし numberOfItems
に対しては、logVisit
の呼び出しがリアクティブになることは望ましくありません。ユーザがショッピングカートに何かを入れて、numberOfItems
が変化しても、それはユーザが再びページを訪れたことを意味しません。つまり、ページを訪れたということは、ある意味で「イベント」なのです。ある特定の瞬間に起こることです。
このコードを 2 つに分割しましょう。
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
const onVisit = useEffectEvent(visitedUrl => {
logVisit(visitedUrl, numberOfItems);
});
useEffect(() => {
onVisit(url);
}, [url]); // ✅ All dependencies declared
// ...
}
ここで、onVisit
はエフェクトイベントです。この中のコードはリアクティブではありません。このため、numberOfItems
(または他のリアクティブな値!)を使用しても、変更時に周囲のコードが再実行される心配はありません。
一方、エフェクトそのものはリアクティブなままです。エフェクトの中のコードは props である url
を使用しているため、異なる url
で再レンダーが起きるたびにエフェクトが再実行されます。次にそれが onVisit
エフェクトイベントを呼び出します。
その結果、url
に変化があるごとに logVisit
が呼び出され、常に最新の numberOfItems
が読み取れることになります。一方で numberOfItems
だけが変化してもコードの再実行は起きません。
さらに深く知る
既存のコードベースで、以下のようにリントルールが抑制されているのを見かけることがあるかもしれません。
function Page({ url }) {
const { items } = useContext(ShoppingCartContext);
const numberOfItems = items.length;
useEffect(() => {
logVisit(url, numberOfItems);
// 🔴 Avoid suppressing the linter like this:
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [url]);
// ...
}
useEffectEvent
が React の安定版に含まれるようになった後は、決してリンタを抑制しないことをお勧めします。
このルールを止めてしまうことの最大の欠点は、新たにコードにリアクティブな依存値を追加してそれにエフェクトが「反応する」必要がある場合でも、もはや React が警告を表示できなくなってしまうことです。先ほどの例でも、url
を依存配列に追加し忘れずに済んだのは、そうするよう React が教えてくれていたからでしたね。リンタを無効化してしまうと、今後そのエフェクトを編集する際に、そのようなリマインダを受け取ることができなくなります。これはバグにつながります。
以下は、リンタを無効化することで発生する紛らわしいバグの一例です。この例では、handleMove
関数は、ドットがカーソルに従うべきかどうかを決定するために、canMove
という state 変数の現在値を読み取ろうとしています。しかし handleMove
の内部では canMove
は常に true
となります。
なぜかわかりますか?
import { useState, useEffect } from 'react'; export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); function handleMove(e) { if (canMove) { setPosition({ x: e.clientX, y: e.clientY }); } } useEffect(() => { window.addEventListener('pointermove', handleMove); return () => window.removeEventListener('pointermove', handleMove); // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <label> <input type="checkbox" checked={canMove} onChange={e => setCanMove(e.target.checked)} /> The dot is allowed to move </label> <hr /> <div style={{ position: 'absolute', backgroundColor: 'pink', borderRadius: '50%', opacity: 0.6, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: 'none', left: -20, top: -20, width: 40, height: 40, }} /> </> ); }
このコードの問題は、依存値リンタを無効化してしまっていることです。無効化のコメントを外すと、このエフェクトの依存値として handleMove
関数を含める必要があることがわかります。これは理にかなっています。なぜなら handleMove
はコンポーネント本体の内部で宣言されているのでリアクティブな値だからです。すべてのリアクティブな値は依存値として指定されなければなりませんし、さもなくば時間の経過とともに古くなってしまう可能性があります!
元のコードを書いた人は、React に対して「このエフェクトはどのリアクティブな値にも依存しない ([]
)」と「嘘」をついています。だから React は canMove
(とそれを使う handleMove
)が変化したのにエフェクトを再同期しなかったのです。React はエフェクトを再同期しなかったため、リスナとしてアタッチされる handleMove
は、初回レンダー時に作成された handleMove
関数のままとなります。初回レンダー時に canMove
は true
であったため、同時に作られた handleMove
からも永遠にその値が見え続けることになります。
リンタを決して抑制しないようにすれば、値が古くなることに関する問題が発生することはありません。
useEffectEvent
を使えば、リンタに「嘘」をつく必要はなく、期待通りにコードが動きます。
import { useState, useEffect } from 'react'; import { experimental_useEffectEvent as useEffectEvent } from 'react'; export default function App() { const [position, setPosition] = useState({ x: 0, y: 0 }); const [canMove, setCanMove] = useState(true); const onMove = useEffectEvent(e => { if (canMove) { setPosition({ x: e.clientX, y: e.clientY }); } }); useEffect(() => { window.addEventListener('pointermove', onMove); return () => window.removeEventListener('pointermove', onMove); }, []); return ( <> <label> <input type="checkbox" checked={canMove} onChange={e => setCanMove(e.target.checked)} /> The dot is allowed to move </label> <hr /> <div style={{ position: 'absolute', backgroundColor: 'pink', borderRadius: '50%', opacity: 0.6, transform: `translate(${position.x}px, ${position.y}px)`, pointerEvents: 'none', left: -20, top: -20, width: 40, height: 40, }} /> </> ); }
だからといって useEffectEvent
が常に正しい解決策だというわけではありません。コード中の、リアクティブにしたくない行にだけ適用するようにしてください。上記のサンドボックスでは、エフェクトコードが canMove
に関してはリアクティブであってほしくなかったため、エフェクトイベントとして抜き出すことが理にかなっていたのです。
リンタを無効化しないで済む他の方法については、エフェクトから依存値を取り除くを参照してください。
エフェクトイベントに関する制限事項
エフェクトイベントは、使い方が非常に限定されています。
- エフェクトの内部からしか呼び出すことができない。
- 他のコンポーネントやフックに渡してはいけない。
例えば、次のようにエフェクトイベントを宣言して渡してはいけません。
function Timer() {
const [count, setCount] = useState(0);
const onTick = useEffectEvent(() => {
setCount(count + 1);
});
useTimer(onTick, 1000); // 🔴 Avoid: Passing Effect Events
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
useEffect(() => {
const id = setInterval(() => {
callback();
}, delay);
return () => {
clearInterval(id);
};
}, [delay, callback]); // Need to specify "callback" in dependencies
}
上記の代わりに、エフェクトイベントは常に、それを使用するエフェクトのすぐ隣で宣言してください。
function Timer() {
const [count, setCount] = useState(0);
useTimer(() => {
setCount(count + 1);
}, 1000);
return <h1>{count}</h1>
}
function useTimer(callback, delay) {
const onTick = useEffectEvent(() => {
callback();
});
useEffect(() => {
const id = setInterval(() => {
onTick(); // ✅ Good: Only called locally inside an Effect
}, delay);
return () => {
clearInterval(id);
};
}, [delay]); // No need to specify "onTick" (an Effect Event) as a dependency
}
エフェクトイベントとは、エフェクトコードを構成する「パーツ」のうちの非リアクティブな部分です。それを使用するエフェクトの隣に置くようにしましょう。
まとめ
- イベントハンドラは、特定のユーザ操作に応答して実行される。
- エフェクトは、同期が必要になるたびに実行される。
- イベントハンドラ内のロジックは、リアクティブではない。
- エフェクト内のロジックは、リアクティブである。
- エフェクト内の非リアクティブなロジックをエフェクトイベントに移動することができる。
- エフェクトイベントを呼び出せるのはエフェクトの内部だけである。
- エフェクトイベントを他のコンポーネントやフックに渡してはいけない。
チャレンジ 1/4: 更新されない変数を修正
この Timer
コンポーネントは、1 秒ごとに値が増加する count
という state 変数を保持しています。値をいくつ増加させるのかは、increment
という state 変数に格納されます。プラスボタンとマイナスボタンで increment
変数を制御できます。
しかし、プラスボタンを何度クリックしても、カウンタは 1 秒ごとに 1 つずつ増えていきます。このコードの何が問題なのでしょうか? なぜエフェクトのコード内部では increment
が常に 1 になっているのでしょうか? 間違いを見つけて修正しましょう。
import { useState, useEffect } from 'react'; export default function Timer() { const [count, setCount] = useState(0); const [increment, setIncrement] = useState(1); useEffect(() => { const id = setInterval(() => { setCount(c => c + increment); }, 1000); return () => { clearInterval(id); }; // eslint-disable-next-line react-hooks/exhaustive-deps }, []); return ( <> <h1> Counter: {count} <button onClick={() => setCount(0)}>Reset</button> </h1> <hr /> <p> Every second, increment by: <button disabled={increment === 0} onClick={() => { setIncrement(i => i - 1); }}>–</button> <b>{increment}</b> <button onClick={() => { setIncrement(i => i + 1); }}>+</button> </p> </> ); }