イベントハンドラは、同じ操作を再度実行した場合にのみ再実行されます。イベントハンドラとは異なり、エフェクトは、プロップや状態変数などの読み取る値が前回のレンダリング時の値と異なる場合に再同期されます。場合によっては、両方の動作を組み合わせる必要もあります。つまり、一部の値には応答して再実行されるが、他の値には応答しないエフェクトです。このページでは、その方法について説明します。
学ぶこと
- イベントハンドラとエフェクトのどちらを選択するか
- エフェクトがリアクティブである理由、そしてイベントハンドラがリアクティブでない理由
- エフェクトのコードの一部をリアクティブにしない場合の対処法
- エフェクトイベントとは何か、そしてエフェクトからエフェクトイベントを抽出する方法
- エフェクトイベントを使用して、エフェクトから最新の props と state を読み取る方法
イベントハンドラとエフェクトの選択
まず、イベントハンドラとエフェクトの違いを再確認しましょう。
チャットルームコンポーネントを実装していると想像してください。要件は以下のようになります。
- コンポーネントは、選択されたチャットルームに自動的に接続する必要があります。
- 「送信」ボタンをクリックすると、チャットにメッセージを送信する必要があります。
既にコードを実装済みですが、どこに配置するかわからないとします。イベントハンドラとエフェクトのどちらを使用すべきでしょうか?この質問に答える必要があるときはいつでも、コードを実行する必要がある理由を検討してください。
イベントハンドラは、特定の操作に応じて実行されます
ユーザーの視点から見ると、メッセージの送信は、「送信」ボタンがクリックされたために発生する必要があります。ユーザーは、他の時間や他の理由でメッセージが送信された場合、かなり不満を抱くでしょう。そのため、メッセージの送信はイベントハンドラである必要があります。イベントハンドラを使用すると、特定のインタラクションを処理できます。
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} />} </> ); }
リアクティブな値とリアクティブなロジック
直感的には、イベントハンドラは常に「手動で」トリガーされる、例えばボタンをクリックすることによってトリガーされると言えます。一方、エフェクトは「自動的」です。同期を維持するために必要なだけ頻繁に実行および再実行されます。
これについて考えるには、より正確な方法があります。
コンポーネントの本体内で宣言されたプロップ、状態、変数は、リアクティブな値と呼ばれます。この例では、serverUrl
はリアクティブな値ではありませんが、roomId
とmessage
はリアクティブな値です。これらはレンダリングデータフローに関与します。
const serverUrl = 'https://localhost:1234';
function ChatRoom({ roomId }) {
const [message, setMessage] = useState('');
// ...
}
このようなリアクティブな値は、再レンダリングによって変化する可能性があります。たとえば、ユーザーが
を編集したり、ドロップダウンで異なるmessage
を選択したりする場合です。イベントハンドラーとEffectは、変化に異なる方法で対応します。roomId
- イベントハンドラー内のロジックはリアクティブではありません。 ユーザーが同じインタラクション(例:クリック)を再び実行しない限り、再度実行されません。イベントハンドラーは、リアクティブな値の変化に「反応」することなく、それらの値を読み取ることができます。
- Effect内のロジックはリアクティブです。 Effectがリアクティブな値を読み取る場合、依存関係として指定する必要があります。 その後、再レンダリングによってその値が変更されると、Reactは新しい値を使用してEffectのロジックを再実行します。
この違いを説明するために、前の例を再検討しましょう。
イベントハンドラー内のロジックはリアクティブではありません
このコード行を見てください。このロジックはリアクティブであるべきでしょうか、それともそうでないべきでしょうか?
// ...
sendMessage(message);
// ...
ユーザーの視点から見ると、
の変更は、メッセージを送信したいという意味ではありません。ユーザーが入力しているという意味だけです。言い換えれば、メッセージを送信するロジックはリアクティブであってはなりません。message
リアクティブな値
が変化したという理由だけで、再度実行されるべきではありません。そのため、イベントハンドラーに属します。
function handleSendClick() {
sendMessage(message);
}
イベントハンドラーはリアクティブではないため、
は、ユーザーが「送信」ボタンをクリックしたときだけ実行されます。sendMessage(message)
Effect内のロジックはリアクティブです
これらの行に戻りましょう。
// ...
const connection = createConnection(serverUrl, roomId);
connection.connect();
// ...
ユーザーの視点から見ると、
の変更は、異なる部屋に接続したいという意味です。言い換えれば、部屋への接続のロジックはリアクティブであるべきです。これらのコード行がroomId
リアクティブな値
に「追従」し、その値が異なる場合に再度実行されるようにしたいのです。そのため、Effectに属します。
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.connect();
return () => {
connection.disconnect()
};
}, [roomId]);
Effectはリアクティブであるため、
とcreateConnection(serverUrl, roomId)
は、connection.connect()
の各異なる値に対して実行されます。Effectは、チャット接続を現在選択されている部屋に同期した状態に保ちます。roomId
Effectから非リアクティブなロジックを抽出する
リアクティブなロジックと非リアクティブなロジックを混ぜ合わせたい場合、事態はさらに複雑になります。
たとえば、ユーザーがチャットに接続したときに通知を表示したいとします。適切な色で通知を表示できるように、propsから現在のテーマ(ダークまたはライト)を読み取ります。
function ChatRoom({ roomId, theme }) {
useEffect(() => {
const connection = createConnection(serverUrl, roomId);
connection.on('connected', () => {
showNotification('Connected!', theme);
});
connection.connect();
// ...
しかし、
はリアクティブな値(再レンダリングの結果として変更される可能性があります)であり、Effectによって読み取られるすべてのリアクティブな値は、その依存関係として宣言する必要があります。 そのため、theme
をEffectの依存関係として指定する必要があります。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
言い換えれば、この行はEffect(リアクティブ)の内側にあるにもかかわらず、リアクティブであるべきではありません。
// ...
showNotification('Connected!', theme);
// ...
この非リアクティブなロジックを、それを囲むリアクティブなEffectから分離する方法が必要です。
Effectイベントの宣言
この非リアクティブなロジックをEffectから抽出するには、
と呼ばれる特別なHookを使用します。useEffectEvent
import { useEffect, useEffectEvent } from 'react';
function ChatRoom({ roomId, theme }) {
const onConnected = useEffectEvent(() => {
showNotification('Connected!', theme);
});
// ...
ここで、
はEffectイベントと呼ばれます。これはEffectロジックの一部ですが、イベントハンドラーと非常によく似た動作をします。その中のロジックはリアクティブではなく、常にpropsとstateの最新の値を「認識」します。onConnected
これで、Effectの内側から
Effectイベントを呼び出すことができます。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
// ...
これで問題は解決しました。Effectの依存関係のリストから
を削除する必要があったことに注意してください。Effectイベントはリアクティブではないため、依存関係から省略する必要があります。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'} /> </> ); }
Effectイベントは、イベントハンドラーと非常によく似ていると考えてください。主な違いは、イベントハンドラーはユーザーのインタラクションに応じて実行されるのに対し、EffectイベントはEffectからトリガーされることです。Effectイベントを使用すると、Effectのリアクティブ性と、リアクティブであってはならないコードとの間の「連鎖」を断ち切ることができます。
Effectイベントを使用した最新のpropsとstateの読み取り
Effectイベントを使用すると、依存関係リンターを抑止しようとする多くのパターンを修正できます。
たとえば、ページ訪問をログに記録するEffectがあるとします。
function Page() {
useEffect(() => {
logVisit();
}, []);
// ...
}
その後、サイトに複数のルートを追加します。すると、`Page
`コンポーネントは、現在のパスを含む`url
`プロップを受け取ります。`logVisit
`呼び出しの一部として`url
`を渡したいのですが、依存関係リンターが警告します。
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'
// ...
}
Effect内で`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
`はEffectイベントです。その内部のコードはリアクティブではありません。そのため、周囲のコードが変更時に再実行されることを心配することなく、`numberOfItems
`(またはその他のリアクティブな値!)を使用できます。
一方、Effect自体はリアクティブのままです。Effect内のコードは`url
`プロップを使用するため、異なる`url
`を持つたびに再レンダリング後、Effectは再実行されます。これは、`onVisit
` Effectイベントを呼び出します。
その結果、`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の安定した機能になった後は、リンターの抑制を決して行わないことをお勧めします。
ルールの抑制の最初の欠点は、Effectがコードに導入した新しいリアクティブな依存関係に「反応」する必要がある場合に、Reactが警告しなくなることです。前の例では、Reactによって促されたため、依存関係に`url
`を追加しました。リンターを無効にすると、そのEffectに対する将来の編集に対しては、そのようなリマインダーを受け取らなくなります。これにより、バグが発生します。
これは、リンターの抑制によって発生する分かりにくいバグの例です。この例では、`handleMove
`関数は、点がカーソルに追従するかどうかを決定するために、現在の`canMove
`状態変数の値を読み取る必要があります。しかし、`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, }} /> </> ); }
このコードの問題は、依存関係リンターの抑制にあります。抑制を削除すると、このEffectは`handleMove
`関数に依存する必要があることが分かります。これは理にかなっています。`handleMove
`はコンポーネント本体内で宣言されているため、リアクティブな値です。すべてのリアクティブな値は依存関係として指定する必要があります。そうでないと、時間が経つにつれて古くなる可能性があります!
元のコードの作者は、Effectがリアクティブな値に依存していないとReactに対して「嘘をついて」います([]
)。これが、canMove
が変更された後(そしてhandleMove
も変更された後)、ReactがEffectを再同期しなかった理由です。ReactがEffectを再同期しなかったため、リスナーとしてアタッチされた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
が常に正しい解決策であるという意味ではありません。リアクティブにしたくないコード行にのみ適用する必要があります。上記のサンドボックスでは、EffectのコードをcanMove
に関してリアクティブにする必要はありませんでした。そのため、Effect Eventを抽出することが理にかなっていました。
リンターの抑制に関するその他の正しい代替手段については、「Effect依存関係の削除」をご覧ください。
Effect Eventsの制限事項
Effect Eventsの使用方法は非常に限定されています。
- Effect内からのみ呼び出してください。
- 他のコンポーネントやフックに渡さないでください。
たとえば、Effect Eventをこのように宣言して渡さないでください。
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
}
代わりに、常にEffect Eventを、それを使用するEffectの隣に直接宣言してください。
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
}
Effect Eventsは、Effectコードの非リアクティブな「部分」です。それを使用するEffectの隣に配置する必要があります。
要約
- イベントハンドラーは、特定のインタラクションに応じて実行されます。
- Effectは、同期が必要なときにいつでも実行されます。
- イベントハンドラー内のロジックはリアクティブではありません。
- Effect内のロジックはリアクティブです。
- EffectからEffect Eventsに非リアクティブなロジックを移動できます。
- Effect EventsはEffect内からのみ呼び出してください。
- Effect Eventsを他のコンポーネントやフックに渡さないでください。
課題 1の 4: 更新されない変数を修正する
このTimer
コンポーネントは、毎秒増加するcount
状態変数を保持しています。増加する値はincrement
状態変数に格納されています。increment
変数は、プラスとマイナスのボタンで制御できます。
しかし、プラスボタンを何回クリックしても、カウンターは毎秒1ずつしか増加しません。このコードの何が間違っていますか?increment
がEffectのコード内で常に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> </> ); }