イベントとエフェクトを切り離す
イベントハンドラは同じユーザ操作を再度実行した場合のみ再実行されます。エフェクトはイベントハンドラとは異なり、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
プロパティを受け取ります。この 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
を使用したので、リンタは依存値としてそれを追加するように求めます。しかし、logVisit
の呼び出しが numberOfItems
に対してリアクティブであることは望ましくありません。ユーザがショッピングカートに何かを入れて、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
(または他のリアクティブな値!)を使用しても、変更時に周囲のコードが再実行される心配はありません。
一方、エフェクトそのものはリアクティブなままです。エフェクトの中のコードは 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> </> ); }