イベントとエフェクトを切り離す
イベントハンドラは同じインタラクションを再度実行した場合のみ再実行されます。イベントハンドラとは異なり、エフェクトは、プロパティや 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} />} </> ); }
リアクティブな値とリアクティブなロジック
直感的に言うと、イベントハンドラは、例えばボタンをクリックするなど、常に「手動」でトリガされます。一方、エフェクトは「自動」であり、同期を保つために必要な回数だけ実行され、再実行されます。
もっと正確な考え方があります。
コンポーネントの body 内で宣言された props 、state 、変数をリアクティブ値と呼びます。この例では、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
という特別な Hook を使って、エフェクトからこの非リアクティブなロジックを抽出します:
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...
ここでは、onConnected
はエフェクトイベントと呼ばれています。これはエフェクトロジックの一部ですが、イベントハンドラにより近い動作をします。この中のロジックはリアクティブではなく、常に 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
が独自に変化しても、コードの再実行には至りません。
さらに深く知る
既存のコードベースでは、このように lint ルールが抑制されているのを見かけることがあります:
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
}
エフェクトイベントは、エフェクトのコードの中で反応しない「ピース」です。それらを使用するエフェクトの隣に置く必要があります。
まとめ
- イベントハンドラは、特定のインタラクションに応答して実行されます。
- 同期が必要なときはいつでもエフェクトが実行されます。
- イベントハンドラ内のロジックは、リアクティブではありません。
- エフェクト内のロジックは、リアクティブです。
- エフェクトの非リアクティブなロジックをエフェクトイベントに移動することができます。
- エフェクトイベントを呼び出せるのはエフェクトの内部だけです。
- エフェクトイベントを他のコンポーネントや Hooks に渡さないでください。
チャレンジ 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> </> ); }